-
Notifications
You must be signed in to change notification settings - Fork 49
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
perf: Reduce object generation and optimize performance. #95
Merged
Merged
Changes from 19 commits
Commits
Show all changes
25 commits
Select commit
Hold shift + click to select a range
443c3ab
perf: Reduce object generation and optimize performance.
usualoma 5d8da3b
perf: replace global.Response with a proxy object.
usualoma f0738ea
refactor: Tweaks newResponse and responsePrototype to be more like th…
usualoma dcfa945
perf: Stop "async" and return results synchronously when possible.
usualoma f339d76
refactor: Define properties of prototypes dynamically
usualoma ba4ece7
perf: Set properties directly instead of using Object.assign
usualoma d950215
perf: Use Object.create instead of Object.setPrototypeOf.
usualoma 2b03ddf
perf: Response via cache also for a readable steam or a promise.
usualoma 1e45efc
refactor: Cache only if `bodyInit` is a string or a ReadableStream
usualoma ed4192f
Merge pull request #2 from usualoma/perf/proxy-object-3
usualoma 2cef3d9
Merge pull request #1 from usualoma/perf/proxy-object-2
usualoma 661a123
refactor: Cut out Request and Response object of listener.ts as reque…
usualoma 3a2a7e1
fix: Call outgoing.writeHead in the correct order
usualoma 2df0dd8
fix: Add default content-type if not specified
usualoma b57644b
test: Fixed problem with global.Response being readonly in jest envir…
usualoma 2bed5ba
feat: Enable Response cache even if responseInit has a Headers instan…
usualoma be5b07b
test: /json-stream should be implemented in c.stream
usualoma 08b3822
refactor: add comment to explain synchronous response.
usualoma 41ee775
test: add tests.
usualoma 04162de
refactor: Improve Compatibility with standard Response object
usualoma 2fae062
chore: Bump typescript to 5.3.2 in order to avoid type error in jest
usualoma 7904b8f
test: Use `toBeInstanceOf` to clarify the intent of the test.
usualoma 5e28a91
refactor: Override global Response via in globals.ts
usualoma e0c57d2
refactor: Update visibility of internal property
usualoma a9c3cd2
refactor: Improve compatibility with standard Request object
usualoma File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,7 +1,9 @@ | ||
module.exports = { | ||
testMatch: ['**/test/**/*.+(ts)', '**/src/**/(*.)+(test).+(ts)'], | ||
modulePathIgnorePatterns: ["test/setup.ts"], | ||
transform: { | ||
'^.+\\.(ts)$': 'ts-jest', | ||
}, | ||
testEnvironment: 'node', | ||
setupFiles: ["<rootDir>/test/setup.ts"], | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,107 +1,132 @@ | ||
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 { writeFromReadableStream } from './utils' | ||
import './response' | ||
import { requestPrototype } from './request' | ||
import { writeFromReadableStream, buildOutgoingHttpHeaders } 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 handleFetchError = (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 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 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 init = { | ||
method: method, | ||
headers: headerRecord, | ||
} as RequestInit | ||
const responseViaCache = ( | ||
res: Response, | ||
outgoing: ServerResponse | Http2ServerResponse | ||
): undefined | Promise<undefined> => { | ||
const [body, header] = (res as any).__cache | ||
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 | ||
) | ||
} | ||
} | ||
|
||
if (!(method === 'GET' || method === 'HEAD')) { | ||
// lazy-consume request body | ||
init.body = Readable.toWeb(incoming) as ReadableStream<Uint8Array> | ||
// node 18 fetch needs half duplex mode when request body is stream | ||
;(init as any).duplex = 'half' | ||
const responseViaResponseObject = async ( | ||
res: Response | Promise<Response>, | ||
outgoing: ServerResponse | Http2ServerResponse | ||
) => { | ||
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) | ||
} | ||
} | ||
|
||
let res: Response | ||
const resHeaderRecord: OutgoingHttpHeaders = buildOutgoingHttpHeaders(res.headers) | ||
|
||
if (res.body) { | ||
try { | ||
res = (await fetchCallback(new Request(url, init))) 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 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) { | ||
handleResponseError(e, outgoing) | ||
} | ||
} else { | ||
outgoing.writeHead(res.status, resHeaderRecord) | ||
outgoing.end() | ||
} | ||
} | ||
|
||
export const getRequestListener = (fetchCallback: FetchCallback) => { | ||
return ( | ||
incoming: IncomingMessage | Http2ServerRequest, | ||
outgoing: ServerResponse | Http2ServerResponse | ||
) => { | ||
let res | ||
|
||
const resHeaderRecord: OutgoingHttpHeaders = {} | ||
const cookies = [] | ||
for (const [k, v] of res.headers) { | ||
if (k === 'set-cookie') { | ||
cookies.push(v) | ||
// `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<Response> | ||
if ('__cache' in res) { | ||
// synchronous, cacheable response | ||
return responseViaCache(res as Response, outgoing) | ||
} | ||
} catch (e: unknown) { | ||
if (!res) { | ||
res = handleFetchError(e) | ||
} else { | ||
resHeaderRecord[k] = v | ||
return handleResponseError(e, outgoing) | ||
} | ||
} | ||
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() | ||
} | ||
return responseViaResponseObject(res, outgoing) | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<Uint8Array> | ||
// 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<string, any> = { | ||
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]() | ||
}, | ||
}) | ||
}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,58 @@ | ||
// 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<string, any> = { | ||
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) { | ||
let headers = (init?.headers || { 'content-type': 'text/plain;charset=UTF-8' }) as | ||
| Record<string, string> | ||
| Headers | ||
| OutgoingHttpHeaders | ||
if (headers instanceof Headers) { | ||
headers = buildOutgoingHttpHeaders(headers) | ||
} | ||
|
||
;(this as any).__cache = [body, headers] | ||
} | ||
} | ||
newResponse.prototype = responsePrototype | ||
Object.defineProperty(global, 'Response', { | ||
value: newResponse, | ||
}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hono does not return a
Content-Type
header when returning a response with a metho likec.text('hi')
.We have to set
Content-Type
astext/plain;charset=UTF-8
if it's not set, at here for somewhere.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Add default content-type by 2df0dd8 and 2bed5ba
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks!