diff --git a/benchmarks/fetch/README.md b/benchmarks/fetch/README.md index fc98ea09..a41936b5 100644 --- a/benchmarks/fetch/README.md +++ b/benchmarks/fetch/README.md @@ -1,6 +1,6 @@ # Benchmark -Benchmark to compare performance between the published npm version and local development version of @hono/node-server. +Benchmark comparing the published npm version and local development version of @hono/node-server with srvx. This benchmark uses a basic Fetch API-based application without the Hono framework to measure the raw performance of @hono/node-server's adapter. @@ -19,28 +19,42 @@ pnpm run benchmark ## What's Being Tested -Tests three endpoints: +Tests four endpoints: 1. **Ping (GET /)**: Simple response 2. **Query (GET /id/:id)**: Path parameter and query parameter handling 3. **Body (POST /json)**: JSON body processing +4. **Headers (GET /headers)**: Isolated `request.headers.get()` access Each endpoint is tested with 500 concurrent connections for 10 seconds, measuring requests per second (Reqs/sec). +## Benchmark Environment + +- **Machine**: Lenovo LOQ 15IRX9 (83DV) +- **CPU**: Intel Core i5-13450HX (10 cores, 16 threads) +- **Memory**: 24 GB +- **OS**: Arch Linux x86_64 (kernel 7.1.6) +- **Node.js**: 24.19.0 + ## Understanding Results +Last updated: 2026-08-09 + ``` -| Benchmark | npm | dev | Difference | -| ----------------- | -------------- | -------------- | ----------- | -| Average | 111,514.97 | 115,234.56 | +3.34% | -| Ping (GET /) | 122,207.70 | 125,678.90 | +2.84% | -| Query (GET /id) | 106,624.16 | 110,123.45 | +3.28% | -| Body (POST /json) | 105,713.04 | 109,901.23 | +3.96% | +| Benchmark | @hono/node-server (2.1.0) | srvx (0.12.5, fast) | @hono/node-server (dev) | dev vs npm | dev vs srvx | +| ----------------- | ------------------------- | ------------------- | ----------------------- | ---------- | ----------- | +| Average | 83,588.79 | 89,245.89 | 88,398.73 | +5.75% | -0.95% | +| Ping (GET /) | 87,502.45 | 97,875.62 | 96,320.69 | +10.08% | -1.59% | +| Query (GET /id) | 92,967.16 | 89,524.22 | 93,474.95 | +0.55% | +4.41% | +| Body (POST /json) | 72,621.78 | 75,968.80 | 73,823.52 | +1.65% | -2.82% | +| Headers (GET) | 81,263.78 | 93,614.90 | 89,975.77 | +10.72% | -3.89% | ``` -- **npm**: Published npm version (`@hono/node-server`) -- **dev**: Local development version (from repository root `dist/`) -- **Difference**: Performance difference (positive values indicate improvement, negative values indicate regression) +- **@hono/node-server (2.1.0)**: Published npm version +- **@hono/node-server (dev)**: Local development version (from repository root `dist/`) +- **srvx (0.12.5, fast)**: Published npm version using its opt-in `FastResponse` +- **dev vs npm**: Development Hono compared with published Hono +- **dev vs srvx**: Development Hono compared with srvx ## Reference diff --git a/benchmarks/fetch/package.json b/benchmarks/fetch/package.json index 06e793ef..0b558404 100644 --- a/benchmarks/fetch/package.json +++ b/benchmarks/fetch/package.json @@ -6,7 +6,7 @@ "benchmark": "node --experimental-strip-types scripts/bench.ts" }, "dependencies": { - "@hono/node-server": "^1.19.9", - "@hono/node-server-dev": "file:../.." + "@hono/node-server": "^2.1.0", + "srvx": "^0.12.5" } } diff --git a/benchmarks/fetch/scripts/bench.ts b/benchmarks/fetch/scripts/bench.ts index 5946950f..54105d7a 100644 --- a/benchmarks/fetch/scripts/bench.ts +++ b/benchmarks/fetch/scripts/bench.ts @@ -1,4 +1,5 @@ import { spawn } from 'node:child_process' +import { once } from 'node:events' import { setTimeout } from 'node:timers/promises' const PORT = 3000 @@ -16,6 +17,7 @@ interface ServerResult { ping: number query: number body: number + headers: number } async function waitForServer(): Promise { @@ -44,6 +46,15 @@ async function retryFetch(url: string, options?: RequestInit, retries = 0): Prom } } +async function stopServer(server: ReturnType): Promise { + if (server.exitCode !== null || server.signalCode !== null) { + return + } + const exited = once(server, 'exit') + server.kill('SIGKILL') + await exited +} + async function testEndpoints(): Promise { // Test GET / const res1 = await retryFetch('http://127.0.0.1:3000/') @@ -75,6 +86,17 @@ async function testEndpoints(): Promise { `Body: Result not match - expected ${JSON.stringify(body)}, got ${JSON.stringify(json3)}` ) } + + // Test an isolated incoming request-header read. + const res4 = await retryFetch('http://127.0.0.1:3000/headers', { + headers: { + 'x-test': '123', + }, + }) + const text4 = await res4.text() + if (res4.status !== 200 || text4 !== '123') { + throw new Error(`Headers: Result not match - expected "123", got "${text4}"`) + } } async function runBenchmarkForServer( @@ -102,6 +124,7 @@ async function runBenchmarkForServer( { name: 'GET /', url: 'http://127.0.0.1:3000/' }, { name: 'GET /id/:id', url: 'http://127.0.0.1:3000/id/1?name=bun' }, { name: 'POST /json', url: 'http://127.0.0.1:3000/json', method: 'POST' }, + { name: 'GET /headers', url: 'http://127.0.0.1:3000/headers' }, ] const results: BenchmarkResult[] = [] @@ -111,6 +134,9 @@ async function runBenchmarkForServer( if (bench.method === 'POST') { args.push('-m', 'POST', '-H', 'Content-Type:application/json', '-f', './scripts/body.json') } + if (bench.name === 'GET /headers') { + args.push('-H', 'x-test:123') + } args.push(bench.url) const output = await new Promise((resolve, reject) => { @@ -147,7 +173,8 @@ async function runBenchmarkForServer( const ping = results[0]?.reqsPerSec || 0 const query = results[1]?.reqsPerSec || 0 const body = results[2]?.reqsPerSec || 0 - const average = (ping + query + body) / 3 + const headers = results[3]?.reqsPerSec || 0 + const average = (ping + query + body + headers) / 4 return { server: serverName, @@ -156,14 +183,14 @@ async function runBenchmarkForServer( ping, query, body, + headers, } } catch (error) { console.error('Error:', (error as Error).message) throw error } finally { console.log('Stopping server...') - server.kill() - await setTimeout(1000) + await stopServer(server) } } @@ -185,14 +212,14 @@ async function testServer(serverFile: string, serverName: string): Promise { const servers = [ - { file: 'src/server-npm.js', name: '@hono/node-server (npm)' }, + { file: 'src/server-npm.js', name: '@hono/node-server (2.1.0)' }, + { file: 'src/server-srvx.js', name: 'srvx (0.12.5, fast)' }, { file: 'src/server-dev.js', name: '@hono/node-server (dev)' }, ] @@ -238,47 +265,53 @@ async function main(): Promise { }) } - const formatDiff = (npm: number, dev: number): string => { - const diff = ((dev - npm) / npm) * 100 - const sign = diff > 0 ? '+' : '' - return `${sign}${diff.toFixed(2)}%` + const formatDiff = (baseline: number, dev: number): string => { + const diff = ((dev - baseline) / baseline) * 100 + return `${diff > 0 ? '+' : ''}${diff.toFixed(2)}%` } - if (allResults.length === 2) { - // Comparison mode: npm vs dev - const npmResult = allResults.find((r) => r.server.includes('npm')) + if (allResults.length === 3) { + const npmResult = allResults.find((r) => r.server === '@hono/node-server (2.1.0)') + const srvxResult = allResults.find((r) => r.server === 'srvx (0.12.5, fast)') const devResult = allResults.find((r) => r.server.includes('dev')) - if (npmResult && devResult) { - console.log('| Benchmark | npm | dev | Difference |') - console.log('| ----------------- | -------------- | -------------- | ----------- |') + if (npmResult && srvxResult && devResult) { + console.log( + '| Benchmark | @hono/node-server (2.1.0) | srvx (0.12.5, fast) | @hono/node-server (dev) | dev vs npm | dev vs srvx |' + ) + console.log( + '| ----------------- | ------------------------- | ------------------- | ----------------------- | ---------- | ----------- |' + ) + console.log( + `| Average | ${formatNumber(npmResult.average).padEnd(25)} | ${formatNumber(srvxResult.average).padEnd(19)} | ${formatNumber(devResult.average).padEnd(23)} | ${formatDiff(npmResult.average, devResult.average).padEnd(10)} | ${formatDiff(srvxResult.average, devResult.average).padEnd(11)} |` + ) console.log( - `| Average | ${formatNumber(npmResult.average).padEnd(14)} | ${formatNumber(devResult.average).padEnd(14)} | ${formatDiff(npmResult.average, devResult.average).padEnd(11)} |` + `| Ping (GET /) | ${formatNumber(npmResult.ping).padEnd(25)} | ${formatNumber(srvxResult.ping).padEnd(19)} | ${formatNumber(devResult.ping).padEnd(23)} | ${formatDiff(npmResult.ping, devResult.ping).padEnd(10)} | ${formatDiff(srvxResult.ping, devResult.ping).padEnd(11)} |` ) console.log( - `| Ping (GET /) | ${formatNumber(npmResult.ping).padEnd(14)} | ${formatNumber(devResult.ping).padEnd(14)} | ${formatDiff(npmResult.ping, devResult.ping).padEnd(11)} |` + `| Query (GET /id) | ${formatNumber(npmResult.query).padEnd(25)} | ${formatNumber(srvxResult.query).padEnd(19)} | ${formatNumber(devResult.query).padEnd(23)} | ${formatDiff(npmResult.query, devResult.query).padEnd(10)} | ${formatDiff(srvxResult.query, devResult.query).padEnd(11)} |` ) console.log( - `| Query (GET /id) | ${formatNumber(npmResult.query).padEnd(14)} | ${formatNumber(devResult.query).padEnd(14)} | ${formatDiff(npmResult.query, devResult.query).padEnd(11)} |` + `| Body (POST /json) | ${formatNumber(npmResult.body).padEnd(25)} | ${formatNumber(srvxResult.body).padEnd(19)} | ${formatNumber(devResult.body).padEnd(23)} | ${formatDiff(npmResult.body, devResult.body).padEnd(10)} | ${formatDiff(srvxResult.body, devResult.body).padEnd(11)} |` ) console.log( - `| Body (POST /json) | ${formatNumber(npmResult.body).padEnd(14)} | ${formatNumber(devResult.body).padEnd(14)} | ${formatDiff(npmResult.body, devResult.body).padEnd(11)} |` + `| Headers (GET) | ${formatNumber(npmResult.headers).padEnd(25)} | ${formatNumber(srvxResult.headers).padEnd(19)} | ${formatNumber(devResult.headers).padEnd(23)} | ${formatDiff(npmResult.headers, devResult.headers).padEnd(10)} | ${formatDiff(srvxResult.headers, devResult.headers).padEnd(11)} |` ) } } else { // Fallback: original table format console.log( - '| Server | Runtime | Average | Ping | Query | Body |' + '| Server | Runtime | Average | Ping | Query | Body | Headers |' ) console.log( - '| -------------------------- | ------- | ------------ | ------------ | ------------ | ------------ |' + '| -------------------------- | ------- | ------------ | ------------ | ------------ | ------------ | ------------ |' ) const sortedResults = allResults.sort((a, b) => b.average - a.average) for (const result of sortedResults) { console.log( - `| ${result.server.padEnd(26)} | ${result.runtime.padEnd(7)} | ${formatNumber(result.average).padEnd(12)} | ${formatNumber(result.ping).padEnd(12)} | ${formatNumber(result.query).padEnd(12)} | ${formatNumber(result.body).padEnd(12)} |` + `| ${result.server.padEnd(26)} | ${result.runtime.padEnd(7)} | ${formatNumber(result.average).padEnd(12)} | ${formatNumber(result.ping).padEnd(12)} | ${formatNumber(result.query).padEnd(12)} | ${formatNumber(result.body).padEnd(12)} | ${formatNumber(result.headers).padEnd(12)} |` ) } } diff --git a/benchmarks/fetch/src/app.js b/benchmarks/fetch/src/app.js index d9da7af0..9badf7fd 100644 --- a/benchmarks/fetch/src/app.js +++ b/benchmarks/fetch/src/app.js @@ -9,6 +9,8 @@ export default { switch (url.pathname) { case '/': return new Response('Hi') + case '/headers': + return new Response(request.headers.get('x-test')) } if (url.pathname.startsWith('/id/')) { diff --git a/benchmarks/fetch/src/server-dev.js b/benchmarks/fetch/src/server-dev.js index 7c850d7d..06ad8ece 100644 --- a/benchmarks/fetch/src/server-dev.js +++ b/benchmarks/fetch/src/server-dev.js @@ -1,4 +1,4 @@ -import { serve } from '@hono/node-server-dev' +import { serve } from '../../../dist/index.mjs' import app from './app.js' const port = 3000 diff --git a/benchmarks/fetch/src/server-srvx.js b/benchmarks/fetch/src/server-srvx.js new file mode 100644 index 00000000..4bc6431f --- /dev/null +++ b/benchmarks/fetch/src/server-srvx.js @@ -0,0 +1,12 @@ +import { FastResponse, serve } from 'srvx' +import app from './app.js' + +const port = 3000 + +// opt into srvx fast response, since hono uses request/response shims by default +globalThis.Response = FastResponse + +serve({ + fetch: app.fetch, + port, +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b8a0ae70..375d8b1d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -51,11 +51,11 @@ importers: benchmarks/fetch: dependencies: '@hono/node-server': - specifier: ^1.19.9 - version: 1.19.14(hono@4.12.8) - '@hono/node-server-dev': - specifier: file:../.. - version: '@hono/node-server@file:(hono@4.12.8)' + specifier: ^2.1.0 + version: 2.1.0(hono@4.12.8) + srvx: + specifier: ^0.12.5 + version: 0.12.5 packages: @@ -141,14 +141,8 @@ packages: eslint: ^9.0.0 typescript: ^5.0.0 - '@hono/node-server@1.19.14': - resolution: {integrity: sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==} - engines: {node: '>=18.14.1'} - peerDependencies: - hono: ^4 - - '@hono/node-server@file:': - resolution: {directory: '', type: directory} + '@hono/node-server@2.1.0': + resolution: {integrity: sha512-XovyyCCnBzW+zKu+z/zq8hwNs4KOR5rEMAOxo2f40Q5xoOI37IMm6MIg2COOUtUApo0i6850MTBKH2u4QLGIqg==} engines: {node: '>=20'} peerDependencies: hono: ^4 @@ -2378,6 +2372,11 @@ packages: spdx-license-ids@3.0.23: resolution: {integrity: sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==} + srvx@0.12.5: + resolution: {integrity: sha512-IuvtDNQg5EIwv3c6dleyau7u8hCyGQ7D6+V/QM799Aud07z0wCUcurKLTRfyG33C8oUY+UWcVBFkfHMcbtmRLA==} + engines: {node: '>=20.16.0'} + hasBin: true + stable-hash-x@0.2.0: resolution: {integrity: sha512-o3yWv49B/o4QZk5ZcsALc6t0+eCelPc44zZsLtCQnZPDwFpDYSWcDnrv2TtMmMbQ7uKo3J0HTURCqckw23czNQ==} engines: {node: '>=12.0.0'} @@ -2900,11 +2899,7 @@ snapshots: - eslint-plugin-import - supports-color - '@hono/node-server@1.19.14(hono@4.12.8)': - dependencies: - hono: 4.12.8 - - '@hono/node-server@file:(hono@4.12.8)': + '@hono/node-server@2.1.0(hono@4.12.8)': dependencies: hono: 4.12.8 @@ -4934,6 +4929,8 @@ snapshots: spdx-license-ids@3.0.23: {} + srvx@0.12.5: {} + stable-hash-x@0.2.0: {} stackback@0.0.2: {} diff --git a/src/headers.ts b/src/headers.ts new file mode 100644 index 00000000..1f32a4df --- /dev/null +++ b/src/headers.ts @@ -0,0 +1,249 @@ +import type { IncomingMessage } from 'node:http' +import { Http2ServerRequest } from 'node:http2' + +type IncomingHeadersSource = Pick & { + headers?: IncomingMessage['headers'] +} + +// Node's HTTP/1 parser already joins ordinary repeated headers with the same +// separators as WHATWG Headers, so its parsed object is a safe fast path for +// those names. It discards repeats of this fixed set by default, however, and +// HTTP/2 has different collapsing rules; resolve those cases from rawHeaders. +// https://nodejs.org/api/http.html#messageheaders +// https://github.com/nodejs/node/blob/v26.7.0/lib/_http_incoming.js +// https://www.rfc-editor.org/rfc/rfc9110.html#section-5.2 +const nonJoinedHeaders = new Set([ + 'age', + 'authorization', + 'content-length', + 'content-type', + 'etag', + 'expires', + 'from', + 'host', + 'if-modified-since', + 'if-unmodified-since', + 'last-modified', + 'location', + 'max-forwards', + 'proxy-authorization', + 'referer', + 'retry-after', + 'server', + 'user-agent', +]) + +// Converted from RFC 9110's `field-name = token` and `token`/`tchar` ABNF. +// https://www.rfc-editor.org/rfc/rfc9110.html#section-5.1 +// https://www.rfc-editor.org/rfc/rfc9110.html#section-5.6.2 +const validHeaderName = /^[!#$%&'*+\-.^_`|~\dA-Za-z]+$/ + +const isHttpWhitespace = (code: number): boolean => + code === 0x09 || code === 0x0a || code === 0x0d || code === 0x20 + +const normalizeHeaderValue = (value: string): string => { + if ( + !isHttpWhitespace(value.charCodeAt(0)) && + !isHttpWhitespace(value.charCodeAt(value.length - 1)) + ) { + return value + } + let start = 0 + let end = value.length + while (start < end && isHttpWhitespace(value.charCodeAt(start))) { + start++ + } + while (end > start && isHttpWhitespace(value.charCodeAt(end - 1))) { + end-- + } + return value.slice(start, end) +} + +const forbiddenHeaderValue = /[\0\r\n]/ + +export const GlobalHeaders = globalThis.Headers +export type GlobalHeaders = InstanceType + +const materializeHeaders = ( + rawHeaders: string[], + HeadersCtor: typeof GlobalHeaders = GlobalHeaders +): GlobalHeaders => { + const headers = new HeadersCtor() + for (let i = 0; i < rawHeaders.length; i += 2) { + const name = rawHeaders[i] + if (!name.startsWith(':')) { + headers.append(name, rawHeaders[i + 1]) + } + } + return headers +} + +export class RequestHeaders { + #incoming: IncomingHeadersSource + #rawHeaders?: string[] + #headers?: GlobalHeaders + #invalidValue?: boolean + + constructor(incoming: IncomingHeadersSource) { + this.#incoming = incoming + if (incoming instanceof Http2ServerRequest) { + this.#rawHeaders = incoming.rawHeaders.slice() + } + } + + get #lazyRawHeaders(): string[] { + return (this.#rawHeaders ??= this.#incoming.rawHeaders.slice()) + } + + get #native(): GlobalHeaders { + if (!this.#headers) { + this.#headers = materializeHeaders(this.#lazyRawHeaders) + this.#rawHeaders = undefined + } + return this.#headers + } + + #normalizedName(name: string): string | undefined { + if (typeof name !== 'string') { + return + } + if (!validHeaderName.test(name)) { + throw new TypeError(`Invalid header name: ${name}`) + } + return name.toLowerCase() + } + + // The HTTP/1 fast path trusts Node's parser-produced headers object. Mutating + // it through the incoming binding is outside this optimization's contract; + // detecting such changes would require scanning or copying every header. + #lookupHttp1(lowerName: string): string | null | undefined { + const headers = + this.#incoming instanceof Http2ServerRequest ? undefined : this.#incoming.headers + if ( + !headers || + nonJoinedHeaders.has(lowerName) || + lowerName === 'set-cookie' || + lowerName === '__proto__' + ) { + return + } + + if (!Object.hasOwn(headers, lowerName)) { + return null + } + const rawValue = headers[lowerName] + if (typeof rawValue === 'string') { + const value = normalizeHeaderValue(rawValue) + return forbiddenHeaderValue.test(value) ? undefined : value + } + return + } + + #lookup(rawHeaders: string[], lowerName: string): string | null | undefined { + const separator = lowerName === 'cookie' ? '; ' : ', ' + let value: string | null = null + for (let i = 0; i < rawHeaders.length; i += 2) { + const rawName = rawHeaders[i] + if (rawName.length === lowerName.length && rawName.toLowerCase() === lowerName) { + const rawValue = normalizeHeaderValue(rawHeaders[i + 1]) + if (forbiddenHeaderValue.test(rawValue)) { + this.#invalidValue = true + return + } + value = value === null ? rawValue : value + separator + rawValue + } + } + + return value + } + + append(name: string, value: string): void { + this.#native.append(name, value) + } + + delete(name: string): void { + this.#native.delete(name) + } + + get(name: string): string | null { + const lowerName = this.#normalizedName(name) + if (lowerName && !this.#headers && !this.#invalidValue) { + const http1Value = this.#lookupHttp1(lowerName) + if (http1Value !== undefined) { + return http1Value + } + const value = this.#lookup(this.#lazyRawHeaders, lowerName) + if (value !== undefined) { + return value + } + } + return this.#native.get(name) + } + + has(name: string): boolean { + const lowerName = this.#normalizedName(name) + if (lowerName && !this.#headers && !this.#invalidValue) { + const http1Value = this.#lookupHttp1(lowerName) + if (http1Value !== undefined) { + return http1Value !== null + } + const value = this.#lookup(this.#lazyRawHeaders, lowerName) + if (value !== undefined) { + return value !== null + } + } + return this.#native.has(name) + } + + set(name: string, value: string): void { + this.#native.set(name, value) + } + + getSetCookie(): string[] { + return this.#native.getSetCookie() + } + + keys(): HeadersIterator { + return this.#native.keys() + } + + values(): HeadersIterator { + return this.#native.values() + } + + entries(): HeadersIterator<[string, string]> { + return this.#native.entries() + } + + forEach( + callback: (value: string, key: string, parent: GlobalHeaders) => void, + thisArg?: unknown + ): void { + this.#native.forEach((value, key) => { + callback.call(thisArg, value, key, this as unknown as GlobalHeaders) + }) + } + + [Symbol.iterator](): HeadersIterator<[string, string]> { + return this.entries() + } +} + +Object.defineProperty(RequestHeaders.prototype, Symbol.for('nodejs.util.inspect.custom'), { + value: function (this: RequestHeaders, depth: number, options: object, inspectFn: Function) { + const props = Object.fromEntries(this) + return `Headers (lightweight) ${inspectFn(props, { ...options, depth: depth == null ? null : depth - 1 })}` + }, +}) + +// Keep request headers compatible with the captured Headers constructor so +// `request.headers instanceof Headers` remains true without Symbol.hasInstance +// or replacing the global constructor. +Object.setPrototypeOf(RequestHeaders.prototype, GlobalHeaders.prototype) + +// Preserve the previous live-global behavior when a consumer installs a +// Headers polyfill after this module has initialized. +export const newHeadersFromIncoming = (incoming: IncomingHeadersSource): GlobalHeaders => + globalThis.Headers === GlobalHeaders + ? (new RequestHeaders(incoming) as unknown as GlobalHeaders) + : materializeHeaders(incoming.rawHeaders, globalThis.Headers) diff --git a/src/request.ts b/src/request.ts index 813d09e3..a4b0e110 100644 --- a/src/request.ts +++ b/src/request.ts @@ -7,6 +7,8 @@ import { Readable } from 'node:stream' import type { ReadableStreamDefaultReader } from 'node:stream/web' import type { TLSSocket } from 'node:tls' import { RequestError } from './error' +import { newHeadersFromIncoming } from './headers' +import type { GlobalHeaders } from './headers' import { buildUrl } from './url' export { RequestError } @@ -43,20 +45,6 @@ export class Request extends GlobalRequest { } } -export const newHeadersFromIncoming = ( - incoming: Pick -) => { - const headerRecord: [string, string][] = [] - const rawHeaders = incoming.rawHeaders - for (let i = 0, len = rawHeaders.length; i < len; i += 2) { - const key = rawHeaders[i] - if (key.charCodeAt(0) !== /*:*/ 0x3a) { - headerRecord.push([key, rawHeaders[i + 1]]) - } - } - return new Headers(headerRecord) -} - export type IncomingMessageWithWrapBodyStream = IncomingMessage & { [wrapBodyStream]: boolean } export const wrapBodyStream = Symbol('wrapBodyStream') @@ -184,7 +172,7 @@ const enqueueBufferedBody = ( const newRequestFromIncoming = ( method: string, url: string, - headers: Headers, + headers: GlobalHeaders, incoming: IncomingMessage | Http2ServerRequest, abortController: AbortController ): Request => { diff --git a/test/headers.test.ts b/test/headers.test.ts new file mode 100644 index 00000000..1f0740a8 --- /dev/null +++ b/test/headers.test.ts @@ -0,0 +1,252 @@ +import type { IncomingMessage } from 'node:http' +import { Http2ServerRequest } from 'node:http2' +import type { ServerHttp2Stream } from 'node:http2' +import { Duplex } from 'node:stream' +import { inspect } from 'node:util' +import { GlobalHeaders, RequestHeaders, newHeadersFromIncoming } from '../src/headers' +import { newRequest, Request as LightweightRequest } from '../src/request' + +// Compatibility cases adapted from srvx's Node header suite: +// https://github.com/h3js/srvx/blob/4052594e76d5ead2cc4c7cf8f7fa6d5ea9558a0a/test/node-headers.test.ts + +const incoming = (rawHeaders: string[], headers: Record): IncomingMessage => + ({ rawHeaders, headers }) as IncomingMessage + +const incomingHttp2 = ( + rawHeaders: string[], + headers: Record +): Http2ServerRequest => + new Http2ServerRequest(new Duplex() as ServerHttp2Stream, headers, {}, rawHeaders) + +const lightweightHeaders = (request: IncomingMessage | Http2ServerRequest): GlobalHeaders => + newHeadersFromIncoming(request) + +const nonJoinedHeaderNames = [ + 'age', + 'authorization', + 'content-length', + 'content-type', + 'etag', + 'expires', + 'from', + 'host', + 'if-modified-since', + 'if-unmodified-since', + 'last-modified', + 'location', + 'max-forwards', + 'proxy-authorization', + 'referer', + 'retry-after', + 'server', + 'user-agent', +] + +describe('RequestHeaders', () => { + it('reads common headers without iterating rawHeaders', () => { + let rawHeadersReads = 0 + const request = { + headers: { 'x-test': 'value' }, + get rawHeaders() { + rawHeadersReads++ + return ['x-test', 'value'] + }, + } as unknown as IncomingMessage + + const headers = lightweightHeaders(request) + + expect(headers).toBeInstanceOf(GlobalHeaders) + expect(headers.get('X-Test')).toBe('value') + expect(headers.has('x-test')).toBe(true) + expect(rawHeadersReads).toBe(0) + expect(() => headers.get('bad name')).toThrow(TypeError) + expect(() => headers.has(':path')).toThrow(TypeError) + expect(rawHeadersReads).toBe(0) + + expect([...headers]).toEqual([['x-test', 'value']]) + expect(rawHeadersReads).toBe(1) + }) + + it.each(nonJoinedHeaderNames)( + 'combines repeated %s values consistently before and after iteration', + (name) => { + const rawHeaders = [name, 'one', name, 'two'] + const collapsed = { [name]: 'one' } + + const before = lightweightHeaders(incoming(rawHeaders, collapsed)) + expect(before.get(name)).toBe('one, two') + expect(before.has(name)).toBe(true) + + const after = lightweightHeaders(incoming(rawHeaders, collapsed)) + void [...after] + expect(after.get(name)).toBe('one, two') + } + ) + + it('combines headers collapsed by the HTTP/2 parser', () => { + const headers = lightweightHeaders( + incomingHttp2(['if-none-match', '"a"', 'if-none-match', '"b"'], { + 'if-none-match': '"a"', + }) + ) + + expect(headers.get('if-none-match')).toBe('"a", "b"') + void [...headers] + expect(headers.get('if-none-match')).toBe('"a", "b"') + }) + + it('uses an immutable raw-header snapshot for HTTP/2', () => { + const rawHeaders = ['x-secret', 'topsecret', 'host', 'localhost'] + const request = incomingHttp2(rawHeaders, { + 'x-secret': 'topsecret', + host: 'localhost', + }) + const headers = lightweightHeaders(request) + + delete request.headers['x-secret'] + request.headers['x-added'] = 'value' + rawHeaders[1] = 'changed' + rawHeaders.push('x-added', 'value') + + expect(headers.get('x-secret')).toBe('topsecret') + expect(headers.has('x-added')).toBe(false) + void [...headers] + expect(headers.get('x-secret')).toBe('topsecret') + expect(headers.has('x-added')).toBe(false) + }) + + it('ignores non-string values in synthesized parsed headers', () => { + const headers = lightweightHeaders( + incoming(['content-length', '123'], { 'content-length': 123 }) + ) + + expect(headers.get('content-length')).toBe('123') + expect(headers.has('content-length')).toBe(true) + void [...headers] + expect(headers.get('content-length')).toBe('123') + }) + + it('normalizes raw values consistently before and after materialization', () => { + const rawHeaders = ['x-token', ' abc\t', 'x-tab', '\tv v\t', 'x-interior', 'a b'] + const headers = lightweightHeaders(incomingHttp2(rawHeaders, {})) + + expect(headers.get('x-token')).toBe('abc') + expect(headers.get('x-tab')).toBe('v v') + expect(headers.get('x-interior')).toBe('a b') + void [...headers] + expect(headers.get('x-token')).toBe('abc') + expect(headers.get('x-tab')).toBe('v v') + expect(headers.get('x-interior')).toBe('a b') + }) + + it('fails closed on values rejected by native Headers', () => { + const rawHeaders = ['x-evil', 'ok', 'x-evil', 'bad\0value'] + + expect(() => lightweightHeaders(incomingHttp2(rawHeaders, {})).get('x-evil')).toThrow(TypeError) + expect(() => lightweightHeaders(incomingHttp2(rawHeaders, {})).has('x-evil')).toThrow(TypeError) + expect(() => [...lightweightHeaders(incomingHttp2(rawHeaders, {}))]).toThrow(TypeError) + }) + + it('preserves cookie and set-cookie representations', () => { + const headers = lightweightHeaders( + incoming(['cookie', 'a=1', 'cookie', 'b=2', 'set-cookie', 'a=1', 'set-cookie', 'b=2'], { + cookie: 'a=1; b=2', + 'set-cookie': ['ignored'], + }) + ) + + expect(headers.get('cookie')).toBe('a=1; b=2') + expect(headers.get('set-cookie')).toBe('a=1, b=2') + expect(headers.getSetCookie()).toEqual(['a=1', 'b=2']) + expect(Object.fromEntries(headers).cookie).toBe('a=1; b=2') + }) + + it('reads a literal __proto__ header without exposing prototype properties', () => { + const headers = lightweightHeaders( + incoming(['__proto__', 'value', 'x-test', 'one'], { 'x-test': 'one' }) + ) + + expect(headers.get('__proto__')).toBe('value') + expect(headers.has('__proto__')).toBe(true) + expect(headers.get('toString')).toBe(null) + expect(headers.has('toString')).toBe(false) + }) + + it('materializes for validation and mutation while preserving identity', () => { + const headers = lightweightHeaders(incoming(['host', 'localhost'], { host: 'localhost' })) + + expect(() => headers.get('bad name')).toThrow(TypeError) + expect(() => headers.has(':path')).toThrow(TypeError) + headers.set('x-test', 'value') + headers.append('x-test', 'second') + + expect(headers.get('x-test')).toBe('value, second') + let callbackParent: GlobalHeaders | undefined + headers.forEach((_value, _key, parent) => { + callbackParent = parent + }) + expect(callbackParent).toBe(headers) + }) + + it('uses the lightweight inspection format', () => { + const headers = lightweightHeaders(incoming(['x-test', 'value'], { 'x-test': 'value' })) + + expect(inspect(headers)).toContain("Headers (lightweight) { 'x-test': 'value' }") + }) + + it('leaves the standard Headers constructor unchanged', () => { + const headers = new Headers({ 'x-test': 'one' }) + headers.append('x-test', 'two') + + expect(global.Headers).toBe(GlobalHeaders) + expect(Object.getPrototypeOf(headers)).toBe(GlobalHeaders.prototype) + expect(headers.get('x-test')).toBe('one, two') + expect(new Headers(headers).get('x-test')).toBe('one, two') + expect(new Headers({ rawHeaders: 'ordinary value' }).get('rawHeaders')).toBe('ordinary value') + }) + + it('uses the internal implementation without replacing the global constructor', () => { + const request = incoming(['x-test', 'value'], { 'x-test': 'value' }) + const headers = newHeadersFromIncoming(request) + + expect(global.Headers).toBe(GlobalHeaders) + expect(Object.getPrototypeOf(headers)).toBe(RequestHeaders.prototype) + expect(headers).toBeInstanceOf(GlobalHeaders) + expect(new GlobalHeaders(headers).get('x-test')).toBe('value') + }) + + it('uses the live global Headers constructor when it changes after module initialization', () => { + class PolyfillHeaders extends GlobalHeaders {} + const descriptor = Object.getOwnPropertyDescriptor(globalThis, 'Headers') + Object.defineProperty(globalThis, 'Headers', { + value: PolyfillHeaders, + configurable: true, + writable: true, + }) + + try { + const headers = newHeadersFromIncoming(incoming(['x-test', 'value'], { 'x-test': 'value' })) + + expect(headers).toBeInstanceOf(PolyfillHeaders) + expect(Object.getPrototypeOf(headers)).toBe(PolyfillHeaders.prototype) + expect(headers.get('x-test')).toBe('value') + } finally { + Object.defineProperty(globalThis, 'Headers', descriptor!) + } + }) + + it('can initialize and clone a native Request after header mutation', () => { + const request = newRequest({ + method: 'GET', + url: '/', + headers: { host: 'localhost' }, + rawHeaders: ['host', 'localhost'], + } as IncomingMessage) + const headers = request.headers + headers.set('x-test', 'value') + + expect(request.keepalive).toBe(false) + expect(request.headers).toBe(headers) + expect(new LightweightRequest(request).headers.get('x-test')).toBe('value') + }) +}) diff --git a/test/helpers/request.ts b/test/helpers/request.ts index 7a342429..8c1017dc 100644 --- a/test/helpers/request.ts +++ b/test/helpers/request.ts @@ -5,7 +5,7 @@ import { connect } from 'node:http2' import type { IncomingHttpHeaders as IncomingHttp2Headers } from 'node:http2' import { Server as HttpsServer, request as requestHTTPS } from 'node:https' import type { AddressInfo } from 'node:net' -import { newHeadersFromIncoming } from '../../src/request' +import { newHeadersFromIncoming } from '../../src/headers' import { GlobalResponse } from '../../src/response' import type { ServerType } from '../../src/types' diff --git a/test/listener.test.ts b/test/listener.test.ts index 9765f128..14a76901 100644 --- a/test/listener.test.ts +++ b/test/listener.test.ts @@ -2,6 +2,7 @@ import { EventEmitter } from 'node:events' import { createServer } from 'node:http' import type { IncomingMessage, ServerResponse } from 'node:http' import { Readable } from 'node:stream' +import { GlobalHeaders } from '../src/headers' import { getRequestListener } from '../src/listener' import { GlobalRequest, Request as LightweightRequest, RequestError } from '../src/request' import { GlobalResponse, Response as LightweightResponse } from '../src/response' @@ -630,6 +631,10 @@ describe('overrideGlobalObjects', () => { const fetchCallback = vi.fn() beforeEach(() => { + Object.defineProperty(global, 'Headers', { + value: GlobalHeaders, + writable: true, + }) Object.defineProperty(global, 'Request', { value: GlobalRequest, writable: true, @@ -643,6 +648,7 @@ describe('overrideGlobalObjects', () => { describe('default', () => { it('Should be overridden', () => { getRequestListener(fetchCallback) + expect(global.Headers).toBe(GlobalHeaders) expect(global.Request).toBe(LightweightRequest) expect(global.Response).toBe(LightweightResponse) }) @@ -653,6 +659,7 @@ describe('overrideGlobalObjects', () => { getRequestListener(fetchCallback, { overrideGlobalObjects: true, }) + expect(global.Headers).toBe(GlobalHeaders) expect(global.Request).toBe(LightweightRequest) expect(global.Response).toBe(LightweightResponse) }) @@ -663,6 +670,7 @@ describe('overrideGlobalObjects', () => { getRequestListener(fetchCallback, { overrideGlobalObjects: false, }) + expect(global.Headers).toBe(GlobalHeaders) expect(global.Request).toBe(GlobalRequest) expect(global.Response).toBe(GlobalResponse) }) diff --git a/test/server.test.ts b/test/server.test.ts index 1182a862..387353d4 100644 --- a/test/server.test.ts +++ b/test/server.test.ts @@ -8,6 +8,7 @@ import fs from 'node:fs' import { createServer as createHttp2Server } from 'node:http2' import { createServer as createHTTPSServer } from 'node:https' import { gunzipSync, inflateSync } from 'node:zlib' +import { GlobalHeaders } from '../src/headers' import { GlobalRequest, Request as LightweightRequest, getAbortController } from '../src/request' import { GlobalResponse, Response as LightweightResponse } from '../src/response' import { createAdaptorServer, serve } from '../src/server' @@ -1104,6 +1105,10 @@ describe('overrideGlobalObjects', () => { const app = new Hono() beforeEach(() => { + Object.defineProperty(global, 'Headers', { + value: GlobalHeaders, + writable: true, + }) Object.defineProperty(global, 'Request', { value: GlobalRequest, writable: true, @@ -1117,6 +1122,7 @@ describe('overrideGlobalObjects', () => { describe('default', () => { it('Should be overridden', () => { createAdaptorServer(app) + expect(global.Headers).toBe(GlobalHeaders) expect(global.Request).toBe(LightweightRequest) expect(global.Response).toBe(LightweightResponse) }) @@ -1125,6 +1131,7 @@ describe('overrideGlobalObjects', () => { describe('overrideGlobalObjects: true', () => { it('Should be overridden', () => { createAdaptorServer({ overrideGlobalObjects: true, fetch: app.fetch }) + expect(global.Headers).toBe(GlobalHeaders) expect(global.Request).toBe(LightweightRequest) expect(global.Response).toBe(LightweightResponse) }) @@ -1133,6 +1140,7 @@ describe('overrideGlobalObjects', () => { describe('overrideGlobalObjects: false', () => { it('Should not be overridden', () => { createAdaptorServer({ overrideGlobalObjects: false, fetch: app.fetch }) + expect(global.Headers).toBe(GlobalHeaders) expect(global.Request).toBe(GlobalRequest) expect(global.Response).toBe(GlobalResponse) }) diff --git a/test/setup.ts b/test/setup.ts index c89e1b7d..0778d417 100644 --- a/test/setup.ts +++ b/test/setup.ts @@ -10,3 +10,7 @@ Object.defineProperty(global, 'Request', { value: global.Request, writable: true, }) +Object.defineProperty(global, 'Headers', { + value: global.Headers, + writable: true, +})