From 443c3ab4966ca9e3eb6c796ea24bee2576548036 Mon Sep 17 00:00:00 2001 From: Taku Amano Date: Mon, 13 Nov 2023 17:03:14 +0900 Subject: [PATCH 01/23] perf: Reduce object generation and optimize performance. --- src/listener.ts | 148 ++++++++++++++++++++++++++++++++++++-------- test/server.test.ts | 10 +++ 2 files changed, 132 insertions(+), 26 deletions(-) diff --git a/src/listener.ts b/src/listener.ts index 64ceb0b..ba0faf9 100644 --- a/src/listener.ts +++ b/src/listener.ts @@ -8,46 +8,142 @@ import { writeFromReadableStream } from './utils' const regBuffer = /^no$/i const regContentType = /^(application\/json\b|text\/(?!event-stream\b))/i -export const getRequestListener = (fetchCallback: FetchCallback) => { - return async ( - incoming: IncomingMessage | Http2ServerRequest, - outgoing: ServerResponse | Http2ServerResponse - ) => { - const method = incoming.method || 'GET' - const url = `http://${incoming.headers.host}${incoming.url}` - - const headerRecord: [string, string][] = [] - const len = incoming.rawHeaders.length - for (let i = 0; i < len; i += 2) { - headerRecord.push([incoming.rawHeaders[i], incoming.rawHeaders[i + 1]]) +class CustomResponse extends global.Response { + public __cache: [string, Record] | undefined + constructor(body: BodyInit | null, init?: ResponseInit) { + super(body, init) + if (typeof body === 'string' && !(init?.headers instanceof Headers)) { + this.__cache = [body, (init?.headers || {}) as Record] } + } + get headers() { + // discard cache if headers are retrieved as they may change + this.__cache = undefined + return super.headers + } +} +Object.defineProperty(global, 'Response', { + value: CustomResponse, +}) - const init = { - method: method, - headers: headerRecord, - } as RequestInit +function newRequestFromIncoming( + method: string, + url: string, + incoming: IncomingMessage | Http2ServerRequest +): Request { + const headerRecord: [string, string][] = [] + const len = incoming.rawHeaders.length + for (let i = 0; i < len; i += 2) { + headerRecord.push([incoming.rawHeaders[i], incoming.rawHeaders[i + 1]]) + } - if (!(method === 'GET' || method === 'HEAD')) { - // lazy-consume request body - init.body = Readable.toWeb(incoming) as ReadableStream - // node 18 fetch needs half duplex mode when request body is stream - ;(init as any).duplex = 'half' - } + const init = { + method: method, + headers: headerRecord, + } as RequestInit - let res: Response + if (!(method === 'GET' || method === 'HEAD')) { + // lazy-consume request body + init.body = Readable.toWeb(incoming) as ReadableStream + // node 18 fetch needs half duplex mode when request body is stream + ;(init as any).duplex = 'half' + } + return new Request(url, init) +} + +const requestPrototype: Record = { + request() { + return (this.requestCache ||= newRequestFromIncoming(this.method, this.url, this.incoming)) + }, + get body() { + return this.request().body + }, + get bodyUsed() { + return this.request().bodyUsed + }, + get cache() { + return this.request().cache + }, + get credentials() { + return this.request().credentials + }, + get destination() { + return this.request().destination + }, + get headers() { + return this.request().headers + }, + get integrity() { + return this.request().integrity + }, + get mode() { + return this.request().mode + }, + get redirect() { + return this.request().redirect + }, + get referrer() { + return this.request().referrer + }, + get referrerPolicy() { + return this.request().referrerPolicy + }, + get signal() { + return this.request().signal + }, + arrayBuffer() { + return this.request().arrayBuffer() + }, + blob() { + return this.request().blob() + }, + clone() { + return this.request().clone() + }, + formData() { + return this.request().formData() + }, + json() { + return this.request().json() + }, + text() { + return this.request().text() + }, +} + +export const getRequestListener = (fetchCallback: FetchCallback) => { + return async ( + incoming: IncomingMessage | Http2ServerRequest, + outgoing: ServerResponse | Http2ServerResponse + ) => { + let res: CustomResponse + const req = { + method: incoming.method || 'GET', + url: `http://${incoming.headers.host}${incoming.url}`, + incoming, + } as unknown as Request + Object.setPrototypeOf(req, requestPrototype) try { - res = (await fetchCallback(new Request(url, init))) as Response + res = (await fetchCallback(req)) as CustomResponse } catch (e: unknown) { - res = new Response(null, { status: 500 }) + res = new CustomResponse(null, { status: 500 }) if (e instanceof Error) { // timeout error emits 504 timeout if (e.name === 'TimeoutError' || e.constructor.name === 'TimeoutError') { - res = new Response(null, { status: 504 }) + res = new CustomResponse(null, { status: 504 }) } } } + if (res.__cache) { + const [body, header] = res.__cache + header['content-length'] ||= '' + Buffer.byteLength(body) + outgoing.writeHead(res.status, header) + outgoing.end(body) + return + } + const resHeaderRecord: OutgoingHttpHeaders = {} const cookies = [] for (const [k, v] of res.headers) { diff --git a/test/server.test.ts b/test/server.test.ts index 6390661..227f3ae 100644 --- a/test/server.test.ts +++ b/test/server.test.ts @@ -15,6 +15,9 @@ describe('Basic', () => { app.get('/posts', (c) => { return c.text(`Page ${c.req.query('page')}`) }) + app.get('/user-agent', (c) => { + return c.text(c.req.header('user-agent') as string) + }) app.post('/posts', (c) => { return c.redirect('/posts') }) @@ -37,6 +40,13 @@ describe('Basic', () => { expect(res.text).toBe('Page 2') }) + it('Should return 200 response - GET /user-agent', async () => { + const res = await request(server).get('/user-agent').set('user-agent', 'Hono') + expect(res.status).toBe(200) + expect(res.headers['content-type']).toMatch(/text\/plain/) + expect(res.text).toBe('Hono') + }) + it('Should return 302 response - POST /posts', async () => { const res = await request(server).post('/posts') expect(res.status).toBe(302) From 5d8da3bf2a4fcfc682a9c5a4572e2ddf5ffdaf54 Mon Sep 17 00:00:00 2001 From: Taku Amano Date: Tue, 14 Nov 2023 21:28:25 +0900 Subject: [PATCH 02/23] perf: replace global.Response with a proxy object. --- src/listener.ts | 134 ++++++++++++++++++++++++++++++++++-------------- 1 file changed, 96 insertions(+), 38 deletions(-) diff --git a/src/listener.ts b/src/listener.ts index ba0faf9..c11e03e 100644 --- a/src/listener.ts +++ b/src/listener.ts @@ -8,22 +8,80 @@ import { writeFromReadableStream } from './utils' const regBuffer = /^no$/i const regContentType = /^(application\/json\b|text\/(?!event-stream\b))/i -class CustomResponse extends global.Response { - public __cache: [string, Record] | undefined - constructor(body: BodyInit | null, init?: ResponseInit) { - super(body, init) - if (typeof body === 'string' && !(init?.headers instanceof Headers)) { - this.__cache = [body, (init?.headers || {}) as Record] - } - } - get headers() { - // discard cache if headers are retrieved as they may change +const globalResponse = global.Response +const responsePrototype: Record = { + getResponseCache() { this.__cache = undefined - return super.headers - } + return (this.responseCache ||= new globalResponse(this.body, this.init)) + }, + get body() { + return this.getResponseCache().body + }, + get bodyUsed() { + return this.getResponseCache().bodyUsed + }, + get headers() { + return this.getResponseCache().headers + }, + get ok() { + return this.getResponseCache().ok + }, + get redirected() { + return this.getResponseCache().redirected + }, + get statusText() { + return this.getResponseCache().statusText + }, + get trailers() { + return this.getResponseCache().trailers + }, + get type() { + return this.getResponseCache().type + }, + get url() { + return this.getResponseCache().url + }, + arrayBuffer() { + return this.getResponseCache().arrayBuffer() + }, + blob() { + return this.getResponseCache().blob() + }, + clone() { + return this.getResponseCache().clone() + }, + error() { + return this.getResponseCache().error() + }, + formData() { + return this.getResponseCache().formData() + }, + json() { + return this.getResponseCache().json() + }, + redirect() { + return this.getResponseCache().redirect() + }, + text() { + return this.getResponseCache().text() + }, +} + +function newResponse(body: BodyInit | null, init?: ResponseInit): Response { + const res = { + body, + init, + status: init?.status || 200, + __cache: + typeof body === 'string' + ? [body, (init?.headers || {}) as Record] + : undefined, + } as unknown as Response + Object.setPrototypeOf(res, responsePrototype) + return res } Object.defineProperty(global, 'Response', { - value: CustomResponse, + value: newResponse, }) function newRequestFromIncoming( @@ -53,62 +111,62 @@ function newRequestFromIncoming( } const requestPrototype: Record = { - request() { + getRequestCache() { return (this.requestCache ||= newRequestFromIncoming(this.method, this.url, this.incoming)) }, get body() { - return this.request().body + return this.getRequestCache().body }, get bodyUsed() { - return this.request().bodyUsed + return this.getRequestCache().bodyUsed }, get cache() { - return this.request().cache + return this.getRequestCache().cache }, get credentials() { - return this.request().credentials + return this.getRequestCache().credentials }, get destination() { - return this.request().destination + return this.getRequestCache().destination }, get headers() { - return this.request().headers + return this.getRequestCache().headers }, get integrity() { - return this.request().integrity + return this.getRequestCache().integrity }, get mode() { - return this.request().mode + return this.getRequestCache().mode }, get redirect() { - return this.request().redirect + return this.getRequestCache().redirect }, get referrer() { - return this.request().referrer + return this.getRequestCache().referrer }, get referrerPolicy() { - return this.request().referrerPolicy + return this.getRequestCache().referrerPolicy }, get signal() { - return this.request().signal + return this.getRequestCache().signal }, arrayBuffer() { - return this.request().arrayBuffer() + return this.getRequestCache().arrayBuffer() }, blob() { - return this.request().blob() + return this.getRequestCache().blob() }, clone() { - return this.request().clone() + return this.getRequestCache().clone() }, formData() { - return this.request().formData() + return this.getRequestCache().formData() }, json() { - return this.request().json() + return this.getRequestCache().json() }, text() { - return this.request().text() + return this.getRequestCache().text() }, } @@ -117,7 +175,7 @@ export const getRequestListener = (fetchCallback: FetchCallback) => { incoming: IncomingMessage | Http2ServerRequest, outgoing: ServerResponse | Http2ServerResponse ) => { - let res: CustomResponse + let res const req = { method: incoming.method || 'GET', url: `http://${incoming.headers.host}${incoming.url}`, @@ -125,19 +183,19 @@ export const getRequestListener = (fetchCallback: FetchCallback) => { } as unknown as Request Object.setPrototypeOf(req, requestPrototype) try { - res = (await fetchCallback(req)) as CustomResponse + res = (await fetchCallback(req)) as Response } catch (e: unknown) { - res = new CustomResponse(null, { status: 500 }) + res = new Response(null, { status: 500 }) if (e instanceof Error) { // timeout error emits 504 timeout if (e.name === 'TimeoutError' || e.constructor.name === 'TimeoutError') { - res = new CustomResponse(null, { status: 504 }) + res = new Response(null, { status: 504 }) } } } - if (res.__cache) { - const [body, header] = res.__cache + if ((res as any).__cache) { + const [body, header] = (res as any).__cache header['content-length'] ||= '' + Buffer.byteLength(body) outgoing.writeHead(res.status, header) outgoing.end(body) From f0738ea085cfcae3ecfa0a1c3dc20457fdc7cd2e Mon Sep 17 00:00:00 2001 From: Taku Amano Date: Wed, 15 Nov 2023 04:57:35 +0900 Subject: [PATCH 03/23] refactor: Tweaks newResponse and responsePrototype to be more like the original Response class. --- src/listener.ts | 25 ++++++++++++------------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/src/listener.ts b/src/listener.ts index c11e03e..630060e 100644 --- a/src/listener.ts +++ b/src/listener.ts @@ -11,8 +11,8 @@ const regContentType = /^(application\/json\b|text\/(?!event-stream\b))/i const globalResponse = global.Response const responsePrototype: Record = { getResponseCache() { - this.__cache = undefined - return (this.responseCache ||= new globalResponse(this.body, this.init)) + delete this.__cache + return (this.responseCache ||= new globalResponse(this.__body, this.__init)) }, get body() { return this.getResponseCache().body @@ -67,19 +67,18 @@ const responsePrototype: Record = { }, } -function newResponse(body: BodyInit | null, init?: ResponseInit): Response { - const res = { - body, - init, +function newResponse(this: Response, body: BodyInit | null, init?: ResponseInit) { + Object.assign(this, { status: init?.status || 200, - __cache: - typeof body === 'string' - ? [body, (init?.headers || {}) as Record] - : undefined, - } as unknown as Response - Object.setPrototypeOf(res, responsePrototype) - return res + __body: body, + __init: init, + __cache: [body, (init?.headers || {}) as Record], + }) + if (typeof body !== 'string') { + delete (this as any).__cache + } } +newResponse.prototype = responsePrototype Object.defineProperty(global, 'Response', { value: newResponse, }) From dcfa9455194df2a9cc895cf57443eb67452bda77 Mon Sep 17 00:00:00 2001 From: Taku Amano Date: Wed, 15 Nov 2023 05:20:10 +0900 Subject: [PATCH 04/23] perf: Stop "async" and return results synchronously when possible. --- src/listener.ts | 159 ++++++++++++++++++++++++++---------------------- 1 file changed, 87 insertions(+), 72 deletions(-) diff --git a/src/listener.ts b/src/listener.ts index 630060e..50010fc 100644 --- a/src/listener.ts +++ b/src/listener.ts @@ -169,8 +169,83 @@ const requestPrototype: Record = { }, } +const handleError = (e: unknown): Response => + new Response(null, { + status: + e instanceof Error && (e.name === 'TimeoutError' || e.constructor.name === 'TimeoutError') + ? 504 // timeout error emits 504 timeout + : 500, + }) + +const responseViaResponseObject = async ( + res: Response | Promise, + outgoing: ServerResponse | Http2ServerResponse +) => { + try { + res = await res + } catch (e: unknown) { + res = handleError(e) + } + + const resHeaderRecord: OutgoingHttpHeaders = {} + const cookies = [] + for (const [k, v] of res.headers) { + if (k === 'set-cookie') { + cookies.push(v) + } else { + resHeaderRecord[k] = v + } + } + if (cookies.length > 0) { + resHeaderRecord['set-cookie'] = cookies + } + + if (res.body) { + try { + /** + * If content-encoding is set, we assume that the response should be not decoded. + * Else if transfer-encoding is set, we assume that the response should be streamed. + * Else if content-length is set, we assume that the response content has been taken care of. + * Else if x-accel-buffering is set to no, we assume that the response should be streamed. + * Else if content-type is not application/json nor text/* but can be text/event-stream, + * we assume that the response should be streamed. + */ + if ( + resHeaderRecord['transfer-encoding'] || + resHeaderRecord['content-encoding'] || + resHeaderRecord['content-length'] || + // nginx buffering variant + (resHeaderRecord['x-accel-buffering'] && + regBuffer.test(resHeaderRecord['x-accel-buffering'] as string)) || + !regContentType.test(resHeaderRecord['content-type'] as string) + ) { + outgoing.writeHead(res.status, resHeaderRecord) + await writeFromReadableStream(res.body, outgoing) + } else { + const buffer = await res.arrayBuffer() + resHeaderRecord['content-length'] = buffer.byteLength + outgoing.writeHead(res.status, resHeaderRecord) + outgoing.end(new Uint8Array(buffer)) + } + } catch (e: unknown) { + const err = (e instanceof Error ? e : new Error('unknown error', { cause: e })) as Error & { + code: string + } + if (err.code === 'ERR_STREAM_PREMATURE_CLOSE') { + console.info('The user aborted a request.') + } else { + console.error(e) + outgoing.destroy(err) + } + } + } else { + outgoing.writeHead(res.status, resHeaderRecord) + outgoing.end() + } +} + export const getRequestListener = (fetchCallback: FetchCallback) => { - return async ( + return ( incoming: IncomingMessage | Http2ServerRequest, outgoing: ServerResponse | Http2ServerResponse ) => { @@ -182,79 +257,19 @@ export const getRequestListener = (fetchCallback: FetchCallback) => { } as unknown as Request Object.setPrototypeOf(req, requestPrototype) try { - res = (await fetchCallback(req)) as Response - } catch (e: unknown) { - res = new Response(null, { status: 500 }) - if (e instanceof Error) { - // timeout error emits 504 timeout - if (e.name === 'TimeoutError' || e.constructor.name === 'TimeoutError') { - res = new Response(null, { status: 504 }) - } - } - } - - if ((res as any).__cache) { - const [body, header] = (res as any).__cache - header['content-length'] ||= '' + Buffer.byteLength(body) - outgoing.writeHead(res.status, header) - outgoing.end(body) - return - } - - const resHeaderRecord: OutgoingHttpHeaders = {} - const cookies = [] - for (const [k, v] of res.headers) { - if (k === 'set-cookie') { - cookies.push(v) - } else { - resHeaderRecord[k] = v + res = fetchCallback(req) as Response | Promise + if ("__cache" in res) { + // response via cache + const [body, header] = (res as any).__cache + header['content-length'] ||= '' + Buffer.byteLength(body) + outgoing.writeHead((res as Response).status, header) + outgoing.end(body) + return } - } - if (cookies.length > 0) { - resHeaderRecord['set-cookie'] = cookies + } catch (e: unknown) { + res = handleError(e) } - if (res.body) { - try { - /** - * If content-encoding is set, we assume that the response should be not decoded. - * Else if transfer-encoding is set, we assume that the response should be streamed. - * Else if content-length is set, we assume that the response content has been taken care of. - * Else if x-accel-buffering is set to no, we assume that the response should be streamed. - * Else if content-type is not application/json nor text/* but can be text/event-stream, - * we assume that the response should be streamed. - */ - if ( - resHeaderRecord['transfer-encoding'] || - resHeaderRecord['content-encoding'] || - resHeaderRecord['content-length'] || - // nginx buffering variant - (resHeaderRecord['x-accel-buffering'] && - regBuffer.test(resHeaderRecord['x-accel-buffering'] as string)) || - !regContentType.test(resHeaderRecord['content-type'] as string) - ) { - outgoing.writeHead(res.status, resHeaderRecord) - await writeFromReadableStream(res.body, outgoing) - } else { - const buffer = await res.arrayBuffer() - resHeaderRecord['content-length'] = buffer.byteLength - outgoing.writeHead(res.status, resHeaderRecord) - outgoing.end(new Uint8Array(buffer)) - } - } catch (e: unknown) { - const err = (e instanceof Error ? e : new Error('unknown error', { cause: e })) as Error & { - code: string - } - if (err.code === 'ERR_STREAM_PREMATURE_CLOSE') { - console.info('The user aborted a request.') - } else { - console.error(e) - outgoing.destroy(err) - } - } - } else { - outgoing.writeHead(res.status, resHeaderRecord) - outgoing.end() - } + return responseViaResponseObject(res, outgoing) } } From f339d76c36ef827bc26af1b6f00f7f40336ebf4e Mon Sep 17 00:00:00 2001 From: Taku Amano Date: Wed, 15 Nov 2023 21:17:35 +0900 Subject: [PATCH 05/23] refactor: Define properties of prototypes dynamically --- src/listener.ts | 156 ++++++++++++++++-------------------------------- 1 file changed, 51 insertions(+), 105 deletions(-) diff --git a/src/listener.ts b/src/listener.ts index 50010fc..f07f376 100644 --- a/src/listener.ts +++ b/src/listener.ts @@ -14,58 +14,31 @@ const responsePrototype: Record = { delete this.__cache return (this.responseCache ||= new globalResponse(this.__body, this.__init)) }, - get body() { - return this.getResponseCache().body - }, - get bodyUsed() { - return this.getResponseCache().bodyUsed - }, - get headers() { - return this.getResponseCache().headers - }, - get ok() { - return this.getResponseCache().ok - }, - get redirected() { - return this.getResponseCache().redirected - }, - get statusText() { - return this.getResponseCache().statusText - }, - get trailers() { - return this.getResponseCache().trailers - }, - get type() { - return this.getResponseCache().type - }, - get url() { - return this.getResponseCache().url - }, - arrayBuffer() { - return this.getResponseCache().arrayBuffer() - }, - blob() { - return this.getResponseCache().blob() - }, - clone() { - return this.getResponseCache().clone() - }, - error() { - return this.getResponseCache().error() - }, - formData() { - return this.getResponseCache().formData() - }, - json() { - return this.getResponseCache().json() - }, - redirect() { - return this.getResponseCache().redirect() - }, - text() { - return this.getResponseCache().text() - }, } +;[ + 'body', + 'bodyUsed', + 'headers', + 'ok', + 'redirected', + 'statusText', + 'trailers', + 'type', + 'url', +].forEach((k) => { + Object.defineProperty(responsePrototype, k, { + get() { + return this.getResponseCache()[k] + }, + }) +}) +;['arrayBuffer', 'blob', 'clone', 'error', 'formData', 'json', 'redirect', 'text'].forEach((k) => { + Object.defineProperty(responsePrototype, k, { + value: function () { + return this.getResponseCache()[k]() + }, + }) +}) function newResponse(this: Response, body: BodyInit | null, init?: ResponseInit) { Object.assign(this, { @@ -113,61 +86,34 @@ const requestPrototype: Record = { getRequestCache() { return (this.requestCache ||= newRequestFromIncoming(this.method, this.url, this.incoming)) }, - get body() { - return this.getRequestCache().body - }, - get bodyUsed() { - return this.getRequestCache().bodyUsed - }, - get cache() { - return this.getRequestCache().cache - }, - get credentials() { - return this.getRequestCache().credentials - }, - get destination() { - return this.getRequestCache().destination - }, - get headers() { - return this.getRequestCache().headers - }, - get integrity() { - return this.getRequestCache().integrity - }, - get mode() { - return this.getRequestCache().mode - }, - get redirect() { - return this.getRequestCache().redirect - }, - get referrer() { - return this.getRequestCache().referrer - }, - get referrerPolicy() { - return this.getRequestCache().referrerPolicy - }, - get signal() { - return this.getRequestCache().signal - }, - arrayBuffer() { - return this.getRequestCache().arrayBuffer() - }, - blob() { - return this.getRequestCache().blob() - }, - clone() { - return this.getRequestCache().clone() - }, - formData() { - return this.getRequestCache().formData() - }, - json() { - return this.getRequestCache().json() - }, - text() { - return this.getRequestCache().text() - }, } +;[ + 'body', + 'bodyUsed', + 'cache', + 'credentials', + 'destination', + 'headers', + 'integrity', + 'mode', + 'redirect', + 'referrer', + 'referrerPolicy', + 'signal', +].forEach((k) => { + Object.defineProperty(requestPrototype, k, { + get() { + return this.getRequestCache()[k] + }, + }) +}) +;['arrayBuffer', 'blob', 'clone', 'formData', 'json', 'text'].forEach((k) => { + Object.defineProperty(requestPrototype, k, { + value: function () { + return this.getRequestCache()[k]() + }, + }) +}) const handleError = (e: unknown): Response => new Response(null, { From ba4ece7f5541673698f5af8e918eb5bba729efc1 Mon Sep 17 00:00:00 2001 From: Taku Amano Date: Wed, 15 Nov 2023 21:17:51 +0900 Subject: [PATCH 06/23] perf: Set properties directly instead of using Object.assign --- src/listener.ts | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/listener.ts b/src/listener.ts index f07f376..c202275 100644 --- a/src/listener.ts +++ b/src/listener.ts @@ -41,12 +41,10 @@ const responsePrototype: Record = { }) function newResponse(this: Response, body: BodyInit | null, init?: ResponseInit) { - Object.assign(this, { - status: init?.status || 200, - __body: body, - __init: init, - __cache: [body, (init?.headers || {}) as Record], - }) + ;(this as any).status = init?.status || 200 + ;(this as any).__body = body + ;(this as any).__init = init + ;(this as any).__cache = [body, (init?.headers || {}) as Record] if (typeof body !== 'string') { delete (this as any).__cache } From d9502154069ba5c1ddf079d9ee301fb7125012c4 Mon Sep 17 00:00:00 2001 From: Taku Amano Date: Wed, 15 Nov 2023 21:18:37 +0900 Subject: [PATCH 07/23] perf: Use Object.create instead of Object.setPrototypeOf. In modern JavaScript, Object.setPrototypeOf is said to perform poorly. * https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/create * https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/setPrototypeOf --- src/listener.ts | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/src/listener.ts b/src/listener.ts index c202275..5e2c087 100644 --- a/src/listener.ts +++ b/src/listener.ts @@ -194,15 +194,13 @@ export const getRequestListener = (fetchCallback: FetchCallback) => { outgoing: ServerResponse | Http2ServerResponse ) => { let res - const req = { - method: incoming.method || 'GET', - url: `http://${incoming.headers.host}${incoming.url}`, - incoming, - } as unknown as Request - Object.setPrototypeOf(req, requestPrototype) + const req = Object.create(requestPrototype) + req.method = incoming.method || 'GET' + req.url = `http://${incoming.headers.host}${incoming.url}` + req.incoming = incoming try { res = fetchCallback(req) as Response | Promise - if ("__cache" in res) { + if ('__cache' in res) { // response via cache const [body, header] = (res as any).__cache header['content-length'] ||= '' + Buffer.byteLength(body) From 2b03ddf6106da12158776f1155086bce2887beec Mon Sep 17 00:00:00 2001 From: Taku Amano Date: Wed, 15 Nov 2023 21:54:30 +0900 Subject: [PATCH 08/23] perf: Response via cache also for a readable steam or a promise. --- src/listener.ts | 69 ++++++++++++++++++++++++++++++++----------------- 1 file changed, 45 insertions(+), 24 deletions(-) diff --git a/src/listener.ts b/src/listener.ts index 5e2c087..10a69ef 100644 --- a/src/listener.ts +++ b/src/listener.ts @@ -45,9 +45,6 @@ function newResponse(this: Response, body: BodyInit | null, init?: ResponseInit) ;(this as any).__body = body ;(this as any).__init = init ;(this as any).__cache = [body, (init?.headers || {}) as Record] - if (typeof body !== 'string') { - delete (this as any).__cache - } } newResponse.prototype = responsePrototype Object.defineProperty(global, 'Response', { @@ -113,7 +110,7 @@ const requestPrototype: Record = { }) }) -const handleError = (e: unknown): Response => +const handleFetchError = (e: unknown): Response => new Response(null, { status: e instanceof Error && (e.name === 'TimeoutError' || e.constructor.name === 'TimeoutError') @@ -121,14 +118,47 @@ const handleError = (e: unknown): Response => : 500, }) +const handleResponseError = (e: unknown, outgoing: ServerResponse | Http2ServerResponse) => { + const err = (e instanceof Error ? e : new Error('unknown error', { cause: e })) as Error & { + code: string + } + if (err.code === 'ERR_STREAM_PREMATURE_CLOSE') { + console.info('The user aborted a request.') + } else { + console.error(e) + outgoing.destroy(err) + } +} + +const responseViaCache = ( + res: Response, + outgoing: ServerResponse | Http2ServerResponse +): undefined | Promise => { + const [body, header] = (res as any).__cache + outgoing.writeHead((res as Response).status, header) + if (typeof body === 'string') { + header['content-length'] ||= '' + Buffer.byteLength(body) + outgoing.end(body) + } else { + return writeFromReadableStream(body, outgoing)?.catch( + (e) => handleResponseError(e, outgoing) as undefined + ) + } +} + const responseViaResponseObject = async ( res: Response | Promise, outgoing: ServerResponse | Http2ServerResponse ) => { - try { - res = await res - } catch (e: unknown) { - res = handleError(e) + if (res instanceof Promise) { + res = await res.catch(handleFetchError) + } + if ('__cache' in res) { + try { + return responseViaCache(res as Response, outgoing) + } catch (e: unknown) { + return handleResponseError(e, outgoing) + } } const resHeaderRecord: OutgoingHttpHeaders = {} @@ -172,15 +202,7 @@ const responseViaResponseObject = async ( outgoing.end(new Uint8Array(buffer)) } } catch (e: unknown) { - const err = (e instanceof Error ? e : new Error('unknown error', { cause: e })) as Error & { - code: string - } - if (err.code === 'ERR_STREAM_PREMATURE_CLOSE') { - console.info('The user aborted a request.') - } else { - console.error(e) - outgoing.destroy(err) - } + handleResponseError(e, outgoing) } } else { outgoing.writeHead(res.status, resHeaderRecord) @@ -201,15 +223,14 @@ export const getRequestListener = (fetchCallback: FetchCallback) => { try { res = fetchCallback(req) as Response | Promise if ('__cache' in res) { - // response via cache - const [body, header] = (res as any).__cache - header['content-length'] ||= '' + Buffer.byteLength(body) - outgoing.writeHead((res as Response).status, header) - outgoing.end(body) - return + return responseViaCache(res as Response, outgoing) } } catch (e: unknown) { - res = handleError(e) + if (!res) { + res = handleFetchError(e) + } else { + return handleResponseError(e, outgoing) + } } return responseViaResponseObject(res, outgoing) From 1e45efc48c92d7977de2b2120a0b8a099b8c0820 Mon Sep 17 00:00:00 2001 From: Taku Amano Date: Fri, 17 Nov 2023 21:33:59 +0900 Subject: [PATCH 09/23] refactor: Cache only if `bodyInit` is a string or a ReadableStream --- src/listener.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/listener.ts b/src/listener.ts index 10a69ef..fd185b6 100644 --- a/src/listener.ts +++ b/src/listener.ts @@ -44,7 +44,9 @@ function newResponse(this: Response, body: BodyInit | null, init?: ResponseInit) ;(this as any).status = init?.status || 200 ;(this as any).__body = body ;(this as any).__init = init - ;(this as any).__cache = [body, (init?.headers || {}) as Record] + if (typeof body === 'string' || body instanceof ReadableStream) { + ;(this as any).__cache = [body, (init?.headers || {}) as Record] + } } newResponse.prototype = responsePrototype Object.defineProperty(global, 'Response', { From 661a123dfd11ae193e1e8b9e998f435ee940250b Mon Sep 17 00:00:00 2001 From: Taku Amano Date: Sat, 18 Nov 2023 17:40:11 +0900 Subject: [PATCH 10/23] refactor: Cut out Request and Response object of listener.ts as request.ts and response.ts. --- src/listener.ts | 111 +++--------------------------------------------- src/request.ts | 64 ++++++++++++++++++++++++++++ src/response.ts | 46 ++++++++++++++++++++ 3 files changed, 116 insertions(+), 105 deletions(-) create mode 100644 src/request.ts create mode 100644 src/response.ts diff --git a/src/listener.ts b/src/listener.ts index fd185b6..01d10b3 100644 --- a/src/listener.ts +++ b/src/listener.ts @@ -1,117 +1,14 @@ import type { IncomingMessage, ServerResponse, OutgoingHttpHeaders } from 'node:http' import type { Http2ServerRequest, Http2ServerResponse } from 'node:http2' -import { Readable } from 'node:stream' import type { FetchCallback } from './types' import './globals' +import './response' +import { requestPrototype } from './request' import { writeFromReadableStream } from './utils' const regBuffer = /^no$/i const regContentType = /^(application\/json\b|text\/(?!event-stream\b))/i -const globalResponse = global.Response -const responsePrototype: Record = { - getResponseCache() { - delete this.__cache - return (this.responseCache ||= new globalResponse(this.__body, this.__init)) - }, -} -;[ - 'body', - 'bodyUsed', - 'headers', - 'ok', - 'redirected', - 'statusText', - 'trailers', - 'type', - 'url', -].forEach((k) => { - Object.defineProperty(responsePrototype, k, { - get() { - return this.getResponseCache()[k] - }, - }) -}) -;['arrayBuffer', 'blob', 'clone', 'error', 'formData', 'json', 'redirect', 'text'].forEach((k) => { - Object.defineProperty(responsePrototype, k, { - value: function () { - return this.getResponseCache()[k]() - }, - }) -}) - -function newResponse(this: Response, body: BodyInit | null, init?: ResponseInit) { - ;(this as any).status = init?.status || 200 - ;(this as any).__body = body - ;(this as any).__init = init - if (typeof body === 'string' || body instanceof ReadableStream) { - ;(this as any).__cache = [body, (init?.headers || {}) as Record] - } -} -newResponse.prototype = responsePrototype -Object.defineProperty(global, 'Response', { - value: newResponse, -}) - -function newRequestFromIncoming( - method: string, - url: string, - incoming: IncomingMessage | Http2ServerRequest -): Request { - const headerRecord: [string, string][] = [] - const len = incoming.rawHeaders.length - for (let i = 0; i < len; i += 2) { - headerRecord.push([incoming.rawHeaders[i], incoming.rawHeaders[i + 1]]) - } - - const init = { - method: method, - headers: headerRecord, - } as RequestInit - - if (!(method === 'GET' || method === 'HEAD')) { - // lazy-consume request body - init.body = Readable.toWeb(incoming) as ReadableStream - // node 18 fetch needs half duplex mode when request body is stream - ;(init as any).duplex = 'half' - } - - return new Request(url, init) -} - -const requestPrototype: Record = { - getRequestCache() { - return (this.requestCache ||= newRequestFromIncoming(this.method, this.url, this.incoming)) - }, -} -;[ - 'body', - 'bodyUsed', - 'cache', - 'credentials', - 'destination', - 'headers', - 'integrity', - 'mode', - 'redirect', - 'referrer', - 'referrerPolicy', - 'signal', -].forEach((k) => { - Object.defineProperty(requestPrototype, k, { - get() { - return this.getRequestCache()[k] - }, - }) -}) -;['arrayBuffer', 'blob', 'clone', 'formData', 'json', 'text'].forEach((k) => { - Object.defineProperty(requestPrototype, k, { - value: function () { - return this.getRequestCache()[k]() - }, - }) -}) - const handleFetchError = (e: unknown): Response => new Response(null, { status: @@ -218,10 +115,14 @@ export const getRequestListener = (fetchCallback: FetchCallback) => { outgoing: ServerResponse | Http2ServerResponse ) => { let res + + // `fetchCallback()` requests a Request object, but global.Request is expensive to generate, + // so generate a pseudo Request object with only the minimum required information. const req = Object.create(requestPrototype) req.method = incoming.method || 'GET' req.url = `http://${incoming.headers.host}${incoming.url}` req.incoming = incoming + try { res = fetchCallback(req) as Response | Promise if ('__cache' in res) { diff --git a/src/request.ts b/src/request.ts new file mode 100644 index 0000000..e8f7bc8 --- /dev/null +++ b/src/request.ts @@ -0,0 +1,64 @@ +// Define prototype for lightweight pseudo Request object + +import { Readable } from 'node:stream' +import type { IncomingMessage } from 'node:http' +import type { Http2ServerRequest } from 'node:http2' + +const newRequestFromIncoming = ( + method: string, + url: string, + incoming: IncomingMessage | Http2ServerRequest +): Request => { + const headerRecord: [string, string][] = [] + const len = incoming.rawHeaders.length + for (let i = 0; i < len; i += 2) { + headerRecord.push([incoming.rawHeaders[i], incoming.rawHeaders[i + 1]]) + } + + const init = { + method: method, + headers: headerRecord, + } as RequestInit + + if (!(method === 'GET' || method === 'HEAD')) { + // lazy-consume request body + init.body = Readable.toWeb(incoming) as ReadableStream + // node 18 fetch needs half duplex mode when request body is stream + ;(init as any).duplex = 'half' + } + + return new Request(url, init) +} + +export const requestPrototype: Record = { + getRequestCache() { + return (this.requestCache ||= newRequestFromIncoming(this.method, this.url, this.incoming)) + }, +} +;[ + 'body', + 'bodyUsed', + 'cache', + 'credentials', + 'destination', + 'headers', + 'integrity', + 'mode', + 'redirect', + 'referrer', + 'referrerPolicy', + 'signal', +].forEach((k) => { + Object.defineProperty(requestPrototype, k, { + get() { + return this.getRequestCache()[k] + }, + }) +}) +;['arrayBuffer', 'blob', 'clone', 'formData', 'json', 'text'].forEach((k) => { + Object.defineProperty(requestPrototype, k, { + value: function () { + return this.getRequestCache()[k]() + }, + }) +}) diff --git a/src/response.ts b/src/response.ts new file mode 100644 index 0000000..9121fb7 --- /dev/null +++ b/src/response.ts @@ -0,0 +1,46 @@ +// Define lightweight pseudo Response object and replace global.Response with it. + +const globalResponse = global.Response +const responsePrototype: Record = { + getResponseCache() { + delete this.__cache + return (this.responseCache ||= new globalResponse(this.__body, this.__init)) + }, +} +;[ + 'body', + 'bodyUsed', + 'headers', + 'ok', + 'redirected', + 'statusText', + 'trailers', + 'type', + 'url', +].forEach((k) => { + Object.defineProperty(responsePrototype, k, { + get() { + return this.getResponseCache()[k] + }, + }) +}) +;['arrayBuffer', 'blob', 'clone', 'error', 'formData', 'json', 'redirect', 'text'].forEach((k) => { + Object.defineProperty(responsePrototype, k, { + value: function () { + return this.getResponseCache()[k]() + }, + }) +}) + +function newResponse(this: Response, body: BodyInit | null, init?: ResponseInit) { + ;(this as any).status = init?.status || 200 + ;(this as any).__body = body + ;(this as any).__init = init + if (typeof body === 'string' || body instanceof ReadableStream) { + ;(this as any).__cache = [body, (init?.headers || {}) as Record] + } +} +newResponse.prototype = responsePrototype +Object.defineProperty(global, 'Response', { + value: newResponse, +}) From 3a2a7e1d06f0ed1da78e31c18d53613ae97ed702 Mon Sep 17 00:00:00 2001 From: Taku Amano Date: Sat, 18 Nov 2023 22:27:56 +0900 Subject: [PATCH 11/23] fix: Call outgoing.writeHead in the correct order --- src/listener.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/listener.ts b/src/listener.ts index 01d10b3..762e848 100644 --- a/src/listener.ts +++ b/src/listener.ts @@ -34,11 +34,12 @@ const responseViaCache = ( outgoing: ServerResponse | Http2ServerResponse ): undefined | Promise => { const [body, header] = (res as any).__cache - outgoing.writeHead((res as Response).status, header) if (typeof body === 'string') { header['content-length'] ||= '' + Buffer.byteLength(body) + outgoing.writeHead((res as Response).status, header) outgoing.end(body) } else { + outgoing.writeHead((res as Response).status, header) return writeFromReadableStream(body, outgoing)?.catch( (e) => handleResponseError(e, outgoing) as undefined ) From 2df0dd8d01ef4d8a13501a501efe785b9cdda112 Mon Sep 17 00:00:00 2001 From: Taku Amano Date: Sun, 19 Nov 2023 05:40:04 +0900 Subject: [PATCH 12/23] fix: Add default content-type if not specified --- src/response.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/response.ts b/src/response.ts index 9121fb7..4b51d06 100644 --- a/src/response.ts +++ b/src/response.ts @@ -37,7 +37,10 @@ function newResponse(this: Response, body: BodyInit | null, init?: ResponseInit) ;(this as any).__body = body ;(this as any).__init = init if (typeof body === 'string' || body instanceof ReadableStream) { - ;(this as any).__cache = [body, (init?.headers || {}) as Record] + ;(this as any).__cache = [ + body, + (init?.headers || { 'content-type': 'text/plain;charset=UTF-8' }) as Record, + ] } } newResponse.prototype = responsePrototype From b57644b229327a6ca8f1454dd79113f19300da21 Mon Sep 17 00:00:00 2001 From: Taku Amano Date: Sun, 19 Nov 2023 08:32:31 +0900 Subject: [PATCH 13/23] test: Fixed problem with global.Response being readonly in jest environment. --- jest.config.js | 2 ++ test/setup.ts | 4 ++++ 2 files changed, 6 insertions(+) create mode 100644 test/setup.ts diff --git a/jest.config.js b/jest.config.js index 01c5528..a53a447 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,7 +1,9 @@ module.exports = { testMatch: ['**/test/**/*.+(ts)', '**/src/**/(*.)+(test).+(ts)'], + modulePathIgnorePatterns: ["test/setup.ts"], transform: { '^.+\\.(ts)$': 'ts-jest', }, testEnvironment: 'node', + setupFiles: ["/test/setup.ts"], } diff --git a/test/setup.ts b/test/setup.ts new file mode 100644 index 0000000..b4c3fbd --- /dev/null +++ b/test/setup.ts @@ -0,0 +1,4 @@ +Object.defineProperty(global, 'Response', { + value: global.Response, + writable: true, +}) From 2bed5ba1f2e389c09790b1fdf7913b8f5cfb6c76 Mon Sep 17 00:00:00 2001 From: Taku Amano Date: Sun, 19 Nov 2023 08:35:11 +0900 Subject: [PATCH 14/23] feat: Enable Response cache even if responseInit has a Headers instance in headers. --- src/listener.ts | 15 ++------------- src/response.ts | 17 +++++++++++++---- src/utils.ts | 20 ++++++++++++++++++++ 3 files changed, 35 insertions(+), 17 deletions(-) diff --git a/src/listener.ts b/src/listener.ts index 762e848..f2f428f 100644 --- a/src/listener.ts +++ b/src/listener.ts @@ -4,7 +4,7 @@ import type { FetchCallback } from './types' import './globals' import './response' import { requestPrototype } from './request' -import { writeFromReadableStream } from './utils' +import { writeFromReadableStream, buildOutgoingHttpHeaders } from './utils' const regBuffer = /^no$/i const regContentType = /^(application\/json\b|text\/(?!event-stream\b))/i @@ -61,18 +61,7 @@ const responseViaResponseObject = async ( } } - const resHeaderRecord: OutgoingHttpHeaders = {} - const cookies = [] - for (const [k, v] of res.headers) { - if (k === 'set-cookie') { - cookies.push(v) - } else { - resHeaderRecord[k] = v - } - } - if (cookies.length > 0) { - resHeaderRecord['set-cookie'] = cookies - } + const resHeaderRecord: OutgoingHttpHeaders = buildOutgoingHttpHeaders(res.headers) if (res.body) { try { diff --git a/src/response.ts b/src/response.ts index 4b51d06..43a7de2 100644 --- a/src/response.ts +++ b/src/response.ts @@ -1,5 +1,8 @@ // Define lightweight pseudo Response object and replace global.Response with it. +import type { OutgoingHttpHeaders } from 'node:http' +import { buildOutgoingHttpHeaders } from './utils' + const globalResponse = global.Response const responsePrototype: Record = { getResponseCache() { @@ -36,11 +39,17 @@ function newResponse(this: Response, body: BodyInit | null, init?: ResponseInit) ;(this as any).status = init?.status || 200 ;(this as any).__body = body ;(this as any).__init = init + if (typeof body === 'string' || body instanceof ReadableStream) { - ;(this as any).__cache = [ - body, - (init?.headers || { 'content-type': 'text/plain;charset=UTF-8' }) as Record, - ] + let headers = (init?.headers || { 'content-type': 'text/plain;charset=UTF-8' }) as + | Record + | Headers + | OutgoingHttpHeaders + if (headers instanceof Headers) { + headers = buildOutgoingHttpHeaders(headers) + } + + ;(this as any).__cache = [body, headers] } } newResponse.prototype = responsePrototype diff --git a/src/utils.ts b/src/utils.ts index 60e7749..34ff376 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,4 +1,5 @@ import type { Writable } from 'node:stream' +import type { OutgoingHttpHeaders } from 'node:http' export function writeFromReadableStream(stream: ReadableStream, writable: Writable) { if (stream.locked) { @@ -37,3 +38,22 @@ export function writeFromReadableStream(stream: ReadableStream, writ } } } + +export const buildOutgoingHttpHeaders = (headers: Headers): OutgoingHttpHeaders => { + const res: OutgoingHttpHeaders = {} + + const cookies = [] + for (const [k, v] of headers) { + if (k === 'set-cookie') { + cookies.push(v) + } else { + res[k] = v + } + } + if (cookies.length > 0) { + res['set-cookie'] = cookies + } + res['content-type'] ??= 'text/plain;charset=UTF-8' + + return res +} From be5b07b8644eab2cd6d8489828f13eecadf41746 Mon Sep 17 00:00:00 2001 From: Taku Amano Date: Sun, 19 Nov 2023 08:37:38 +0900 Subject: [PATCH 15/23] test: /json-stream should be implemented in c.stream --- test/server.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/test/server.test.ts b/test/server.test.ts index 227f3ae..cb0048d 100644 --- a/test/server.test.ts +++ b/test/server.test.ts @@ -279,7 +279,10 @@ describe('Stream and non-stream response', () => { app.get('/text', (c) => c.text('Hello!')) app.get('/json-stream', (c) => { c.header('x-accel-buffering', 'no') - return c.json({ foo: 'bar' }) + c.header('content-type', 'application/json') + return c.stream(async (stream) => { + stream.write(JSON.stringify({ foo: 'bar' })) + }) }) app.get('/stream', (c) => { const stream = new ReadableStream({ From 08b3822b0e8734f1894d8f549fb31ea9eae61cb1 Mon Sep 17 00:00:00 2001 From: Taku Amano Date: Sun, 19 Nov 2023 09:05:56 +0900 Subject: [PATCH 16/23] refactor: add comment to explain synchronous response. --- src/listener.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/listener.ts b/src/listener.ts index f2f428f..3202639 100644 --- a/src/listener.ts +++ b/src/listener.ts @@ -116,6 +116,7 @@ export const getRequestListener = (fetchCallback: FetchCallback) => { try { res = fetchCallback(req) as Response | Promise if ('__cache' in res) { + // synchronous, cacheable response return responseViaCache(res as Response, outgoing) } } catch (e: unknown) { From 41ee7759c982e7c62e46c46886aac1f8c4975b8c Mon Sep 17 00:00:00 2001 From: Taku Amano Date: Sun, 19 Nov 2023 09:06:20 +0900 Subject: [PATCH 17/23] test: add tests. --- test/server.test.ts | 90 ++++++++++++++++++++++++++++++++++++--------- 1 file changed, 72 insertions(+), 18 deletions(-) diff --git a/test/server.test.ts b/test/server.test.ts index cb0048d..b0eacae 100644 --- a/test/server.test.ts +++ b/test/server.test.ts @@ -148,27 +148,81 @@ describe('Request body', () => { }) describe('Response body', () => { - const app = new Hono() - app.get('/json', (c) => { - return c.json({ foo: 'bar' }) - }) - app.get('/html', (c) => { - return c.html('

Hello!

') - }) - const server = createAdaptorServer(app) + describe('Cached Response', () => { + const app = new Hono() + app.get('/json', (c) => { + return c.json({ foo: 'bar' }) + }) + app.get('/json-async', async (c) => { + return c.json({ foo: 'async' }) + }) + app.get('/html', (c) => { + return c.html('

Hello!

') + }) + app.get('/html-async', async (c) => { + return c.html('

Hello!

') + }) + const server = createAdaptorServer(app) - it('Should return JSON body', async () => { - const res = await request(server).get('/json') - expect(res.status).toBe(200) - expect(res.headers['content-type']).toMatch(/application\/json/) - expect(JSON.parse(res.text)).toEqual({ foo: 'bar' }) + it('Should return JSON body', async () => { + const res = await request(server).get('/json') + expect(res.status).toBe(200) + expect(res.headers['content-type']).toMatch(/application\/json/) + expect(JSON.parse(res.text)).toEqual({ foo: 'bar' }) + }) + + it('Should return JSON body from /json-async', async () => { + const res = await request(server).get('/json-async') + expect(res.status).toBe(200) + expect(res.headers['content-type']).toMatch(/application\/json/) + expect(JSON.parse(res.text)).toEqual({ foo: 'async' }) + }) + + it('Should return HTML', async () => { + const res = await request(server).get('/html') + expect(res.status).toBe(200) + expect(res.headers['content-type']).toMatch(/text\/html/) + expect(res.text).toBe('

Hello!

') + }) + + it('Should return HTML from /html-async', async () => { + const res = await request(server).get('/html-async') + expect(res.status).toBe(200) + expect(res.headers['content-type']).toMatch(/text\/html/) + expect(res.text).toBe('

Hello!

') + }) }) - it('Should return HTML', async () => { - const res = await request(server).get('/html') - expect(res.status).toBe(200) - expect(res.headers['content-type']).toMatch(/text\/html/) - expect(res.text).toBe('

Hello!

') + describe('Fallback to global.Response', () => { + const app = new Hono() + + app.get('/json-blob', async () => { + return new Response(new Blob([JSON.stringify({ foo: 'blob' })]), { + headers: { 'content-type': 'application/json' }, + }) + }) + + app.get('/json-buffer', async () => { + return new Response(new TextEncoder().encode(JSON.stringify({ foo: 'buffer' })).buffer, { + headers: { 'content-type': 'application/json' }, + }) + }) + + const server = createAdaptorServer(app) + + it('Should return JSON body from /json-blob', async () => { + const res = await request(server).get('/json-blob') + expect(res.status).toBe(200) + expect(res.headers['content-type']).toMatch(/application\/json/) + expect(JSON.parse(res.text)).toEqual({ foo: 'blob' }) + }) + + it('Should return JSON body from /json-buffer', async () => { + const res = await request(server).get('/json-buffer') + expect(res.status).toBe(200) + expect(res.headers['content-type']).toMatch(/application\/json/) + expect(JSON.parse(res.text)).toEqual({ foo: 'buffer' }) + }) }) }) From 04162deeed2aa342450f0036fd709a546385d911 Mon Sep 17 00:00:00 2001 From: Taku Amano Date: Tue, 21 Nov 2023 17:17:35 +0900 Subject: [PATCH 18/23] refactor: Improve Compatibility with standard Response object --- src/listener.ts | 6 ++--- src/response.ts | 62 +++++++++++++++++++++++-------------------- test/response.test.ts | 58 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 94 insertions(+), 32 deletions(-) create mode 100644 test/response.test.ts diff --git a/src/listener.ts b/src/listener.ts index 3202639..10f6faf 100644 --- a/src/listener.ts +++ b/src/listener.ts @@ -33,13 +33,13 @@ const responseViaCache = ( res: Response, outgoing: ServerResponse | Http2ServerResponse ): undefined | Promise => { - const [body, header] = (res as any).__cache + const [status, body, header] = (res as any).__cache if (typeof body === 'string') { header['content-length'] ||= '' + Buffer.byteLength(body) - outgoing.writeHead((res as Response).status, header) + outgoing.writeHead(status, header) outgoing.end(body) } else { - outgoing.writeHead((res as Response).status, header) + outgoing.writeHead(status, header) return writeFromReadableStream(body, outgoing)?.catch( (e) => handleResponseError(e, outgoing) as undefined ) diff --git a/src/response.ts b/src/response.ts index 43a7de2..49d4788 100644 --- a/src/response.ts +++ b/src/response.ts @@ -1,14 +1,34 @@ -// Define lightweight pseudo Response object and replace global.Response with it. +// Define lightweight pseudo Response class and replace global.Response with it. import type { OutgoingHttpHeaders } from 'node:http' import { buildOutgoingHttpHeaders } from './utils' -const globalResponse = global.Response -const responsePrototype: Record = { +export const globalResponse = global.Response +class Response { getResponseCache() { - delete this.__cache - return (this.responseCache ||= new globalResponse(this.__body, this.__init)) - }, + delete (this as any).__cache + return ((this as any).responseCache ||= new globalResponse( + (this as any).__body, + (this as any).__init + )) + } + + constructor(body: BodyInit | null, init?: ResponseInit) { + ;(this as any).__body = body + ;(this as any).__init = init + + if (typeof body === 'string' || body instanceof ReadableStream) { + let headers = (init?.headers || { 'content-type': 'text/plain;charset=UTF-8' }) as + | Record + | Headers + | OutgoingHttpHeaders + if (headers instanceof Headers) { + headers = buildOutgoingHttpHeaders(headers) + } + + ;(this as any).__cache = [init?.status || 200, body, headers] + } + } } ;[ 'body', @@ -16,43 +36,27 @@ const responsePrototype: Record = { 'headers', 'ok', 'redirected', + 'status', 'statusText', 'trailers', 'type', 'url', ].forEach((k) => { - Object.defineProperty(responsePrototype, k, { + Object.defineProperty(Response.prototype, k, { get() { return this.getResponseCache()[k] }, }) }) -;['arrayBuffer', 'blob', 'clone', 'error', 'formData', 'json', 'redirect', 'text'].forEach((k) => { - Object.defineProperty(responsePrototype, k, { +;['arrayBuffer', 'blob', 'clone', 'formData', 'json', 'text'].forEach((k) => { + Object.defineProperty(Response.prototype, k, { value: function () { return this.getResponseCache()[k]() }, }) }) - -function newResponse(this: Response, body: BodyInit | null, init?: ResponseInit) { - ;(this as any).status = init?.status || 200 - ;(this as any).__body = body - ;(this as any).__init = init - - if (typeof body === 'string' || body instanceof ReadableStream) { - let headers = (init?.headers || { 'content-type': 'text/plain;charset=UTF-8' }) as - | Record - | Headers - | OutgoingHttpHeaders - if (headers instanceof Headers) { - headers = buildOutgoingHttpHeaders(headers) - } - - ;(this as any).__cache = [body, headers] - } -} -newResponse.prototype = responsePrototype +Object.setPrototypeOf(Response, globalResponse) +Object.setPrototypeOf(Response.prototype, globalResponse.prototype) Object.defineProperty(global, 'Response', { - value: newResponse, + value: Response, }) diff --git a/test/response.test.ts b/test/response.test.ts new file mode 100644 index 0000000..7259a84 --- /dev/null +++ b/test/response.test.ts @@ -0,0 +1,58 @@ +import { createServer, type Server } from 'node:http' +import { AddressInfo } from 'node:net' +import { globalResponse } from '../src/response' + +class NextResponse extends Response {} + +describe('Response', () => { + let server: Server + let port: number + beforeAll( + async () => + new Promise((resolve) => { + server = createServer((_, res) => { + res.writeHead(200, { + 'Content-Type': 'application/json charset=UTF-8', + }) + res.end(JSON.stringify({ status: 'ok' })) + }) + .listen(0) + .on('listening', () => { + port = (server.address() as AddressInfo).port + resolve() + }) + }) + ) + + afterAll(() => { + server.close() + }) + + it('Compatibility with standard Response object', async () => { + // response name not changed + expect(Response.name).toEqual('Response') + + // response prototype chain not changed + expect(new Response() instanceof globalResponse).toBe(true) + + // `fetch()` and `Response` are not changed + const fetchRes = await fetch(`http://localhost:${port}`) + expect(new Response() instanceof fetchRes.constructor).toBe(true) + const resJson = await fetchRes.json() + expect(fetchRes.headers.get('content-type')).toEqual('application/json charset=UTF-8') + expect(resJson).toEqual({ status: 'ok' }) + + // can only use new operator + expect(() => { + ;(Response as any)() + }).toThrow() + + // support Response static method + expect(Response.error).toEqual(expect.any(Function)) + expect(Response.json).toEqual(expect.any(Function)) + expect(Response.redirect).toEqual(expect.any(Function)) + + // support other class to extends from Response + expect(NextResponse.prototype).toBeInstanceOf(Response) + }) +}) From 2fae0621719ad2cf333ed11c9de1b930b9b681ac Mon Sep 17 00:00:00 2001 From: Taku Amano Date: Tue, 21 Nov 2023 17:28:11 +0900 Subject: [PATCH 19/23] chore: Bump typescript to 5.3.2 in order to avoid type error in jest Since `Response.json` is not defined in 4.8.3. --- package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index e651080..d1cf18d 100644 --- a/package.json +++ b/package.json @@ -71,6 +71,6 @@ "supertest": "^6.3.3", "ts-jest": "^29.1.1", "tsup": "^7.2.0", - "typescript": "^4.8.3" + "typescript": "^5.3.2" } } diff --git a/yarn.lock b/yarn.lock index 418c764..d6c2af6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4533,10 +4533,10 @@ typedarray-to-buffer@^3.1.5: dependencies: is-typedarray "^1.0.0" -typescript@^4.8.3: - version "4.8.3" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.8.3.tgz#d59344522c4bc464a65a730ac695007fdb66dd88" - integrity sha512-goMHfm00nWPa8UvR/CPSvykqf6dVV8x/dp0c5mFTMTIu0u0FlGWRioyy7Nn0PGAdHxpJZnuO/ut+PpQ8UiHAig== +typescript@^5.3.2: + version "5.3.2" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.3.2.tgz#00d1c7c1c46928c5845c1ee8d0cc2791031d4c43" + integrity sha512-6l+RyNy7oAHDfxC4FzSJcz9vnjTKxrLpDG5M2Vu4SHRVNg6xzqZp6LYSR9zjqQTu8DU/f5xwxUdADOkbrIX2gQ== unique-string@^2.0.0: version "2.0.0" From 7904b8fe971b887e456fe9cf627dc9643c27532b Mon Sep 17 00:00:00 2001 From: Taku Amano Date: Tue, 21 Nov 2023 17:37:04 +0900 Subject: [PATCH 20/23] test: Use `toBeInstanceOf` to clarify the intent of the test. --- test/response.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/response.test.ts b/test/response.test.ts index 7259a84..638deb2 100644 --- a/test/response.test.ts +++ b/test/response.test.ts @@ -33,11 +33,11 @@ describe('Response', () => { expect(Response.name).toEqual('Response') // response prototype chain not changed - expect(new Response() instanceof globalResponse).toBe(true) + expect(new Response()).toBeInstanceOf(globalResponse) // `fetch()` and `Response` are not changed const fetchRes = await fetch(`http://localhost:${port}`) - expect(new Response() instanceof fetchRes.constructor).toBe(true) + expect(new Response()).toBeInstanceOf(fetchRes.constructor) const resJson = await fetchRes.json() expect(fetchRes.headers.get('content-type')).toEqual('application/json charset=UTF-8') expect(resJson).toEqual({ status: 'ok' }) @@ -53,6 +53,6 @@ describe('Response', () => { expect(Response.redirect).toEqual(expect.any(Function)) // support other class to extends from Response - expect(NextResponse.prototype).toBeInstanceOf(Response) + expect(new NextResponse()).toBeInstanceOf(Response) }) }) From 5e28a91ae698a11004b65086ccd6f0c7c5f295ee Mon Sep 17 00:00:00 2001 From: Taku Amano Date: Tue, 21 Nov 2023 21:26:33 +0900 Subject: [PATCH 21/23] refactor: Override global Response via in globals.ts --- src/globals.ts | 6 ++++++ src/response.ts | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/globals.ts b/src/globals.ts index b974132..31cd8c7 100644 --- a/src/globals.ts +++ b/src/globals.ts @@ -1,4 +1,10 @@ import crypto from 'node:crypto' +import { Response } from './response' + +Object.defineProperty(global, 'Response', { + value: Response, +}) + const webFetch = global.fetch /** jest dose not use crypto in the global, but this is OK for node 18 */ diff --git a/src/response.ts b/src/response.ts index 49d4788..18f5049 100644 --- a/src/response.ts +++ b/src/response.ts @@ -4,7 +4,7 @@ import type { OutgoingHttpHeaders } from 'node:http' import { buildOutgoingHttpHeaders } from './utils' export const globalResponse = global.Response -class Response { +export class Response { getResponseCache() { delete (this as any).__cache return ((this as any).responseCache ||= new globalResponse( From e0c57d2fcce7c6ded721f88315e10f51272c4caf Mon Sep 17 00:00:00 2001 From: Taku Amano Date: Tue, 21 Nov 2023 21:40:55 +0900 Subject: [PATCH 22/23] refactor: Update visibility of internal property --- src/listener.ts | 8 ++++---- src/request.ts | 13 ++++++++----- src/response.ts | 28 ++++++++++++++++------------ 3 files changed, 28 insertions(+), 21 deletions(-) diff --git a/src/listener.ts b/src/listener.ts index 10f6faf..319cdf1 100644 --- a/src/listener.ts +++ b/src/listener.ts @@ -2,7 +2,7 @@ import type { IncomingMessage, ServerResponse, OutgoingHttpHeaders } from 'node: import type { Http2ServerRequest, Http2ServerResponse } from 'node:http2' import type { FetchCallback } from './types' import './globals' -import './response' +import { cacheKey } from './response' import { requestPrototype } from './request' import { writeFromReadableStream, buildOutgoingHttpHeaders } from './utils' @@ -33,7 +33,7 @@ const responseViaCache = ( res: Response, outgoing: ServerResponse | Http2ServerResponse ): undefined | Promise => { - const [status, body, header] = (res as any).__cache + const [status, body, header] = (res as any)[cacheKey] if (typeof body === 'string') { header['content-length'] ||= '' + Buffer.byteLength(body) outgoing.writeHead(status, header) @@ -53,7 +53,7 @@ const responseViaResponseObject = async ( if (res instanceof Promise) { res = await res.catch(handleFetchError) } - if ('__cache' in res) { + if (cacheKey in res) { try { return responseViaCache(res as Response, outgoing) } catch (e: unknown) { @@ -115,7 +115,7 @@ export const getRequestListener = (fetchCallback: FetchCallback) => { try { res = fetchCallback(req) as Response | Promise - if ('__cache' in res) { + if (cacheKey in res) { // synchronous, cacheable response return responseViaCache(res as Response, outgoing) } diff --git a/src/request.ts b/src/request.ts index e8f7bc8..be2fbf1 100644 --- a/src/request.ts +++ b/src/request.ts @@ -30,9 +30,12 @@ const newRequestFromIncoming = ( return new Request(url, init) } -export const requestPrototype: Record = { - getRequestCache() { - return (this.requestCache ||= newRequestFromIncoming(this.method, this.url, this.incoming)) +const getRequestCache = Symbol('getRequestCache') +const requestCache = Symbol('requestCache') + +export const requestPrototype: Record = { + [getRequestCache]() { + return (this[requestCache] ||= newRequestFromIncoming(this.method, this.url, this.incoming)) }, } ;[ @@ -51,14 +54,14 @@ export const requestPrototype: Record = { ].forEach((k) => { Object.defineProperty(requestPrototype, k, { get() { - return this.getRequestCache()[k] + return this[getRequestCache]()[k] }, }) }) ;['arrayBuffer', 'blob', 'clone', 'formData', 'json', 'text'].forEach((k) => { Object.defineProperty(requestPrototype, k, { value: function () { - return this.getRequestCache()[k]() + return this[getRequestCache]()[k]() }, }) }) diff --git a/src/response.ts b/src/response.ts index 18f5049..dcae54c 100644 --- a/src/response.ts +++ b/src/response.ts @@ -3,19 +3,23 @@ import type { OutgoingHttpHeaders } from 'node:http' import { buildOutgoingHttpHeaders } from './utils' +const responseCache = Symbol('responseCache') +export const cacheKey = Symbol('cache') + export const globalResponse = global.Response export class Response { - getResponseCache() { - delete (this as any).__cache - return ((this as any).responseCache ||= new globalResponse( - (this as any).__body, - (this as any).__init - )) + #body?: BodyInit | null + #init?: ResponseInit; + + // @ts-ignore + private get cache(): typeof globalResponse { + delete (this as any)[cacheKey] + return ((this as any)[responseCache] ||= new globalResponse(this.#body, this.#init)) } - constructor(body: BodyInit | null, init?: ResponseInit) { - ;(this as any).__body = body - ;(this as any).__init = init + constructor(body?: BodyInit | null, init?: ResponseInit) { + this.#body = body + this.#init = init if (typeof body === 'string' || body instanceof ReadableStream) { let headers = (init?.headers || { 'content-type': 'text/plain;charset=UTF-8' }) as @@ -26,7 +30,7 @@ export class Response { headers = buildOutgoingHttpHeaders(headers) } - ;(this as any).__cache = [init?.status || 200, body, headers] + (this as any)[cacheKey] = [init?.status || 200, body, headers] } } } @@ -44,14 +48,14 @@ export class Response { ].forEach((k) => { Object.defineProperty(Response.prototype, k, { get() { - return this.getResponseCache()[k] + return this.cache[k] }, }) }) ;['arrayBuffer', 'blob', 'clone', 'formData', 'json', 'text'].forEach((k) => { Object.defineProperty(Response.prototype, k, { value: function () { - return this.getResponseCache()[k]() + return this.cache[k]() }, }) }) From a9c3cd2357f906053a284559d6a82d287c8a6d67 Mon Sep 17 00:00:00 2001 From: Taku Amano Date: Wed, 22 Nov 2023 06:48:01 +0900 Subject: [PATCH 23/23] refactor: Improve compatibility with standard Request object --- src/listener.ts | 7 ++----- src/request.ts | 20 ++++++++++++++++++-- test/request.test.ts | 20 ++++++++++++++++++++ 3 files changed, 40 insertions(+), 7 deletions(-) create mode 100644 test/request.test.ts diff --git a/src/listener.ts b/src/listener.ts index 319cdf1..6a00a33 100644 --- a/src/listener.ts +++ b/src/listener.ts @@ -3,7 +3,7 @@ import type { Http2ServerRequest, Http2ServerResponse } from 'node:http2' import type { FetchCallback } from './types' import './globals' import { cacheKey } from './response' -import { requestPrototype } from './request' +import { newRequest } from './request' import { writeFromReadableStream, buildOutgoingHttpHeaders } from './utils' const regBuffer = /^no$/i @@ -108,10 +108,7 @@ export const getRequestListener = (fetchCallback: FetchCallback) => { // `fetchCallback()` requests a Request object, but global.Request is expensive to generate, // so generate a pseudo Request object with only the minimum required information. - const req = Object.create(requestPrototype) - req.method = incoming.method || 'GET' - req.url = `http://${incoming.headers.host}${incoming.url}` - req.incoming = incoming + const req = newRequest(incoming) try { res = fetchCallback(req) as Response | Promise diff --git a/src/request.ts b/src/request.ts index be2fbf1..d46f672 100644 --- a/src/request.ts +++ b/src/request.ts @@ -32,10 +32,19 @@ const newRequestFromIncoming = ( const getRequestCache = Symbol('getRequestCache') const requestCache = Symbol('requestCache') +const incomingKey = Symbol('incomingKey') + +const requestPrototype: Record = { + get method() { + return this[incomingKey].method || 'GET' + }, + + get url() { + return `http://${this[incomingKey].headers.host}${this[incomingKey].url}` + }, -export const requestPrototype: Record = { [getRequestCache]() { - return (this[requestCache] ||= newRequestFromIncoming(this.method, this.url, this.incoming)) + return (this[requestCache] ||= newRequestFromIncoming(this.method, this.url, this[incomingKey])) }, } ;[ @@ -65,3 +74,10 @@ export const requestPrototype: Record = { }, }) }) +Object.setPrototypeOf(requestPrototype, global.Request.prototype) + +export const newRequest = (incoming: IncomingMessage | Http2ServerRequest) => { + const req = Object.create(requestPrototype) + req[incomingKey] = incoming + return req +}; \ No newline at end of file diff --git a/test/request.test.ts b/test/request.test.ts new file mode 100644 index 0000000..aa75a7a --- /dev/null +++ b/test/request.test.ts @@ -0,0 +1,20 @@ +import { IncomingMessage } from 'node:http' +import { newRequest } from '../src/request' + +describe('Request', () => { + it('Compatibility with standard Request object', async () => { + const req = newRequest({ + method: 'GET', + url: '/', + headers: { + host: 'localhost', + }, + rawHeaders: ['host', 'localhost'], + } as IncomingMessage) + + expect(req).toBeInstanceOf(global.Request) + expect(req.method).toBe('GET') + expect(req.url).toBe('http://localhost/') + expect(req.headers.get('host')).toBe('localhost') + }) +})