Skip to content

Commit

Permalink
feat(helper/proxy): introduce proxy helper (#3589)
Browse files Browse the repository at this point in the history
* 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 <haochenx@acm.org>

* 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 <yusuke@kamawada.com>

* 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 <haochenx@acm.org>
Co-authored-by: Yusuke Wada <yusuke@kamawada.com>
  • Loading branch information
3 people authored Feb 6, 2025
1 parent 6b6fbad commit 31d6e6c
Show file tree
Hide file tree
Showing 4 changed files with 309 additions and 0 deletions.
1 change: 1 addition & 0 deletions jsr.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
8 changes: 8 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down Expand Up @@ -595,6 +600,9 @@
],
"conninfo": [
"./dist/types/helper/conninfo"
],
"proxy": [
"./dist/types/helper/proxy"
]
}
},
Expand Down
183 changes: 183 additions & 0 deletions src/helper/proxy/index.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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')
})
})
})
117 changes: 117 additions & 0 deletions src/helper/proxy/index.ts
Original file line number Diff line number Diff line change
@@ -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<RequestInit, 'headers'> {
raw?: Request
headers?:
| HeadersInit
| [string, string][]
| Record<RequestHeader, string | undefined>
| Record<string, string | undefined>
}

interface ProxyFetch {
(input: string | URL | Request, init?: ProxyRequestInit): Promise<Response>
}

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,
})
}

0 comments on commit 31d6e6c

Please sign in to comment.