Skip to content
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 25 commits into from
Nov 27, 2023
Merged
Show file tree
Hide file tree
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 Nov 13, 2023
5d8da3b
perf: replace global.Response with a proxy object.
usualoma Nov 14, 2023
f0738ea
refactor: Tweaks newResponse and responsePrototype to be more like th…
usualoma Nov 14, 2023
dcfa945
perf: Stop "async" and return results synchronously when possible.
usualoma Nov 14, 2023
f339d76
refactor: Define properties of prototypes dynamically
usualoma Nov 15, 2023
ba4ece7
perf: Set properties directly instead of using Object.assign
usualoma Nov 15, 2023
d950215
perf: Use Object.create instead of Object.setPrototypeOf.
usualoma Nov 15, 2023
2b03ddf
perf: Response via cache also for a readable steam or a promise.
usualoma Nov 15, 2023
1e45efc
refactor: Cache only if `bodyInit` is a string or a ReadableStream
usualoma Nov 17, 2023
ed4192f
Merge pull request #2 from usualoma/perf/proxy-object-3
usualoma Nov 18, 2023
2cef3d9
Merge pull request #1 from usualoma/perf/proxy-object-2
usualoma Nov 18, 2023
661a123
refactor: Cut out Request and Response object of listener.ts as reque…
usualoma Nov 18, 2023
3a2a7e1
fix: Call outgoing.writeHead in the correct order
usualoma Nov 18, 2023
2df0dd8
fix: Add default content-type if not specified
usualoma Nov 18, 2023
b57644b
test: Fixed problem with global.Response being readonly in jest envir…
usualoma Nov 18, 2023
2bed5ba
feat: Enable Response cache even if responseInit has a Headers instan…
usualoma Nov 18, 2023
be5b07b
test: /json-stream should be implemented in c.stream
usualoma Nov 18, 2023
08b3822
refactor: add comment to explain synchronous response.
usualoma Nov 19, 2023
41ee775
test: add tests.
usualoma Nov 19, 2023
04162de
refactor: Improve Compatibility with standard Response object
usualoma Nov 21, 2023
2fae062
chore: Bump typescript to 5.3.2 in order to avoid type error in jest
usualoma Nov 21, 2023
7904b8f
test: Use `toBeInstanceOf` to clarify the intent of the test.
usualoma Nov 21, 2023
5e28a91
refactor: Override global Response via in globals.ts
usualoma Nov 21, 2023
e0c57d2
refactor: Update visibility of internal property
usualoma Nov 21, 2023
a9c3cd2
refactor: Improve compatibility with standard Request object
usualoma Nov 21, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions jest.config.js
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"],
}
191 changes: 108 additions & 83 deletions src/listener.ts
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,
})
Copy link
Member

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 like c.text('hi').

We have to set Content-Type as text/plain;charset=UTF-8 if it's not set, at here for somewhere.

Copy link
Member Author

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

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks!


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)
}
}
64 changes: 64 additions & 0 deletions src/request.ts
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]()
},
})
})
58 changes: 58 additions & 0 deletions src/response.ts
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,
})
20 changes: 20 additions & 0 deletions src/utils.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { Writable } from 'node:stream'
import type { OutgoingHttpHeaders } from 'node:http'

export function writeFromReadableStream(stream: ReadableStream<Uint8Array>, writable: Writable) {
if (stream.locked) {
Expand Down Expand Up @@ -37,3 +38,22 @@ export function writeFromReadableStream(stream: ReadableStream<Uint8Array>, 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
}
Loading