From dcfa1a53485f357bceeb158e26090a855c06cd8e Mon Sep 17 00:00:00 2001 From: Taku Amano Date: Wed, 30 Oct 2024 09:25:19 +0900 Subject: [PATCH 01/15] feat(helper/proxy): introduce proxy helper --- src/helper/proxy/index.test.ts | 112 +++++++++++++++++++++++++++++++++ src/helper/proxy/index.ts | 85 +++++++++++++++++++++++++ 2 files changed, 197 insertions(+) create mode 100644 src/helper/proxy/index.test.ts create mode 100644 src/helper/proxy/index.ts diff --git a/src/helper/proxy/index.test.ts b/src/helper/proxy/index.test.ts new file mode 100644 index 000000000..16afd6eaa --- /dev/null +++ b/src/helper/proxy/index.test.ts @@ -0,0 +1,112 @@ +import { Hono } from '../../hono' +import { proxyFetch } from '.' + +describe('Proxy Middleware', () => { + describe('proxyFetch', () => { + beforeEach(() => { + global.fetch = vi.fn().mockResolvedValue( + new Response('ok', { + headers: { + 'Content-Encoding': 'gzip', + 'Content-Length': '100', + 'Content-Range': '100-200', + 'X-Response-Id': '456', + }, + }) + ) + }) + + it('simple proxy', async () => { + const app = new Hono() + app.get('/proxy/:path', (c) => + proxyFetch( + new Request(`https://example.com/${c.req.param('path')}`, { + headers: { + 'X-Request-Id': '123', + 'Accept-Encoding': 'gzip', + }, + }) + ) + ) + const res = await app.request('/proxy/test') + const req = (global.fetch as ReturnType).mock.calls[0][0] + + expect(req.url).toBe('https://example.com/test') + 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')).toBeNull() + }) + + it('proxySetRequestHeaders option', async () => { + const app = new Hono() + app.get('/proxy/:path', (c) => + proxyFetch( + new Request(`https://example.com/${c.req.param('path')}`, { + headers: { + 'X-Request-Id': '123', + 'X-To-Be-Deleted': 'to-be-deleted', + 'Accept-Encoding': 'gzip', + }, + }), + { + proxySetRequestHeaders: { + 'X-Request-Id': 'abc', + 'X-Forwarded-For': '127.0.0.1', + 'X-Forwarded-Host': 'example.com', + 'X-To-Be-Deleted': undefined, + }, + } + ) + ) + const res = await app.request('/proxy/test') + const req = (global.fetch as ReturnType).mock.calls[0][0] + + expect(req.url).toBe('https://example.com/test') + expect(req.headers.get('X-Request-Id')).toBe('abc') + expect(req.headers.get('X-Forwarded-For')).toBe('127.0.0.1') + expect(req.headers.get('X-Forwarded-Host')).toBe('example.com') + expect(req.headers.get('X-To-Be-Deleted')).toBeNull() + 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')).toBeNull() + }) + + it('proxySetRequestHeaders option', async () => { + const app = new Hono() + app.get('/proxy/:path', (c) => + proxyFetch( + new Request(`https://example.com/${c.req.param('path')}`, { + headers: { + 'X-Request-Id': '123', + 'Accept-Encoding': 'gzip', + }, + }), + { + proxyDeleteResponseHeaderNames: ['X-Response-Id'], + } + ) + ) + const res = await app.request('/proxy/test') + const req = (global.fetch as ReturnType).mock.calls[0][0] + + expect(req.url).toBe('https://example.com/test') + 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')).toBeNull() + expect(res.headers.get('Content-Encoding')).toBeNull() + expect(res.headers.get('Content-Length')).toBeNull() + expect(res.headers.get('Content-Range')).toBeNull() + }) + }) +}) diff --git a/src/helper/proxy/index.ts b/src/helper/proxy/index.ts new file mode 100644 index 000000000..4f4f0ad2f --- /dev/null +++ b/src/helper/proxy/index.ts @@ -0,0 +1,85 @@ +/** + * @module + * Proxy Helper for Hono. + */ + +/* + * The following headers will be deleted from the response: + * * Content-Encoding + * * Content-Length + * * Content-Range + */ +const forceDeleteResponseHeaderNames = ['Content-Encoding', 'Content-Length', 'Content-Range'] + +// Typical header names for requests for proxy use +type ProxyRequestHeaderName = 'X-Forwarded-For' | 'X-Forwarded-Proto' | 'X-Forwarded-Host' + +interface ProxyRequestInit extends RequestInit { + /** + * Headers that are overwritten in requests to the origin server. + * Specify undefined to delete the header. + */ + proxySetRequestHeaders?: Partial> & + Record + /** + * Headers included in the response from the origin server that should be removed in the response to the client. + */ + proxyDeleteResponseHeaderNames?: string[] +} + +interface ProxyFetch { + (input: RequestInfo | URL, init?: ProxyRequestInit): Promise + (input: string | URL | globalThis.Request, init?: ProxyRequestInit): Promise +} + +/** + * 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 proxyFetch(new Request(`http://${originServer}/${c.req.param('path')}`, c.req.raw), { + * proxySetRequestHeaders: { + * '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.raw + * }, + * proxyDeleteResponseHeaderNames: ['Cookie'], + * }) + * }) + * ``` + */ +export const proxyFetch: ProxyFetch = async (input, proxyInit) => { + const { + proxySetRequestHeaders = {}, + proxyDeleteResponseHeaderNames = [], + ...requestInit + } = proxyInit ?? {} + + const req = new Request(input, requestInit) + req.headers.delete('accept-encoding') + + for (const [key, value] of Object.entries(proxySetRequestHeaders)) { + if (value !== undefined) { + req.headers.set(key, value) + } else { + req.headers.delete(key) + } + } + + const res = await fetch(req) + const resHeaders = new Headers(res.headers) + for (const key of forceDeleteResponseHeaderNames.concat(proxyDeleteResponseHeaderNames)) { + resHeaders.delete(key) + } + + return new Response(res.body, { + ...res, + headers: resHeaders, + }) +} From 64cdd4133c11a0f9a8e338c0c3bc6a403a7b3c1c Mon Sep 17 00:00:00 2001 From: Taku Amano Date: Wed, 30 Oct 2024 18:16:58 +0900 Subject: [PATCH 02/15] chore(helper/proxy): expose proxy helper --- jsr.json | 1 + package.json | 8 ++++++++ 2 files changed, 9 insertions(+) diff --git a/jsr.json b/jsr.json index 09c22785b..9d253f8ea 100644 --- a/jsr.json +++ b/jsr.json @@ -79,6 +79,7 @@ "./testing": "./src/helper/testing/index.ts", "./dev": "./src/helper/dev/index.ts", "./ws": "./src/helper/websocket/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 77b62e787..a1452a93c 100644 --- a/package.json +++ b/package.json @@ -383,6 +383,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": { @@ -587,6 +592,9 @@ ], "conninfo": [ "./dist/types/helper/conninfo" + ], + "proxy": [ + "./dist/types/helper/proxy" ] } }, From ad17d90a6e295e3ac1eaaa159b89036a24fda8eb Mon Sep 17 00:00:00 2001 From: Taku Amano Date: Wed, 30 Oct 2024 22:22:16 +0900 Subject: [PATCH 03/15] test(helper/proxy): fix test name --- src/helper/proxy/index.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/helper/proxy/index.test.ts b/src/helper/proxy/index.test.ts index 16afd6eaa..1262b2f35 100644 --- a/src/helper/proxy/index.test.ts +++ b/src/helper/proxy/index.test.ts @@ -80,7 +80,7 @@ describe('Proxy Middleware', () => { expect(res.headers.get('Content-Range')).toBeNull() }) - it('proxySetRequestHeaders option', async () => { + it('proxySetRequestHeaderNames option', async () => { const app = new Hono() app.get('/proxy/:path', (c) => proxyFetch( From 5998770200b3e4101243c95b44498be3a33b7985 Mon Sep 17 00:00:00 2001 From: Taku Amano Date: Thu, 31 Oct 2024 06:23:41 +0900 Subject: [PATCH 04/15] fix(helper/proxy): return Content-Range header as it is Co-authored-by: Haochen M. Kotoi-Xie --- src/helper/proxy/index.test.ts | 8 ++++---- src/helper/proxy/index.ts | 3 +-- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/src/helper/proxy/index.test.ts b/src/helper/proxy/index.test.ts index 1262b2f35..1bcbc16ec 100644 --- a/src/helper/proxy/index.test.ts +++ b/src/helper/proxy/index.test.ts @@ -9,7 +9,7 @@ describe('Proxy Middleware', () => { headers: { 'Content-Encoding': 'gzip', 'Content-Length': '100', - 'Content-Range': '100-200', + 'Content-Range': 'bytes 0-2/1024', 'X-Response-Id': '456', }, }) @@ -39,7 +39,7 @@ describe('Proxy Middleware', () => { 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')).toBeNull() + expect(res.headers.get('Content-Range')).toBe('bytes 0-2/1024') }) it('proxySetRequestHeaders option', async () => { @@ -77,7 +77,7 @@ describe('Proxy Middleware', () => { 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')).toBeNull() + expect(res.headers.get('Content-Range')).toBe('bytes 0-2/1024') }) it('proxySetRequestHeaderNames option', async () => { @@ -106,7 +106,7 @@ describe('Proxy Middleware', () => { expect(res.headers.get('X-Response-Id')).toBeNull() expect(res.headers.get('Content-Encoding')).toBeNull() expect(res.headers.get('Content-Length')).toBeNull() - expect(res.headers.get('Content-Range')).toBeNull() + expect(res.headers.get('Content-Range')).toBe('bytes 0-2/1024') }) }) }) diff --git a/src/helper/proxy/index.ts b/src/helper/proxy/index.ts index 4f4f0ad2f..e1ac48b91 100644 --- a/src/helper/proxy/index.ts +++ b/src/helper/proxy/index.ts @@ -7,9 +7,8 @@ * The following headers will be deleted from the response: * * Content-Encoding * * Content-Length - * * Content-Range */ -const forceDeleteResponseHeaderNames = ['Content-Encoding', 'Content-Length', 'Content-Range'] +const forceDeleteResponseHeaderNames = ['Content-Encoding', 'Content-Length'] // Typical header names for requests for proxy use type ProxyRequestHeaderName = 'X-Forwarded-For' | 'X-Forwarded-Proto' | 'X-Forwarded-Host' From 3a5ef0f4844b67a5bdcf8d5039b76aee8bf4bf3a Mon Sep 17 00:00:00 2001 From: Taku Amano Date: Thu, 31 Oct 2024 06:44:08 +0900 Subject: [PATCH 05/15] refactor(helper/proxy): return the original content-length if the response is uncompressed --- src/helper/proxy/index.test.ts | 74 ++++++++++++++++++++++++++-------- src/helper/proxy/index.ts | 14 +++---- 2 files changed, 63 insertions(+), 25 deletions(-) diff --git a/src/helper/proxy/index.test.ts b/src/helper/proxy/index.test.ts index 1bcbc16ec..a60ece09e 100644 --- a/src/helper/proxy/index.test.ts +++ b/src/helper/proxy/index.test.ts @@ -4,19 +4,34 @@ import { proxyFetch } from '.' describe('Proxy Middleware', () => { describe('proxyFetch', () => { beforeEach(() => { - global.fetch = vi.fn().mockResolvedValue( - new Response('ok', { - headers: { - 'Content-Encoding': 'gzip', - 'Content-Length': '100', - 'Content-Range': 'bytes 0-2/1024', - 'X-Response-Id': '456', - }, - }) - ) + global.fetch = vi.fn().mockImplementation((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', + }, + }) + ) + } + return Promise.resolve(new Response('not found', { status: 404 })) + }) }) - it('simple proxy', async () => { + it('compressed', async () => { const app = new Hono() app.get('/proxy/:path', (c) => proxyFetch( @@ -28,10 +43,10 @@ describe('Proxy Middleware', () => { }) ) ) - const res = await app.request('/proxy/test') + const res = await app.request('/proxy/compressed') const req = (global.fetch as ReturnType).mock.calls[0][0] - expect(req.url).toBe('https://example.com/test') + expect(req.url).toBe('https://example.com/compressed') expect(req.headers.get('X-Request-Id')).toBe('123') expect(req.headers.get('Accept-Encoding')).toBeNull() @@ -42,6 +57,31 @@ describe('Proxy Middleware', () => { expect(res.headers.get('Content-Range')).toBe('bytes 0-2/1024') }) + it('uncompressed', async () => { + const app = new Hono() + app.get('/proxy/:path', (c) => + proxyFetch( + 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('proxySetRequestHeaders option', async () => { const app = new Hono() app.get('/proxy/:path', (c) => @@ -63,10 +103,10 @@ describe('Proxy Middleware', () => { } ) ) - const res = await app.request('/proxy/test') + const res = await app.request('/proxy/compressed') const req = (global.fetch as ReturnType).mock.calls[0][0] - expect(req.url).toBe('https://example.com/test') + expect(req.url).toBe('https://example.com/compressed') expect(req.headers.get('X-Request-Id')).toBe('abc') expect(req.headers.get('X-Forwarded-For')).toBe('127.0.0.1') expect(req.headers.get('X-Forwarded-Host')).toBe('example.com') @@ -95,10 +135,10 @@ describe('Proxy Middleware', () => { } ) ) - const res = await app.request('/proxy/test') + const res = await app.request('/proxy/compressed') const req = (global.fetch as ReturnType).mock.calls[0][0] - expect(req.url).toBe('https://example.com/test') + expect(req.url).toBe('https://example.com/compressed') expect(req.headers.get('X-Request-Id')).toBe('123') expect(req.headers.get('Accept-Encoding')).toBeNull() diff --git a/src/helper/proxy/index.ts b/src/helper/proxy/index.ts index e1ac48b91..fbe9220ab 100644 --- a/src/helper/proxy/index.ts +++ b/src/helper/proxy/index.ts @@ -3,13 +3,6 @@ * Proxy Helper for Hono. */ -/* - * The following headers will be deleted from the response: - * * Content-Encoding - * * Content-Length - */ -const forceDeleteResponseHeaderNames = ['Content-Encoding', 'Content-Length'] - // Typical header names for requests for proxy use type ProxyRequestHeaderName = 'X-Forwarded-For' | 'X-Forwarded-Proto' | 'X-Forwarded-Host' @@ -73,7 +66,12 @@ export const proxyFetch: ProxyFetch = async (input, proxyInit) => { const res = await fetch(req) const resHeaders = new Headers(res.headers) - for (const key of forceDeleteResponseHeaderNames.concat(proxyDeleteResponseHeaderNames)) { + 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') + } + for (const key of proxyDeleteResponseHeaderNames) { resHeaders.delete(key) } From b6eb510285c2fb040433cc95ba60dfc07ed2dce9 Mon Sep 17 00:00:00 2001 From: Taku Amano Date: Sun, 26 Jan 2025 11:39:29 +0900 Subject: [PATCH 06/15] feat(middleware): enable to pass `...c.req` to init options --- src/helper/proxy/index.test.ts | 81 +++++++++------------------------- src/helper/proxy/index.ts | 67 +++++++++++++++------------- 2 files changed, 56 insertions(+), 92 deletions(-) diff --git a/src/helper/proxy/index.test.ts b/src/helper/proxy/index.test.ts index a60ece09e..331416039 100644 --- a/src/helper/proxy/index.test.ts +++ b/src/helper/proxy/index.test.ts @@ -4,7 +4,7 @@ import { proxyFetch } from '.' describe('Proxy Middleware', () => { describe('proxyFetch', () => { beforeEach(() => { - global.fetch = vi.fn().mockImplementation((req) => { + global.fetch = vi.fn().mockImplementation(async (req) => { if (req.url === 'https://example.com/compressed') { return Promise.resolve( new Response('ok', { @@ -26,6 +26,8 @@ describe('Proxy Middleware', () => { }, }) ) + } else if (req.url === 'https://example.com/post' && req.method === 'POST') { + return Promise.resolve(new Response(`request body: ${await req.text()}`)) } return Promise.resolve(new Response('not found', { status: 404 })) }) @@ -82,71 +84,28 @@ describe('Proxy Middleware', () => { expect(res.headers.get('Content-Range')).toBe('bytes 0-2/1024') }) - it('proxySetRequestHeaders option', async () => { + it('POST request', async () => { const app = new Hono() - app.get('/proxy/:path', (c) => - proxyFetch( - new Request(`https://example.com/${c.req.param('path')}`, { - headers: { - 'X-Request-Id': '123', - 'X-To-Be-Deleted': 'to-be-deleted', - 'Accept-Encoding': 'gzip', - }, - }), - { - proxySetRequestHeaders: { - 'X-Request-Id': 'abc', - 'X-Forwarded-For': '127.0.0.1', - 'X-Forwarded-Host': 'example.com', - 'X-To-Be-Deleted': undefined, - }, - } - ) - ) - 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('abc') - expect(req.headers.get('X-Forwarded-For')).toBe('127.0.0.1') - expect(req.headers.get('X-Forwarded-Host')).toBe('example.com') - expect(req.headers.get('X-To-Be-Deleted')).toBeNull() - 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('proxySetRequestHeaderNames option', async () => { - const app = new Hono() - app.get('/proxy/:path', (c) => - proxyFetch( - new Request(`https://example.com/${c.req.param('path')}`, { - headers: { - 'X-Request-Id': '123', - 'Accept-Encoding': 'gzip', - }, - }), - { - proxyDeleteResponseHeaderNames: ['X-Response-Id'], - } - ) - ) - const res = await app.request('/proxy/compressed') + app.all('/proxy/:path', (c) => { + return proxyFetch(`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/compressed') - expect(req.headers.get('X-Request-Id')).toBe('123') - expect(req.headers.get('Accept-Encoding')).toBeNull() + expect(req.url).toBe('https://example.com/post') expect(res.status).toBe(200) - expect(res.headers.get('X-Response-Id')).toBeNull() - 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') + expect(await res.text()).toBe('request body: test') }) }) }) diff --git a/src/helper/proxy/index.ts b/src/helper/proxy/index.ts index fbe9220ab..759b0f435 100644 --- a/src/helper/proxy/index.ts +++ b/src/helper/proxy/index.ts @@ -3,20 +3,8 @@ * Proxy Helper for Hono. */ -// Typical header names for requests for proxy use -type ProxyRequestHeaderName = 'X-Forwarded-For' | 'X-Forwarded-Proto' | 'X-Forwarded-Host' - interface ProxyRequestInit extends RequestInit { - /** - * Headers that are overwritten in requests to the origin server. - * Specify undefined to delete the header. - */ - proxySetRequestHeaders?: Partial> & - Record - /** - * Headers included in the response from the origin server that should be removed in the response to the client. - */ - proxyDeleteResponseHeaderNames?: string[] + raw?: Request } interface ProxyFetch { @@ -35,35 +23,55 @@ interface ProxyFetch { * @example * ```ts * app.get('/proxy/:path', (c) => { - * return proxyFetch(new Request(`http://${originServer}/${c.req.param('path')}`, c.req.raw), { - * proxySetRequestHeaders: { + * return proxyFetch(`http://${originServer}/${c.req.param('path')}`, { + * headers: { + * ...c.req.header(), // optional, specify only when header forwarding is truly 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('Cookie') + * return res + * }) + * }) + * + * app.any('/proxy/:path', (c) => { + * return proxyFetch(`http://${originServer}/${c.req.param('path')}`, { + * ...c.req, + * 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.raw + * Authorization: undefined, // do not propagate request headers contained in c.req.header('Authorization') * }, - * proxyDeleteResponseHeaderNames: ['Cookie'], * }) * }) * ``` */ export const proxyFetch: ProxyFetch = async (input, proxyInit) => { const { - proxySetRequestHeaders = {}, - proxyDeleteResponseHeaderNames = [], + raw, ...requestInit } = proxyInit ?? {} - const req = new Request(input, requestInit) - req.headers.delete('accept-encoding') - - for (const [key, value] of Object.entries(proxySetRequestHeaders)) { - if (value !== undefined) { - req.headers.set(key, value) - } else { - req.headers.delete(key) - } + const requestInitRaw: RequestInit & { duplex?: 'half' } = raw + ? { + method: raw.method, + body: raw.body, + headers: raw.headers, + } + : {} + if (requestInitRaw.body) { + requestInitRaw.duplex = 'half' } + const req = new Request(input, { + ...requestInitRaw, + ...requestInit, + }) + req.headers.delete('accept-encoding') + const res = await fetch(req) const resHeaders = new Headers(res.headers) if (resHeaders.has('content-encoding')) { @@ -71,9 +79,6 @@ export const proxyFetch: ProxyFetch = async (input, proxyInit) => { // Content-Length is the size of the compressed content, not the size of the original content resHeaders.delete('content-length') } - for (const key of proxyDeleteResponseHeaderNames) { - resHeaders.delete(key) - } return new Response(res.body, { ...res, From fb54227f9b861c3376032c0ed503ecd13b6bce55 Mon Sep 17 00:00:00 2001 From: Taku Amano Date: Sun, 26 Jan 2025 11:42:09 +0900 Subject: [PATCH 07/15] refactor(middleware/proxy): rename `proxyFetch` to `proxy` --- src/helper/proxy/index.test.ts | 10 +++++----- src/helper/proxy/index.ts | 13 +++++-------- 2 files changed, 10 insertions(+), 13 deletions(-) diff --git a/src/helper/proxy/index.test.ts b/src/helper/proxy/index.test.ts index 331416039..079ba7a5b 100644 --- a/src/helper/proxy/index.test.ts +++ b/src/helper/proxy/index.test.ts @@ -1,8 +1,8 @@ import { Hono } from '../../hono' -import { proxyFetch } from '.' +import { proxy } from '.' describe('Proxy Middleware', () => { - describe('proxyFetch', () => { + describe('proxy', () => { beforeEach(() => { global.fetch = vi.fn().mockImplementation(async (req) => { if (req.url === 'https://example.com/compressed') { @@ -36,7 +36,7 @@ describe('Proxy Middleware', () => { it('compressed', async () => { const app = new Hono() app.get('/proxy/:path', (c) => - proxyFetch( + proxy( new Request(`https://example.com/${c.req.param('path')}`, { headers: { 'X-Request-Id': '123', @@ -62,7 +62,7 @@ describe('Proxy Middleware', () => { it('uncompressed', async () => { const app = new Hono() app.get('/proxy/:path', (c) => - proxyFetch( + proxy( new Request(`https://example.com/${c.req.param('path')}`, { headers: { 'X-Request-Id': '123', @@ -87,7 +87,7 @@ describe('Proxy Middleware', () => { it('POST request', async () => { const app = new Hono() app.all('/proxy/:path', (c) => { - return proxyFetch(`https://example.com/${c.req.param('path')}`, { + return proxy(`https://example.com/${c.req.param('path')}`, { ...c.req, headers: { ...c.req.header(), diff --git a/src/helper/proxy/index.ts b/src/helper/proxy/index.ts index 759b0f435..26035b82b 100644 --- a/src/helper/proxy/index.ts +++ b/src/helper/proxy/index.ts @@ -23,7 +23,7 @@ interface ProxyFetch { * @example * ```ts * app.get('/proxy/:path', (c) => { - * return proxyFetch(`http://${originServer}/${c.req.param('path')}`, { + * return proxy(`http://${originServer}/${c.req.param('path')}`, { * headers: { * ...c.req.header(), // optional, specify only when header forwarding is truly necessary. * 'X-Forwarded-For': '127.0.0.1', @@ -35,9 +35,9 @@ interface ProxyFetch { * return res * }) * }) - * + * * app.any('/proxy/:path', (c) => { - * return proxyFetch(`http://${originServer}/${c.req.param('path')}`, { + * return proxy(`http://${originServer}/${c.req.param('path')}`, { * ...c.req, * headers: { * ...c.req.header(), @@ -49,11 +49,8 @@ interface ProxyFetch { * }) * ``` */ -export const proxyFetch: ProxyFetch = async (input, proxyInit) => { - const { - raw, - ...requestInit - } = proxyInit ?? {} +export const proxy: ProxyFetch = async (input, proxyInit) => { + const { raw, ...requestInit } = proxyInit ?? {} const requestInitRaw: RequestInit & { duplex?: 'half' } = raw ? { From e08c602c9ea95a0b5023a67141750db9531e314b Mon Sep 17 00:00:00 2001 From: Taku Amano Date: Sun, 26 Jan 2025 11:55:33 +0900 Subject: [PATCH 08/15] docs(helper/proxy): update example --- src/helper/proxy/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/helper/proxy/index.ts b/src/helper/proxy/index.ts index 26035b82b..6052b7a2b 100644 --- a/src/helper/proxy/index.ts +++ b/src/helper/proxy/index.ts @@ -25,7 +25,7 @@ interface ProxyFetch { * app.get('/proxy/:path', (c) => { * return proxy(`http://${originServer}/${c.req.param('path')}`, { * headers: { - * ...c.req.header(), // optional, specify only when header forwarding is truly necessary. + * ...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') @@ -38,7 +38,7 @@ interface ProxyFetch { * * app.any('/proxy/:path', (c) => { * return proxy(`http://${originServer}/${c.req.param('path')}`, { - * ...c.req, + * ...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', From eafc02aff50055378b7d87af11d76c84fd9ebeb3 Mon Sep 17 00:00:00 2001 From: Taku Amano Date: Sun, 26 Jan 2025 11:57:19 +0900 Subject: [PATCH 09/15] docs(helper/proxy): fix typo --- src/helper/proxy/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/helper/proxy/index.ts b/src/helper/proxy/index.ts index 6052b7a2b..0754dbefb 100644 --- a/src/helper/proxy/index.ts +++ b/src/helper/proxy/index.ts @@ -38,7 +38,7 @@ interface ProxyFetch { * * app.any('/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 + * ...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', From dc24de31acd6f7e4bde73fc96b9d2e048fc524b3 Mon Sep 17 00:00:00 2001 From: Taku Amano Date: Sun, 26 Jan 2025 12:12:50 +0900 Subject: [PATCH 10/15] refactor(helper/proxy): also accept HonoRequest instance as request init --- src/helper/proxy/index.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/helper/proxy/index.ts b/src/helper/proxy/index.ts index 0754dbefb..40dea5b08 100644 --- a/src/helper/proxy/index.ts +++ b/src/helper/proxy/index.ts @@ -3,13 +3,18 @@ * Proxy Helper for Hono. */ +import type { HonoRequest } from '../../request' + interface ProxyRequestInit extends RequestInit { raw?: Request } interface ProxyFetch { - (input: RequestInfo | URL, init?: ProxyRequestInit): Promise - (input: string | URL | globalThis.Request, init?: ProxyRequestInit): Promise + (input: RequestInfo | URL, init?: ProxyRequestInit | HonoRequest): Promise + ( + input: string | URL | globalThis.Request, + init?: ProxyRequestInit | HonoRequest + ): Promise } /** From 833b8e2496ba0191c3092def7e7b2cf4dc78275d Mon Sep 17 00:00:00 2001 From: Taku Amano Date: Sun, 26 Jan 2025 21:30:49 +0900 Subject: [PATCH 11/15] fix(helper/proxy): remove hop-by-hop headers --- src/helper/proxy/index.test.ts | 46 ++++++++++++++++++++++++++++++++++ src/helper/proxy/index.ts | 19 ++++++++++++++ 2 files changed, 65 insertions(+) diff --git a/src/helper/proxy/index.test.ts b/src/helper/proxy/index.test.ts index 079ba7a5b..f12873a10 100644 --- a/src/helper/proxy/index.test.ts +++ b/src/helper/proxy/index.test.ts @@ -28,6 +28,14 @@ describe('Proxy Middleware', () => { ) } 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', + }, + }) + ) } return Promise.resolve(new Response('not found', { status: 404 })) }) @@ -107,5 +115,43 @@ describe('Proxy Middleware', () => { 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() + }) }) }) diff --git a/src/helper/proxy/index.ts b/src/helper/proxy/index.ts index 40dea5b08..6cd37c8fd 100644 --- a/src/helper/proxy/index.ts +++ b/src/helper/proxy/index.ts @@ -5,6 +5,17 @@ import type { HonoRequest } from '../../request' +// 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 RequestInit { raw?: Request } @@ -67,6 +78,11 @@ export const proxy: ProxyFetch = async (input, proxyInit) => { if (requestInitRaw.body) { requestInitRaw.duplex = 'half' } + if (requestInitRaw.headers) { + hopByHopHeaders.forEach((header) => { + ;(requestInitRaw.headers as Headers).delete(header) + }) + } const req = new Request(input, { ...requestInitRaw, @@ -76,6 +92,9 @@ export const proxy: ProxyFetch = async (input, proxyInit) => { 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 From 2e8e2509143aa2841ea35cc8be91a3eb6a0ff43e Mon Sep 17 00:00:00 2001 From: Taku Amano Date: Mon, 27 Jan 2025 09:01:17 +0900 Subject: [PATCH 12/15] refactor(helper/proxy): build request init from request --- src/helper/proxy/index.ts | 38 +++++++++++++++++++++----------------- 1 file changed, 21 insertions(+), 17 deletions(-) diff --git a/src/helper/proxy/index.ts b/src/helper/proxy/index.ts index 6cd37c8fd..03fd7240d 100644 --- a/src/helper/proxy/index.ts +++ b/src/helper/proxy/index.ts @@ -28,6 +28,26 @@ interface ProxyFetch { ): 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). @@ -68,24 +88,8 @@ interface ProxyFetch { export const proxy: ProxyFetch = async (input, proxyInit) => { const { raw, ...requestInit } = proxyInit ?? {} - const requestInitRaw: RequestInit & { duplex?: 'half' } = raw - ? { - method: raw.method, - body: raw.body, - headers: raw.headers, - } - : {} - if (requestInitRaw.body) { - requestInitRaw.duplex = 'half' - } - if (requestInitRaw.headers) { - hopByHopHeaders.forEach((header) => { - ;(requestInitRaw.headers as Headers).delete(header) - }) - } - const req = new Request(input, { - ...requestInitRaw, + ...buildRequestInitFromRequest(raw), ...requestInit, }) req.headers.delete('accept-encoding') From 832532cc16982f0ce8c6dc03616ba1d050a32f20 Mon Sep 17 00:00:00 2001 From: Taku Amano Date: Mon, 27 Jan 2025 20:38:21 +0900 Subject: [PATCH 13/15] fix(helper/proxy): fix type error Co-authored-by: Yusuke Wada --- src/helper/proxy/index.ts | 29 +++++++++++++++++------------ 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/src/helper/proxy/index.ts b/src/helper/proxy/index.ts index 03fd7240d..6c0f87e6b 100644 --- a/src/helper/proxy/index.ts +++ b/src/helper/proxy/index.ts @@ -3,7 +3,7 @@ * Proxy Helper for Hono. */ -import type { HonoRequest } from '../../request' +import type { RequestHeader } from '../../utils/headers' // https://datatracker.ietf.org/doc/html/rfc2616#section-13.5.1 const hopByHopHeaders = [ @@ -16,16 +16,17 @@ const hopByHopHeaders = [ 'transfer-encoding', ] -interface ProxyRequestInit extends RequestInit { +interface ProxyRequestInit extends Omit { raw?: Request + headers?: + | HeadersInit + | [string, string][] + | Record + | Record } interface ProxyFetch { - (input: RequestInfo | URL, init?: ProxyRequestInit | HonoRequest): Promise - ( - input: string | URL | globalThis.Request, - init?: ProxyRequestInit | HonoRequest - ): Promise + (input: string | URL | Request, init?: ProxyRequestInit): Promise } const buildRequestInitFromRequest = ( @@ -72,7 +73,7 @@ const buildRequestInitFromRequest = ( * }) * }) * - * app.any('/proxy/:path', (c) => { + * 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: { @@ -88,10 +89,14 @@ const buildRequestInitFromRequest = ( export const proxy: ProxyFetch = async (input, proxyInit) => { const { raw, ...requestInit } = proxyInit ?? {} - const req = new Request(input, { - ...buildRequestInitFromRequest(raw), - ...requestInit, - }) + 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) From e11a66d4cad9806c6dc0e66b1ed49c2bf3257589 Mon Sep 17 00:00:00 2001 From: Taku Amano Date: Mon, 27 Jan 2025 20:46:58 +0900 Subject: [PATCH 14/15] test(helper/proxy): add test for modify header --- src/helper/proxy/index.test.ts | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/src/helper/proxy/index.test.ts b/src/helper/proxy/index.test.ts index f12873a10..6d3213873 100644 --- a/src/helper/proxy/index.test.ts +++ b/src/helper/proxy/index.test.ts @@ -36,6 +36,14 @@ describe('Proxy Middleware', () => { }, }) ) + } 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 })) }) @@ -153,5 +161,23 @@ describe('Proxy Middleware', () => { 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') + }) }) }) From 8a816332e3436dcec9c2ae5b6af7da5f92860c4a Mon Sep 17 00:00:00 2001 From: Taku Amano Date: Mon, 27 Jan 2025 21:11:57 +0900 Subject: [PATCH 15/15] fix(helper/proxy): It is generally the Set-Cookie that should be removed from the response header --- src/helper/proxy/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/helper/proxy/index.ts b/src/helper/proxy/index.ts index 6c0f87e6b..9bfed6f73 100644 --- a/src/helper/proxy/index.ts +++ b/src/helper/proxy/index.ts @@ -68,7 +68,7 @@ const buildRequestInitFromRequest = ( * Authorization: undefined, // do not propagate request headers contained in c.req.header('Authorization') * }, * }).then((res) => { - * res.headers.delete('Cookie') + * res.headers.delete('Set-Cookie') * return res * }) * })