Skip to content
Open
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
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,4 @@ coverage/
run/
.DS_Store
*.swp

.vscode
2 changes: 1 addition & 1 deletion .travis.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
sudo: false

language: node_js
node_js:
- '10'
Expand Down
41 changes: 35 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ A simple http proxy base on egg httpclient.


```bash
$ npm i @eggjs/http-proxy --save
$ npm i --save @eggjs/http-proxy
```

```js
Expand All @@ -37,6 +37,23 @@ exports.httpProxy = {
};
```

## Configuration

```js
// {app_root}/config/config.default.js

/**
* @property {Number} timeout - proxy timeout, ms
* @property {Boolean} withCredentials - whether send cookie when cors
* @property {Boolean} cache - whether cache proxy
* @property {Object} cacheOptions - cache options, see https://www.npmjs.com/package/lru-cache
* @property {Object} ignoreHeaders - ignore request/response headers
*/
exports.httpProxy = {};
```

see [config/config.default.js](config/config.default.js) for more detail.

## Usage

```js
Expand Down Expand Up @@ -76,23 +93,35 @@ await ctx.proxyRequest('github.com', {

async beforeResponse(proxyResult) {
proxyResult.headers.addition = 'true';
// streaming=false should modify `data`, otherwise use stream to handler proxyResult.res yourself
// use streaming=false, then modify `data`,
// otherwise handler `proxyResult.res` as stream yourself, but don't forgot to adjuest content-length
proxyResult.data = proxyResult.data.replace('github.com', 'www.github.com');
return proxyResult;
},
});
```

## Configuration
### cache

```js
// {app_root}/config/config.default.js
exports.httpProxy = {

cache: false,
cacheOptions: {
// get cache id, if undefined then don't cache the request
// calcId(targetUrl, options) {
// if (options.method === 'GET') return targetUrl;
// },
// maxAge: 1000 * 60 * 60,
// max: 100,
},
};
```

see [config/config.default.js](config/config.default.js) for more detail.
control cache case by case:

```js
await ctx.proxyRequest('github.com', { cache: true, maxAge: 24 * 60 * 60 * 1000 });
```

## Questions & Suggestions

Expand Down
9 changes: 9 additions & 0 deletions app/extend/application.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,16 @@
'use strict';

const HttpProxy = require('../../lib/http_proxy');
const CacheManager = require('../../lib/cache_manager');

const INSTANCE = Symbol('Application#httpProxyCache');

module.exports = {
HttpProxy,
get httpProxyCache() {
if (!this[INSTANCE]) {
this[INSTANCE] = new CacheManager(this);
}
return this[INSTANCE];
},
};
2 changes: 1 addition & 1 deletion app/extend/context.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use strict';

const HTTPPROXY = Symbol('context#httpProxy');
const HTTPPROXY = Symbol('Context#httpProxy');

