diff --git a/src/listener.ts b/src/listener.ts index c8c2763..d96a6e2 100644 --- a/src/listener.ts +++ b/src/listener.ts @@ -11,7 +11,12 @@ import { wrapBodyStream, toRequestError, } from './request' -import { defaultContentType, cacheKey, Response as LightweightResponse } from './response' +import { + defaultContentType, + cacheKey, + consumeSharedBody, + Response as LightweightResponse, +} from './response' import type { InternalCache } from './response' import type { CustomErrorHandler, FetchCallback, HttpBindings } from './types' import { @@ -169,6 +174,9 @@ const responseViaCache = async ( res: Response, outgoing: ServerResponse | Http2ServerResponse ): Promise => { + if (!consumeSharedBody(res)) { + return responseViaResponseObject(res, outgoing) + } // eslint-disable-next-line @typescript-eslint/no-explicit-any let [status, body, header] = (res as any)[cacheKey] as InternalCache diff --git a/src/response.ts b/src/response.ts index 2ba92cd..5c1bf23 100644 --- a/src/response.ts +++ b/src/response.ts @@ -2,12 +2,14 @@ // Define lightweight pseudo Response class and replace global.Response with it. import type { OutgoingHttpHeaders } from 'node:http' +import { types } from 'node:util' export const defaultContentType = 'text/plain; charset=UTF-8' const responseCache = Symbol('responseCache') const getResponseCache = Symbol('getResponseCache') export const cacheKey = Symbol('cache') +export const copyHeaders = Symbol.for('hono.response.copyHeaders') export type InternalCache = [ number, @@ -19,12 +21,133 @@ interface LightResponse { [cacheKey]?: InternalCache } +interface SharedBody { + body: string | null + members: Response[] + sent: boolean + materialized: boolean +} + +const materializeSharedBody = (group: SharedBody): void => { + if (group.materialized) { + return + } + group.materialized = true + let first: globalThis.Response | undefined + for (const member of group.members) { + const cached = member as LightResponse + const [status, , headers] = cached[cacheKey]! + const native = new GlobalResponse(first ? first.body : group.body, { + status, + headers: headers as Headers, + }) + first ||= native + cached[responseCache] = native + delete cached[cacheKey] + } + if (group.sent && first?.body) { + const reader = first.body.getReader() + void reader.read() + void reader.read() + } +} + +// A second send must observe the same consumed stream as a materialized response. +export const consumeSharedBody = (response: globalThis.Response): boolean => { + const group = Response.sharedBodyFor(response) + if (group?.sent) { + materializeSharedBody(group) + return false + } + if (group && !group.materialized) { + group.sent = true + } + return true +} + export const GlobalResponse = global.Response export class Response { #body?: BodyInit | null - #init?: ResponseInit; + #init?: ResponseInit + #sharedBody?: SharedBody + + static sharedBodyFor(response: object): SharedBody | undefined { + return #sharedBody in response ? response.#sharedBody : undefined + } + + static [copyHeaders](response: globalThis.Response): globalThis.Response | undefined { + if ( + types.isProxy(response) || + Object.getPrototypeOf(response) !== Response.prototype || + Object.getOwnPropertyNames(response).length !== 0 + ) { + return + } + const original = response as unknown as Response + if (!(#sharedBody in original)) { + return + } + const cache = (original as LightResponse)[cacheKey] + if ( + !cache || + (original as LightResponse)[responseCache] || + !(cache[1] === null || typeof cache[1] === 'string') + ) { + return + } + let group = original.#sharedBody + if (group?.sent || (group && group.members.length >= 64)) { + return + } + const init = original.#init + if ( + !group && + init && + (types.isProxy(init) || + Object.getPrototypeOf(init) !== Object.prototype || + Reflect.ownKeys(init).some( + (key) => + !['status', 'headers', 'statusText'].includes(key as string) || + !Object.hasOwn(Object.getOwnPropertyDescriptor(init, key)!, 'value') + )) + ) { + return + } + const status = group ? cache[0] : (init?.status ?? 200) + if ( + !group && + (!Number.isInteger(status) || + status < 200 || + status > 599 || + status !== cache[0] || + (init?.statusText !== undefined && init.statusText !== '') || + (cache[1] !== null && [204, 205, 304].includes(status))) + ) { + return + } + if (!group) { + const headers = new Headers(cache[2] instanceof Headers ? cache[2] : init?.headers) + if (cache[1] !== null && !headers.has('content-type')) { + headers.set('content-type', 'text/plain;charset=UTF-8') + } + cache[2] = headers + group = { body: cache[1], members: [original], sent: false, materialized: false } + original.#sharedBody = group + } + const replacement = new Response(cache[1], { + status, + headers: new Headers(cache[2] as Headers), + }) + group.members.push(replacement) + replacement.#sharedBody = group + return replacement as unknown as globalThis.Response + } [getResponseCache](): globalThis.Response { + if (this.#sharedBody) { + materializeSharedBody(this.#sharedBody) + return (this as LightResponse)[responseCache]! + } // If `cacheKey` has been populated with a live `Headers` instance, the // user (or middleware) may have mutated it after construction. Use those // headers so the GlobalResponse reflects the current state. diff --git a/test/response-copy.test.ts b/test/response-copy.test.ts new file mode 100644 index 0000000..bc9a659 --- /dev/null +++ b/test/response-copy.test.ts @@ -0,0 +1,227 @@ +import { once } from 'node:events' +import { createServer } from 'node:http' +import type { AddressInfo } from 'node:net' +import { getRequestListener } from '../src/listener' +import { + GlobalResponse, + Response as LightweightResponse, + cacheKey, + copyHeaders, + consumeSharedBody, +} from '../src/response' + +const copy = LightweightResponse[copyHeaders] +const light = (body: BodyInit | null, init?: ResponseInit) => + new LightweightResponse(body, init) as unknown as Response + +describe('Response header copies', () => { + it('matches the rebuild path content type before and after header observation', () => { + const initializers: (ResponseInit | undefined)[] = [ + undefined, + {}, + { headers: {} }, + { headers: { 'x-before': 'yes' } }, + { headers: { 'content-type': 'text/custom' } }, + ] + for (const init of initializers) { + for (const observeHeaders of [false, true]) { + const original = light('value', init) + const control = light('value', init) + if (observeHeaders) { + void original.headers + void control.headers + } + const expected = new GlobalResponse(control.body, control) + const replacement = copy(original)! + expect([...replacement.headers]).toEqual([...expected.headers]) + } + } + }) + + it('preserves saved response state after real HTTP writes', async () => { + const states = [] + for (const optimized of [false, true]) { + let saved: Response + const server = createServer( + getRequestListener( + () => { + saved = light('héllo') + const response = optimized ? copy(saved)! : new GlobalResponse(saved.body, saved) + response.headers.append('set-cookie', 'a=1') + response.headers.append('set-cookie', 'b=2') + response.headers.set('x-after', 'yes') + return response + }, + { overrideGlobalObjects: false } + ) + ) + try { + server.listen(0, '127.0.0.1') + await once(server, 'listening') + const url = `http://127.0.0.1:${(server.address() as AddressInfo).port}` + for (const method of ['GET', 'HEAD']) { + const response = await fetch(url, { method }) + const text = await response.text() + states.push({ + method, + text, + headers: [...response.headers].filter(([key]) => key !== 'date'), + savedHeaders: [...saved!.headers], + used: saved!.bodyUsed, + locked: saved!.body!.locked, + }) + } + } finally { + server.closeAllConnections() + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())) + ) + } + } + expect(states.slice(2)).toEqual(states.slice(0, 2)) + }) + + it('keeps strings buffered while copying headers independently', async () => { + const original = light('héllo', { headers: { 'x-original': 'yes' } }) + const saved = original.headers + const replacement = copy(original)! + expect(cacheKey in original).toBe(true) + expect(cacheKey in replacement).toBe(true) + replacement.headers.set('x-after', 'yes') + saved.set('x-saved', 'yes') + expect(replacement.headers.get('x-saved')).toBeNull() + expect(original.headers.get('x-after')).toBeNull() + expect(replacement.headers.get('content-type')).toBe('text/plain;charset=UTF-8') + expect(await replacement.text()).toBe('héllo') + expect(original.bodyUsed).toBe(true) + await expect(original.text()).rejects.toThrow(TypeError) + }) + + it.each(['original', 'replacement', 'clone', 'locked'] as const)( + 'preserves native shared-body semantics when observing %s first', + async (observe) => { + const results = [] + for (const optimized of [false, true]) { + const original = light('value') + const replacement = optimized + ? copy(original)! + : new GlobalResponse(original.body, original) + const read = async (response: Response) => { + try { + return await response.text() + } catch (error) { + return (error as Error).name + } + } + let values: string[] + if (observe === 'clone') { + values = [await read(original.clone()), await read(replacement), await read(original)] + } else if (observe === 'locked') { + const reader = original.body!.getReader() + values = [await read(replacement)] + reader.releaseLock() + } else { + const first = observe === 'original' ? original : replacement + const second = observe === 'original' ? replacement : original + values = [await read(first), await read(second)] + } + results.push({ + values, + used: original.bodyUsed, + shared: original.body === replacement.body, + }) + } + expect(results[1]).toEqual(results[0]) + } + ) + + it('materializes all saved wrappers on body observation', () => { + const original = light('value') + const replacement = copy(copy(original)!)! + expect(replacement.body).toBe(original.body) + expect(cacheKey in original).toBe(false) + expect(cacheKey in replacement).toBe(false) + }) + + it('preserves consumption after a buffered send', async () => { + const original = light('value') + const replacement = copy(original)! + expect(consumeSharedBody(replacement)).toBe(true) + expect(original.bodyUsed).toBe(true) + expect(original.body!.locked).toBe(true) + await expect(replacement.text()).rejects.toThrow(TypeError) + }) + + it('materializes on a repeated send instead of reusing buffered bytes', () => { + const replacement = copy(light('value'))! + expect(consumeSharedBody(replacement)).toBe(true) + expect(consumeSharedBody(replacement)).toBe(false) + expect(cacheKey in replacement).toBe(false) + expect(replacement.bodyUsed).toBe(true) + }) + + it('preserves null bodies and duplicate cookies', async () => { + const original = light(null, { status: 204 }) + original.headers.append('set-cookie', 'a=1') + original.headers.append('set-cookie', 'b=2') + const replacement = copy(original)! + expect(replacement.headers.getSetCookie()).toEqual(['a=1', 'b=2']) + expect(replacement.headers.has('content-type')).toBe(false) + expect(replacement.status).toBe(204) + expect(await replacement.text()).toBe('') + expect(original.bodyUsed).toBe(false) + }) + + it('bounds the number of deferred wrappers', () => { + let response = light('value') + for (let i = 0; i < 63; i++) { + response = copy(response)! + expect(response).toBeDefined() + } + expect(copy(response)).toBeUndefined() + expect(new GlobalResponse(response.body, response).body).toBe(response.body) + }) + + it('declines native responses, subclasses, streams, byte arrays and observed bodies', () => { + const observed = light('value') + void observed.body + for (const response of [ + new GlobalResponse('value'), + new (class extends LightweightResponse {})('value') as unknown as Response, + light(new Uint8Array([1])), + light( + new ReadableStream({ + start(controller) { + controller.close() + }, + }) + ), + observed, + light('value', { statusText: 'Custom' }), + light('value', { + get status() { + return 200 + }, + }), + ]) { + expect(copy(response)).toBeUndefined() + } + }) + + it('does not invoke proxy traps, response properties or forged per-response hooks', () => { + const original = light('value') + const trap = vi.fn(() => { + throw new Error('unexpected access') + }) + expect(copy(new Proxy(original, { get: trap, getPrototypeOf: trap }))).toBeUndefined() + expect(trap).not.toHaveBeenCalled() + Object.defineProperty(original, Symbol.for('hono.response.copyHeaders'), { value: trap }) + expect(copy(original)).toBeDefined() + expect(trap).not.toHaveBeenCalled() + const customized = light('value') + Object.defineProperty(customized, 'body', { get: trap }) + expect(copy(customized)).toBeUndefined() + expect(trap).not.toHaveBeenCalled() + expect(copy(Object.create(LightweightResponse.prototype))).toBeUndefined() + }) +})