From 31d6e6c20994fab65448e1c656d431cead4555f3 Mon Sep 17 00:00:00 2001 From: Taku Amano Date: Thu, 6 Feb 2025 22:00:54 +0900 Subject: [PATCH] feat(helper/proxy): introduce proxy helper (#3589) * feat(helper/proxy): introduce proxy helper * chore(helper/proxy): expose proxy helper * test(helper/proxy): fix test name * fix(helper/proxy): return Content-Range header as it is Co-authored-by: Haochen M. Kotoi-Xie * refactor(helper/proxy): return the original content-length if the response is uncompressed * feat(middleware): enable to pass `...c.req` to init options * refactor(middleware/proxy): rename `proxyFetch` to `proxy` * docs(helper/proxy): update example * docs(helper/proxy): fix typo * refactor(helper/proxy): also accept HonoRequest instance as request init * fix(helper/proxy): remove hop-by-hop headers * refactor(helper/proxy): build request init from request * fix(helper/proxy): fix type error Co-authored-by: Yusuke Wada * test(helper/proxy): add test for modify header * fix(helper/proxy): It is generally the Set-Cookie that should be removed from the response header --------- Co-authored-by: Haochen M. Kotoi-Xie Co-authored-by: Yusuke Wada --- jsr.json | 1 + package.json | 8 ++ src/helper/proxy/index.test.ts | 183 +++++++++++++++++++++++++++++++++ src/helper/proxy/index.ts | 117 +++++++++++++++++++++ 4 files changed, 309 insertions(+) create mode 100644 src/helper/proxy/index.test.ts create mode 100644 src/helper/proxy/index.ts diff --git a/jsr.json b/jsr.json index c3b3559a6..8856f937d 100644 --- a/jsr.json +++ b/jsr.json @@ -81,6 +81,7 @@ "./dev": "./src/helper/dev/index.ts", "./ws": "./src/helper/websocket/index.ts", "./conninfo": "./src/helper/conninfo/index.ts", + "./proxy": "./src/helper/proxy/index.ts", "./utils/body": "./src/utils/body.ts", "./utils/buffer": "./src/utils/buffer.ts", "./utils/color": "./src/utils/color.ts", diff --git a/package.json b/package.json index 42c9e74d9..21446a394 100644 --- a/package.json +++ b/package.json @@ -388,6 +388,11 @@ "types": "./dist/types/helper/conninfo/index.d.ts", "import": "./dist/helper/conninfo/index.js", "require": "./dist/cjs/helper/conninfo/index.js" + }, + "./proxy": { + "types": "./dist/types/helper/proxy/index.d.ts", + "import": "./dist/helper/proxy/index.js", + "require": "./dist/cjs/helper/proxy/index.js" } }, "typesVersions": { @@ -595,6 +600,9 @@ ], "conninfo": [ "./dist/types/helper/conninfo" + ], + "proxy": [ + "./dist/types/helper/proxy" ] } }, diff --git a/src/helper/proxy/index.test.ts b/src/helper/proxy/index.test.ts new file mode 100644 index 000000000..6d3213873 --- /dev/null +++ b/src/helper/proxy/index.test.ts @@ -0,0 +1,183 @@ +import { Hono } from '../../hono' +import { proxy } from '.' + +describe('Proxy Middleware', () => { + describe('proxy', () => { + beforeEach(() => { + global.fetch = vi.fn().mockImplementation(async (req) => { + if (req.url === 'https://example.com/compressed') { + return Promise.resolve( + new Response('ok', { + headers: { + 'Content-Encoding': 'gzip', + 'Content-Length': '1', + 'Content-Range': 'bytes 0-2/1024', + 'X-Response-Id': '456', + }, + }) + ) + } else if (req.url === 'https://example.com/uncompressed') { + return Promise.resolve( + new Response('ok', { + headers: { + 'Content-Length': '2', + 'Content-Range': 'bytes 0-2/1024', + 'X-Response-Id': '456', + }, + }) + ) + } else if (req.url === 'https://example.com/post' && req.method === 'POST') { + return Promise.resolve(new Response(`request body: ${await req.text()}`)) + } else if (req.url === 'https://example.com/hop-by-hop') { + return Promise.resolve( + new Response('ok', { + headers: { + 'Transfer-Encoding': 'chunked', + }, + }) + ) + } else if (req.url === 'https://example.com/set-cookie') { + return Promise.resolve( + new Response('ok', { + headers: { + 'Set-Cookie': 'test=123', + }, + }) + ) + } + return Promise.resolve(new Response('not found', { status: 404 })) + }) + }) + + it('compressed', async () => { + const app = new Hono() + app.get('/proxy/:path', (c) => + proxy( + new Request(`https://example.com/${c.req.param('path')}`, { + headers: { + 'X-Request-Id': '123', + 'Accept-Encoding': 'gzip', + }, + }) + ) + ) + const res = await app.request('/proxy/compressed') + const req = (global.fetch as ReturnType).mock.calls[0][0] + + expect(req.url).toBe('https://example.com/compressed') + expect(req.headers.get('X-Request-Id')).toBe('123') + expect(req.headers.get('Accept-Encoding')).toBeNull() + + expect(res.status).toBe(200) + expect(res.headers.get('X-Response-Id')).toBe('456') + expect(res.headers.get('Content-Encoding')).toBeNull() + expect(res.headers.get('Content-Length')).toBeNull() + expect(res.headers.get('Content-Range')).toBe('bytes 0-2/1024') + }) + + it('uncompressed', async () => { + const app = new Hono() + app.get('/proxy/:path', (c) => + proxy( + new Request(`https://example.com/${c.req.param('path')}`, { + headers: { + 'X-Request-Id': '123', + 'Accept-Encoding': 'gzip', + }, + }) + ) + ) + const res = await app.request('/proxy/uncompressed') + const req = (global.fetch as ReturnType).mock.calls[0][0] + + expect(req.url).toBe('https://example.com/uncompressed') + expect(req.headers.get('X-Request-Id')).toBe('123') + expect(req.headers.get('Accept-Encoding')).toBeNull() + + expect(res.status).toBe(200) + expect(res.headers.get('X-Response-Id')).toBe('456') + expect(res.headers.get('Content-Length')).toBe('2') + expect(res.headers.get('Content-Range')).toBe('bytes 0-2/1024') + }) + + it('POST request', async () => { + const app = new Hono() + app.all('/proxy/:path', (c) => { + return proxy(`https://example.com/${c.req.param('path')}`, { + ...c.req, + headers: { + ...c.req.header(), + 'X-Request-Id': '123', + 'Accept-Encoding': 'gzip', + }, + }) + }) + const res = await app.request('/proxy/post', { + method: 'POST', + body: 'test', + }) + const req = (global.fetch as ReturnType).mock.calls[0][0] + + expect(req.url).toBe('https://example.com/post') + + expect(res.status).toBe(200) + expect(await res.text()).toBe('request body: test') + }) + + it('remove hop-by-hop headers', async () => { + const app = new Hono() + app.get('/proxy/:path', (c) => proxy(`https://example.com/${c.req.param('path')}`)) + + const res = await app.request('/proxy/hop-by-hop', { + headers: { + Connection: 'keep-alive', + 'Keep-Alive': 'timeout=5, max=1000', + 'Proxy-Authorization': 'Basic 123456', + }, + }) + const req = (global.fetch as ReturnType).mock.calls[0][0] + + expect(req.headers.get('Connection')).toBeNull() + expect(req.headers.get('Keep-Alive')).toBeNull() + expect(req.headers.get('Proxy-Authorization')).toBeNull() + + expect(res.headers.get('Transfer-Encoding')).toBeNull() + }) + + it('specify hop-by-hop header by options', async () => { + const app = new Hono() + app.get('/proxy/:path', (c) => + proxy(`https://example.com/${c.req.param('path')}`, { + headers: { + 'Proxy-Authorization': 'Basic 123456', + }, + }) + ) + + const res = await app.request('/proxy/hop-by-hop') + const req = (global.fetch as ReturnType).mock.calls[0][0] + + expect(req.headers.get('Proxy-Authorization')).toBe('Basic 123456') + + expect(res.headers.get('Transfer-Encoding')).toBeNull() + }) + + it('modify header', async () => { + const app = new Hono() + app.get('/proxy/:path', (c) => + proxy(`https://example.com/${c.req.param('path')}`, { + headers: { + 'Set-Cookie': 'test=123', + }, + }).then((res) => { + res.headers.delete('Set-Cookie') + res.headers.set('X-Response-Id', '456') + return res + }) + ) + const res = await app.request('/proxy/set-cookie') + expect(res.headers.get('Set-Cookie')).toBeNull() + expect(res.headers.get('X-Response-Id')).toBe('456') + }) + }) +}) diff --git a/src/helper/proxy/index.ts b/src/helper/proxy/index.ts new file mode 100644 index 000000000..9bfed6f73 --- /dev/null +++ b/src/helper/proxy/index.ts @@ -0,0 +1,117 @@ +/** + * @module + * Proxy Helper for Hono. + */ + +import type { RequestHeader } from '../../utils/headers' + +// https://datatracker.ietf.org/doc/html/rfc2616#section-13.5.1 +const hopByHopHeaders = [ + 'connection', + 'keep-alive', + 'proxy-authenticate', + 'proxy-authorization', + 'te', + 'trailers', + 'transfer-encoding', +] + +interface ProxyRequestInit extends Omit { + raw?: Request + headers?: + | HeadersInit + | [string, string][] + | Record + | Record +} + +interface ProxyFetch { + (input: string | URL | Request, init?: ProxyRequestInit): Promise +} + +const buildRequestInitFromRequest = ( + request: Request | undefined +): RequestInit & { duplex?: 'half' } => { + if (!request) { + return {} + } + + const headers = new Headers(request.headers) + hopByHopHeaders.forEach((header) => { + headers.delete(header) + }) + + return { + method: request.method, + body: request.body, + duplex: request.body ? 'half' : undefined, + headers, + } +} + +/** + * Fetch API wrapper for proxy. + * The parameters and return value are the same as for `fetch` (except for the proxy-specific options). + * + * The “Accept-Encoding” header is replaced with an encoding that the current runtime can handle. + * Unnecessary response headers are deleted and a Response object is returned that can be returned + * as is as a response from the handler. + * + * @example + * ```ts + * app.get('/proxy/:path', (c) => { + * return proxy(`http://${originServer}/${c.req.param('path')}`, { + * headers: { + * ...c.req.header(), // optional, specify only when forwarding all the request data (including credentials) is necessary. + * 'X-Forwarded-For': '127.0.0.1', + * 'X-Forwarded-Host': c.req.header('host'), + * Authorization: undefined, // do not propagate request headers contained in c.req.header('Authorization') + * }, + * }).then((res) => { + * res.headers.delete('Set-Cookie') + * return res + * }) + * }) + * + * app.all('/proxy/:path', (c) => { + * return proxy(`http://${originServer}/${c.req.param('path')}`, { + * ...c.req, // optional, specify only when forwarding all the request data (including credentials) is necessary. + * headers: { + * ...c.req.header(), + * 'X-Forwarded-For': '127.0.0.1', + * 'X-Forwarded-Host': c.req.header('host'), + * Authorization: undefined, // do not propagate request headers contained in c.req.header('Authorization') + * }, + * }) + * }) + * ``` + */ +export const proxy: ProxyFetch = async (input, proxyInit) => { + const { raw, ...requestInit } = proxyInit ?? {} + + const req = new Request( + input, + // @ts-expect-error `headers` in `requestInit` is not compatible with HeadersInit + { + ...buildRequestInitFromRequest(raw), + ...requestInit, + } + ) + req.headers.delete('accept-encoding') + + const res = await fetch(req) + const resHeaders = new Headers(res.headers) + hopByHopHeaders.forEach((header) => { + resHeaders.delete(header) + }) + if (resHeaders.has('content-encoding')) { + resHeaders.delete('content-encoding') + // Content-Length is the size of the compressed content, not the size of the original content + resHeaders.delete('content-length') + } + + return new Response(res.body, { + ...res, + headers: resHeaders, + }) +}