module.exports = {
/**
Expand Down
13 changes: 13 additions & 0 deletions config/config.default.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,26 @@
* @member Config#httpProxy
* @property {Number} timeout - proxy timeout, ms
* @property {Boolean} withCredentials - whether send cookie when cors
* @property {Boolean} cache - whether cache proxy
* @property {Object} cacheOptions - cache options, see https://www.npmjs.com/package/lru-cache
* @property {Object} ignoreHeaders - ignore request/response headers
*/
exports.httpProxy = {
timeout: 10 * 1000,

withCredentials: false,

cache: false,
cacheOptions: {
// only cache GET by default
calcId(targetUrl, options) {
if (options.method === 'GET') return targetUrl;
},
// cache 1 min by default
maxAge: 1000 * 60 * 60,
max: 100,
},

charsetHeaders: '_input_charset',

ignoreHeaders: {
Expand Down
30 changes: 30 additions & 0 deletions lib/cache_manager.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
'use strict';

const assert = require('assert');
const LRU = require('lru-cache');

class HttpProxyCacheManager {
constructor(app) {
this.app = app;
this.config = app.config.httpProxy.cacheOptions;
assert(this.config.calcId, 'config.httpProxy.cacheOptions.calcId is required to be a function');

this.cache = new LRU(this.config);
}

calcId(targetUrl, options) {
return this.config.calcId(targetUrl, options);
}

get(key) {
return this.cache.get(key);
}

set(key, value, options) {
value.res = undefined;
assert(value.data, 'only cache `data`, please use `streaming: false`');
return this.cache.set(key, value, options.maxAge);
}
}

module.exports = HttpProxyCacheManager;
57 changes: 42 additions & 15 deletions lib/http_proxy.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
'use strict';

const { URL } = require('url');
const Stream = require('stream');
const FormStream = require('formstream');
const ContentType = require('content-type');
const address = require('address');
Expand Down Expand Up @@ -41,6 +42,7 @@ class HttpProxy {
};

if (options.withCredentials === undefined) options.withCredentials = this.config.withCredentials;
if (options.cache === undefined) options.cache = this.config.cache;

let urlObj = new URL(ctx.href);
urlObj.host = host;
Expand Down Expand Up @@ -123,18 +125,43 @@ class HttpProxy {
}
}

// send request
const targetUrl = urlObj.toString();
let proxyResult;
try {
proxyResult = await ctx.curl(targetUrl, options);
this.logger.info(`forward:success, status:${proxyResult.status}, targetUrl:${targetUrl}`);
} catch (err) {
this.logger.warn(`forward:fail, status:${err.status}, targetUrl:${targetUrl}`);
throw err;

// check cache
let cacheId;
let hitCache;
if (options.cache) {
cacheId = this.app.httpProxyCache.calcId(targetUrl, options);
if (cacheId) {
proxyResult = this.app.httpProxyCache.get(cacheId);
if (proxyResult) {
ctx.set('x-proxy-cache', true);
hitCache = true;
}
}
}

// send request
if (!hitCache) {
try {
proxyResult = await ctx.curl(targetUrl, options);

if (options.beforeResponse) proxyResult = await options.beforeResponse(proxyResult);

// store cache
if (cacheId) {
this.app.httpProxyCache.set(cacheId, proxyResult, options);
}
} catch (err) {
this.logger.warn(`forward:fail, status:${err.status}, targetUrl:${targetUrl}`);
throw err;
}
}

if (options.beforeResponse) proxyResult = await options.beforeResponse(proxyResult);
this.logger.info(`forward:success, status:${proxyResult.status}, targetUrl:${targetUrl}, hitCache: ${hitCache}`);

// send response
const { headers, status, data, res } = proxyResult;

for (const key of Object.keys(headers)) {
Expand All @@ -145,20 +172,20 @@ class HttpProxy {
ctx.status = status;

// avoid egg middleware post-handler to override headers, such as x-frame-options
if (data) {
let body = data;
let body = data || res;
if (body instanceof Stream) {
ctx.respond = false;
ctx.res.flushHeaders();
body.pipe(ctx.res);
} else {
if (!Buffer.isBuffer(body) && typeof body !== 'string') {
// body: json
body = JSON.stringify(body);
ctx.length = Buffer.byteLength(body);
}
ctx.length = Buffer.byteLength(body);
ctx.respond = false;
ctx.res.flushHeaders();
ctx.res.end(body);
} else {
ctx.respond = false;
ctx.res.flushHeaders();
res.pipe(ctx.res);
}
}
}
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@
"dependencies": {
"address": "^1.1.2",
"content-type": "^1.0.4",
"formstream": "^1.1.0"
"formstream": "^1.1.0",
"lru-cache": "^5.1.1"
},
"devDependencies": {
"async-busboy": "^1.0.1",
Expand Down
87 changes: 87 additions & 0 deletions test/cache.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
'use strict';

const mock = require('egg-mock');
const assert = require('assert');
const mockServer = require('./fixtures/mock_server');

describe('test/cache.test.js', () => {
let app;
let cache;

before(async () => {
app = mock.app({
baseDir: 'apps/cache',
});
await app.ready();
cache = app.httpProxyCache.cache;
});

beforeEach(() => mockServer.mock());
afterEach(() => {
cache.reset();
mockServer.restore();
});

after(() => app.close());
afterEach(mock.restore);

it('should cache', async () => {
let res = await app.httpRequest()
.get('/proxy')
.query('name=tz')
.expect(200);

assert(res.body.path = '/?name=tz');
assert(!res.headers['x-proxy-cache']);
assert(cache.has('http://example.com/?name=tz'));
// not save res
assert(!cache.get('http://example.com/?name=tz').res);

res = await app.httpRequest()
.get('/proxy')
.query('name=tz')
.expect(200);

assert(res.headers['x-proxy-cache']);
assert(res.body.path = '/?name=tz');
});

it('should not cache POST', async () => {
let res = await app.httpRequest()
.post('/proxy/json')
.set('cookie', 'csrfToken=abc')
.set('x-csrf-token', 'abc')
.send({ a: 'b' })
.expect(200);

assert(res.body.requestBody.a === 'b');
assert(!res.headers['x-proxy-cache']);
assert(cache.length === 0);

mockServer.restore();
mockServer.mock();

res = await app.httpRequest()
.post('/proxy/json')
.set('cookie', 'csrfToken=abc')
.set('x-csrf-token', 'abc')
.send({ a: 'b' })
.expect(200);

assert(res.body.requestBody.a === 'b');
assert(!res.headers[ 'x-proxy-cache' ]);
assert(cache.length === 0);
});

it('should not cache when options.cache = false', async () => {
const res = await app.httpRequest()
.get('/proxy/nocache')
.query('name=tz')
.expect(200);

assert(res.body.path = '/?name=tz');
assert(!res.headers['x-proxy-cache']);

assert(cache.length === 0);
});
});
Loading