Skip to content

Commit

Permalink
feat(handler): response interceptor
Browse files Browse the repository at this point in the history
  • Loading branch information
chimurai committed Apr 14, 2021
1 parent 3aaf63d commit 3880639
Show file tree
Hide file tree
Showing 12 changed files with 407 additions and 2 deletions.
Binary file added .github/docs/response-interceptor-lenna.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# Changelog

## next

- feat(handler): response interceptor

## [v1.1.2](https://github.com/chimurai/http-proxy-middleware/releases/tag/v1.1.2)

- fix(log error): handle optional target ([#523](https://github.com/chimurai/http-proxy-middleware/pull/523))
Expand Down
35 changes: 35 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ _All_ `http-proxy` [options](https://github.com/nodejitsu/node-http-proxy#option
- [app.use\(path, proxy\)](#appusepath-proxy)
- [WebSocket](#websocket)
- [External WebSocket upgrade](#external-websocket-upgrade)
- [Intercept and manipulate responses](#intercept-and-manipulate-responses)
- [Working examples](#working-examples)
- [Recipes](#recipes)
- [Compatible servers](#compatible-servers)
Expand Down Expand Up @@ -481,6 +482,39 @@ const server = app.listen(3000);
server.on('upgrade', wsProxy.upgrade); // <-- subscribe to http 'upgrade'
```
## Intercept and manipulate responses
Intercept responses from upstream with `responseInterceptor`. (Make sure to set `selfHandleResponse: true`)
Responses which are compressed with `brotli`, `gzip` and `deflate` will be decompressed automatically. The response will be returned as `buffer` ([docs](https://nodejs.org/api/buffer.html)) which you can manipulate.
With `buffer`, response manipulation is not limited to text responses (html/css/js, etc...); image manipulation will be possible too. ([example](https://github.com/chimurai/http-proxy-middleware/blob/response-interceptor/recipes/response-interceptor.md#manipulate-image-response))
NOTE: `responseInterceptor` disables streaming of target's response.
Example:
```javascript
const { createProxyMiddleware, responseInterceptor } = require('http-proxy-middleware');

const proxy = createProxyMiddleware({
/**
* IMPORTANT: avoid res.end being called automatically
**/
selfHandleResponse: true, // res.end() will be called internally by responseInterceptor()

/**
* Intercept response and replace 'Hello' with 'Goodbye'
**/
onProxyRes: responseInterceptor(async (responseBuffer, proxyRes, req, res) => {
const response = responseBuffer.toString('utf-8'); // convert buffer to string
return response.replace('Hello', 'Goodbye'); // manipulate response and return the result
}),
});
```
Check out [interception recipes](https://github.com/chimurai/http-proxy-middleware/blob/response-interceptor/recipes/response-interceptor.md#readme) for more examples.
## Working examples
View and play around with [working examples](https://github.com/chimurai/http-proxy-middleware/tree/master/examples).
Expand All @@ -489,6 +523,7 @@ View and play around with [working examples](https://github.com/chimurai/http-pr
- express ([example source](https://github.com/chimurai/http-proxy-middleware/tree/master/examples/express/index.js))
- connect ([example source](https://github.com/chimurai/http-proxy-middleware/tree/master/examples/connect/index.js))
- WebSocket ([example source](https://github.com/chimurai/http-proxy-middleware/tree/master/examples/websocket/index.js))
- Response Manipulation ([example source](https://github.com/chimurai/http-proxy-middleware/blob/master/response-interceptor/examples/response-interceptor/index.js))
## Recipes
Expand Down
79 changes: 79 additions & 0 deletions examples/response-interceptor/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
/**
* Module dependencies.
*/
const express = require('express');
const { createProxyMiddleware, responseInterceptor } = require('../../dist'); // require('http-proxy-middleware');

// test with double-byte characters
const favoriteFoods = [
{
country: 'NL',
food: 'Kroket',
},
{
country: 'HK',
food: '叉燒包',
},
{
country: 'US',
food: 'Hamburger',
},
{
country: 'TH',
food: 'ส้มตำไทย',
},
{
country: 'IN',
food: 'बटर चिकन',
},
];

/**
* Configure proxy middleware
*/
const jsonPlaceholderProxy = createProxyMiddleware({
target: 'http://jsonplaceholder.typicode.com',
router: {
'/users': 'http://jsonplaceholder.typicode.com',
'/brotli': 'http://httpbin.org',
'/gzip': 'http://httpbin.org',
'/deflate': 'http://httpbin.org',
},
changeOrigin: true, // for vhosted sites, changes host header to match to target's host
selfHandleResponse: true, // manually call res.end(); IMPORTANT: res.end() is called internally by responseInterceptor()
onProxyRes: responseInterceptor(async (buffer, proxyRes, req, res) => {
// log original request and proxied request info
const exchange = `[DEBUG] ${req.method} ${req.path} -> ${proxyRes.req.protocol}//${proxyRes.req.host}${proxyRes.req.path} [${proxyRes.statusCode}]`;
console.log(exchange);

// log original response
// console.log(`[DEBUG] original response:\n${buffer.toString('utf-8')}`);

// set response content-type
res.setHeader('content-type', 'application/json; charset=utf-8');

// set response status code
res.statusCode = 418;

// return a complete different response
return JSON.stringify(favoriteFoods);
}),
logLevel: 'debug',
});

const app = express();

/**
* Add the proxy to express
*/
app.use(jsonPlaceholderProxy);

app.listen(3000);

console.log('[DEMO] Server: listening on port 3000');
console.log('[DEMO] Open: http://localhost:3000/users');
console.log('[DEMO] Open: http://localhost:3000/brotli');
console.log('[DEMO] Open: http://localhost:3000/gzip');
console.log('[DEMO] Open: http://localhost:3000/deflate');

require('open')('http://localhost:3000/users');
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "http-proxy-middleware",
"version": "1.1.2",
"version": "1.2.0-beta.2",
"description": "The one-liner node.js proxy middleware for connect, express and browser-sync",
"main": "dist/index.js",
"types": "dist/index.d.ts",
Expand Down
130 changes: 130 additions & 0 deletions recipes/response-interceptor.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
# Response Interceptor

Intercept responses from upstream with `responseInterceptor`. (Make sure to set `selfHandleResponse: true`)

Responses which are compressed with `brotli`, `gzip` and `deflate` will be decompressed automatically. Response will be made available as [`buffer`](https://nodejs.org/api/buffer.html) which you can manipulate.

## Replace text and change http status code

```js
const { createProxyMiddleware, responseInterceptor } = require('http-proxy-middleware');

const proxy = createProxyMiddleware({
target: 'http://www.example.com',
changeOrigin: true, // for vhosted sites

/**
* IMPORTANT: avoid res.end being called automatically
**/
selfHandleResponse: true, // res.end() will be called internally by responseInterceptor()

/**
* Intercept response and replace 'Hello' with 'Teapot' with 418 http response status code
**/
onProxyRes: responseInterceptor(async (responseBuffer, proxyRes, req, res) => {
res.statusCode = 418; // set different response status code

const response = responseBuffer.toString('utf-8');
return response.replace('Hello', 'Teapot');
}),
});
```

## Log request and response

```javascript
const proxy = createProxyMiddleware({
target: 'http://www.example.com',
changeOrigin: true, // for vhosted sites

selfHandleResponse: true, // res.end() will be called internally by responseInterceptor()

onProxyRes: responseInterceptor(async (responseBuffer, proxyRes, req, res) => {
// log original request and proxied request info
const exchange = `[DEBUG] ${req.method} ${req.path} -> ${proxyRes.req.protocol}//${proxyRes.req.host}${proxyRes.req.path} [${proxyRes.statusCode}]`;
console.log(exchange); // [DEBUG] GET / -> http://www.example.com [200]

// log complete response
const response = responseBuffer.toString('utf-8');
console.log(response); // log response body

return responseBuffer;
}),
});
```

## Manipulate JSON responses (application/json)

```javascript
const proxy = createProxyMiddleware({
target: 'http://jsonplaceholder.typicode.com',
changeOrigin: true, // for vhosted sites

selfHandleResponse: true, // res.end() will be called internally by responseInterceptor()

onProxyRes: responseInterceptor(async (responseBuffer, proxyRes, req, res) => {
// detect json responses
if (proxyRes.headers['content-type'] === 'application/json') {
let data = JSON.parse(responseBuffer.toString('utf-8'));

// manipulate JSON data here
data = Object.assign({}, data, { extra: 'foo bar' });

// return manipulated JSON
return JSON.stringify(data);
}

// return other content-types as-is
return responseBuffer;
}),
});
```

## Manipulate image response

Example [Lenna](https://en.wikipedia.org/wiki/Lenna) image: <https://upload.wikimedia.org/wikipedia/en/7/7d/Lenna_%28test_image%29.png>

Proxy and manipulate image (flip, sepia, pixelate).

[![Image of Lenna](../.github/docs/response-interceptor-lenna.png)](https://codesandbox.io/s/trusting-engelbart-03rjl)

Check [source code](https://codesandbox.io/s/trusting-engelbart-03rjl) on codesandbox.

Some working examples on <https://03rjl.sse.codesandbox.io>:

- Lenna - ([manipulated](https://03rjl.sse.codesandbox.io/wikipedia/en/7/7d/Lenna_%28test_image%29.png)) ([original](https://upload.wikimedia.org/wikipedia/en/7/7d/Lenna_%28test_image%29.png)).
- Starry Night - ([manipulated](https://03rjl.sse.codesandbox.io/wikipedia/commons/thumb/e/ea/Van_Gogh_-_Starry_Night_-_Google_Art_Project.jpg/1024px-Van_Gogh_-_Starry_Night_-_Google_Art_Project.jpg)) ([original](https://upload.wikimedia.org/wikipedia/commons/thumb/e/ea/Van_Gogh_-_Starry_Night_-_Google_Art_Project.jpg/1024px-Van_Gogh_-_Starry_Night_-_Google_Art_Project.jpg)).
- Mona Lisa - ([manipulated](https://03rjl.sse.codesandbox.io/wikipedia/commons/thumb/e/ec/Mona_Lisa%2C_by_Leonardo_da_Vinci%2C_from_C2RMF_retouched.jpg/800px-Mona_Lisa%2C_by_Leonardo_da_Vinci%2C_from_C2RMF_retouched.jpg)) ([original](https://upload.wikimedia.org/wikipedia/commons/thumb/e/ec/Mona_Lisa%2C_by_Leonardo_da_Vinci%2C_from_C2RMF_retouched.jpg/800px-Mona_Lisa%2C_by_Leonardo_da_Vinci%2C_from_C2RMF_retouched.jpg)).

_You can just use any relative image path from <https://upload.wikimedia.org> and use the relative image path on <https://03rjl.sse.codesandbox.io> to see the manipulated image._

```javascript
const Jimp = require('jimp'); // use jimp libray for image manipulation

const proxy = createProxyMiddleware({
target: 'https://upload.wikimedia.org',
changeOrigin: true, // for vhosted sites

selfHandleResponse: true, // res.end() will be called internally by responseInterceptor()

onProxyRes: responseInterceptor(async (responseBuffer, proxyRes, req, res) => {
const imageTypes = ['image/png', 'image/jpg', 'image/jpeg', 'image/gif'];

// detect image responses
if (imageTypes.includes(proxyRes.headers['content-type'])) {
try {
const image = await Jimp.read(responseBuffer);
image.flip(true, false).sepia().pixelate(5);
return image.getBufferAsync(Jimp.AUTO);
} catch (err) {
console.log('image processing error: ', err);
return responseBuffer;
}
}

return responseBuffer; // return other content-types as-is
}),
});

// http://localhost:3000/wikipedia/en/7/7d/Lenna\_%28test_image%29.png
```
1 change: 1 addition & 0 deletions src/handlers/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './public';
1 change: 1 addition & 0 deletions src/handlers/public.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { responseInterceptor } from './response-interceptor';
81 changes: 81 additions & 0 deletions src/handlers/response-interceptor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import type * as http from 'http';
import * as zlib from 'zlib';

type Interceptor = (
buffer: Buffer,
proxyRes: http.IncomingMessage,
req: http.IncomingMessage,
res: http.ServerResponse
) => Promise<Buffer | string>;

/**
* Intercept responses from upstream.
* Automatically decompress (deflate, gzip, brotli).
* Give developer the opportunity to modify intercepted Buffer and http.ServerResponse
*
* NOTE: must set options.selfHandleResponse=true (prevent automatic call of res.end())
*/
export function responseInterceptor(interceptor: Interceptor) {
return async function proxyRes(
proxyRes: http.IncomingMessage,
req: http.IncomingMessage,
res: http.ServerResponse
): Promise<void> {
const originalProxyRes = proxyRes;
let buffer = Buffer.from('', 'utf8');

// decompress proxy response
const _proxyRes = decompress(proxyRes, proxyRes.headers['content-encoding']);

// concat data stream
_proxyRes.on('data', (chunk) => (buffer = Buffer.concat([buffer, chunk])));

_proxyRes.on('end', async () => {
// set original content type from upstream
res.setHeader('content-type', originalProxyRes.headers['content-type'] || '');

// call interceptor with intercepted response (buffer)
const interceptedBuffer = Buffer.from(await interceptor(buffer, originalProxyRes, req, res));

// set correct content-length (with double byte character support)
res.setHeader('content-length', Buffer.byteLength(interceptedBuffer, 'utf8'));

res.write(interceptedBuffer);
res.end();
});

_proxyRes.on('error', (error) => {
res.end(`Error fetching proxied request: ${error.message}`);
});
};
}

/**
* Streaming decompression of proxy response
* source: https://github.com/apache/superset/blob/9773aba522e957ed9423045ca153219638a85d2f/superset-frontend/webpack.proxy-config.js#L116
*/
function decompress(proxyRes: http.IncomingMessage, contentEncoding: string) {
let _proxyRes = proxyRes;
let decompress;

switch (contentEncoding) {
case 'gzip':
decompress = zlib.createGunzip();
break;
case 'br':
decompress = zlib.createBrotliDecompress();
break;
case 'deflate':
decompress = zlib.createInflate();
break;
default:
break;
}

if (decompress) {
_proxyRes.pipe(decompress);
_proxyRes = decompress;
}

return _proxyRes;
}
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,6 @@ export function createProxyMiddleware(context: Filter | Options, options?: Optio
return middleware;
}

export * from './handlers';

export { Filter, Options, RequestHandler } from './types';
2 changes: 1 addition & 1 deletion test/e2e/_utils.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import * as express from 'express';
import { Express, RequestHandler } from 'express';

export { createProxyMiddleware } from '../../dist/index';
export { createProxyMiddleware, responseInterceptor } from '../../dist/index';

export function createApp(middleware: RequestHandler): Express {
const app = express();
Expand Down
Loading

0 comments on commit 3880639

Please sign in to comment.