diff --git a/src/listener.ts b/src/listener.ts index 8e92635..504c528 100644 --- a/src/listener.ts +++ b/src/listener.ts @@ -6,7 +6,8 @@ import { Request as LightweightRequest, toRequestError, } from './request' -import { cacheKey, getInternalBody, Response as LightweightResponse } from './response' +import { cacheKey, Response as LightweightResponse } from './response' +import type { InternalCache } from './response' import type { CustomErrorHandler, FetchCallback, HttpBindings } from './types' import { writeFromReadableStream, buildOutgoingHttpHeaders } from './utils' import { X_ALREADY_SENT } from './utils/response/constants' @@ -44,18 +45,30 @@ const handleResponseError = (e: unknown, outgoing: ServerResponse | Http2ServerR } } -const responseViaCache = ( +const responseViaCache = async ( res: Response, outgoing: ServerResponse | Http2ServerResponse -): undefined | Promise => { +): Promise => { // eslint-disable-next-line @typescript-eslint/no-explicit-any - const [status, body, header] = (res as any)[cacheKey] + let [status, body, header] = (res as any)[cacheKey] as InternalCache + if (header instanceof Headers) { + header = buildOutgoingHttpHeaders(header) + } + if (typeof body === 'string') { header['Content-Length'] = Buffer.byteLength(body) - outgoing.writeHead(status, header) + } else if (body instanceof Uint8Array) { + header['Content-Length'] = body.byteLength + } else if (body instanceof Blob) { + header['Content-Length'] = body.size + } + + outgoing.writeHead(status, header) + if (typeof body === 'string' || body instanceof Uint8Array) { outgoing.end(body) + } else if (body instanceof Blob) { + outgoing.end(new Uint8Array(await body.arrayBuffer())) } else { - outgoing.writeHead(status, header) return writeFromReadableStream(body, outgoing)?.catch( (e) => handleResponseError(e, outgoing) as undefined ) @@ -89,29 +102,6 @@ const responseViaResponseObject = async ( const resHeaderRecord: OutgoingHttpHeaders = buildOutgoingHttpHeaders(res.headers) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const internalBody = getInternalBody(res as any) - if (internalBody) { - const { length, source, stream } = internalBody - if (source instanceof Uint8Array && source.byteLength !== length) { - // maybe `source` is detached, so we should send via res.body - } else { - // send via internal raw data - if (length) { - resHeaderRecord['content-length'] = length - } - outgoing.writeHead(res.status, resHeaderRecord) - if (typeof source === 'string' || source instanceof Uint8Array) { - outgoing.end(source) - } else if (source instanceof Blob) { - outgoing.end(new Uint8Array(await source.arrayBuffer())) - } else { - await writeFromReadableStream(stream, outgoing) - } - return - } - } - if (res.body) { /** * If content-encoding is set, we assume that the response should be not decoded. diff --git a/src/response.ts b/src/response.ts index 5a50a73..914f05f 100644 --- a/src/response.ts +++ b/src/response.ts @@ -2,29 +2,33 @@ // Define lightweight pseudo Response class and replace global.Response with it. import type { OutgoingHttpHeaders } from 'node:http' -import { buildOutgoingHttpHeaders } from './utils' - -interface InternalBody { - source: string | Uint8Array | FormData | Blob | null - stream: ReadableStream - length: number | null -} const responseCache = Symbol('responseCache') const getResponseCache = Symbol('getResponseCache') export const cacheKey = Symbol('cache') +export type InternalCache = [ + number, + string | ReadableStream, + Record | Headers | OutgoingHttpHeaders, +] +interface LightResponse { + [responseCache]?: globalThis.Response + [cacheKey]?: InternalCache +} + export const GlobalResponse = global.Response export class Response { #body?: BodyInit | null #init?: ResponseInit; - [getResponseCache](): typeof GlobalResponse { - delete (this as any)[cacheKey] - return ((this as any)[responseCache] ||= new GlobalResponse(this.#body, this.#init)) + [getResponseCache](): globalThis.Response { + delete (this as LightResponse)[cacheKey] + return ((this as LightResponse)[responseCache] ||= new GlobalResponse(this.#body, this.#init)) } constructor(body?: BodyInit | null, init?: ResponseInit) { + let headers: HeadersInit this.#body = body if (init instanceof Response) { const cachedGlobalResponse = (init as any)[responseCache] @@ -35,36 +39,48 @@ export class Response { return } else { this.#init = init.#init + // clone headers to avoid sharing the same object between parent and child + headers = new Headers((init.#init as ResponseInit).headers) } } else { this.#init = init } - if (typeof body === 'string' || typeof (body as ReadableStream)?.getReader !== 'undefined') { - let headers = (init?.headers || { 'content-type': 'text/plain; charset=UTF-8' }) as - | Record - | Headers - | OutgoingHttpHeaders - if (headers instanceof Headers) { - headers = buildOutgoingHttpHeaders(headers) - } - + if ( + typeof body === 'string' || + typeof (body as ReadableStream)?.getReader !== 'undefined' || + body instanceof Blob || + body instanceof Uint8Array + ) { + headers ||= init?.headers || { 'content-type': 'text/plain; charset=UTF-8' } ;(this as any)[cacheKey] = [init?.status || 200, body, headers] } } + + get headers(): Headers { + const cache = (this as LightResponse)[cacheKey] as InternalCache + if (cache) { + if (!(cache[2] instanceof Headers)) { + cache[2] = new Headers(cache[2] as HeadersInit) + } + return cache[2] + } + return this[getResponseCache]().headers + } + + get status() { + return ( + ((this as LightResponse)[cacheKey] as InternalCache | undefined)?.[0] ?? + this[getResponseCache]().status + ) + } + + get ok() { + const status = this.status + return status >= 200 && status < 300 + } } -;[ - 'body', - 'bodyUsed', - 'headers', - 'ok', - 'redirected', - 'status', - 'statusText', - 'trailers', - 'type', - 'url', -].forEach((k) => { +;['body', 'bodyUsed', 'redirected', 'statusText', 'trailers', 'type', 'url'].forEach((k) => { Object.defineProperty(Response.prototype, k, { get() { return this[getResponseCache]()[k] @@ -80,26 +96,3 @@ export class Response { }) Object.setPrototypeOf(Response, GlobalResponse) Object.setPrototypeOf(Response.prototype, GlobalResponse.prototype) - -const stateKey = Reflect.ownKeys(new GlobalResponse()).find( - (k) => typeof k === 'symbol' && k.toString() === 'Symbol(state)' -) as symbol | undefined -if (!stateKey) { - console.warn('Failed to find Response internal state key') -} - -export function getInternalBody( - response: Response | typeof GlobalResponse -): InternalBody | undefined { - if (!stateKey) { - return - } - - if (response instanceof Response) { - response = (response as any)[getResponseCache]() - } - - const state = (response as any)[stateKey] as { body?: InternalBody } | undefined - - return (state && state.body) || undefined -} diff --git a/test/response.test.ts b/test/response.test.ts index 1716527..e999e2f 100644 --- a/test/response.test.ts +++ b/test/response.test.ts @@ -1,7 +1,7 @@ import { createServer } from 'node:http' import type { Server } from 'node:http' import type { AddressInfo } from 'node:net' -import { GlobalResponse, Response as LightweightResponse } from '../src/response' +import { GlobalResponse, Response as LightweightResponse, cacheKey } from '../src/response' Object.defineProperty(global, 'Response', { value: LightweightResponse, @@ -99,4 +99,23 @@ describe('Response', () => { ) expect(await childResponse.text()).toEqual('HONO') }) + + describe('Fallback to GlobalResponse object', () => { + it('Should return value from internal cache', () => { + const res = new Response('Hello! Node!') + res.headers.set('x-test', 'test') + expect(res.headers.get('x-test')).toEqual('test') + expect(res.status).toEqual(200) + expect(res.ok).toEqual(true) + expect(cacheKey in res).toBe(true) + }) + + it('Should return value from generated GlobalResponse object', () => { + const res = new Response('Hello! Node!', { + statusText: 'OK', + }) + expect(res.statusText).toEqual('OK') + expect(cacheKey in res).toBe(false) + }) + }) }) diff --git a/test/server.test.ts b/test/server.test.ts index 2cb36b3..745efc9 100644 --- a/test/server.test.ts +++ b/test/server.test.ts @@ -107,7 +107,7 @@ describe('Basic', () => { }) }) -describe('via internal body', () => { +describe('various response body types', () => { const app = new Hono() app.use('*', async (c, next) => { await next()