Skip to content

feat: response interceptor #520

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Apr 18, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
82 changes: 82 additions & 0 deletions examples/response-interceptor/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
// file deepcode ignore DisablePoweredBy: example code
// file deepcode ignore UseCsurfForExpress: example code

/**
* 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>/[relative wikimedia image path]:

- 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
```
File renamed without changes.
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: 1 addition & 1 deletion src/http-proxy-middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import type { Filter, Request, RequestHandler, Response, Options } from './types
import * as httpProxy from 'http-proxy';
import { createConfig, Config } from './config-factory';
import * as contextMatcher from './context-matcher';
import * as handlers from './handlers';
import * as handlers from './_handlers';
import { getArrow, getInstance } from './logger';
import * as PathRewriter from './path-rewriter';
import * as Router from './router';
Expand Down
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