Skip to content

Commit 68df0e1

Browse files
authored
perf: Reduce object generation and optimize performance. (#95)
* perf: Reduce object generation and optimize performance. * perf: replace global.Response with a proxy object. * refactor: Tweaks newResponse and responsePrototype to be more like the original Response class. * perf: Stop "async" and return results synchronously when possible. * refactor: Define properties of prototypes dynamically * perf: Set properties directly instead of using Object.assign * 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 * perf: Response via cache also for a readable steam or a promise. * refactor: Cache only if `bodyInit` is a string or a ReadableStream * refactor: Cut out Request and Response object of listener.ts as request.ts and response.ts. * fix: Call outgoing.writeHead in the correct order * fix: Add default content-type if not specified * test: Fixed problem with global.Response being readonly in jest environment. * feat: Enable Response cache even if responseInit has a Headers instance in headers. * test: /json-stream should be implemented in c.stream * refactor: add comment to explain synchronous response. * test: add tests. * refactor: Improve Compatibility with standard Response object * 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. * test: Use `toBeInstanceOf` to clarify the intent of the test. * refactor: Override global Response via in globals.ts * refactor: Update visibility of internal property * refactor: Improve compatibility with standard Request object
1 parent 5b0c13b commit 68df0e1

File tree

12 files changed

+455
-107
lines changed

12 files changed

+455
-107
lines changed

jest.config.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
module.exports = {
22
testMatch: ['**/test/**/*.+(ts)', '**/src/**/(*.)+(test).+(ts)'],
3+
modulePathIgnorePatterns: ["test/setup.ts"],
34
transform: {
45
'^.+\\.(ts)$': 'ts-jest',
56
},
67
testEnvironment: 'node',
8+
setupFiles: ["<rootDir>/test/setup.ts"],
79
}

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,6 @@
7171
"supertest": "^6.3.3",
7272
"ts-jest": "^29.1.1",
7373
"tsup": "^7.2.0",
74-
"typescript": "^4.8.3"
74+
"typescript": "^5.3.2"
7575
}
7676
}

src/globals.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
11
import crypto from 'node:crypto'
2+
import { Response } from './response'
3+
4+
Object.defineProperty(global, 'Response', {
5+
value: Response,
6+
})
7+
28
const webFetch = global.fetch
39

410
/** jest dose not use crypto in the global, but this is OK for node 18 */

src/listener.ts

Lines changed: 105 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -1,107 +1,129 @@
11
import type { IncomingMessage, ServerResponse, OutgoingHttpHeaders } from 'node:http'
22
import type { Http2ServerRequest, Http2ServerResponse } from 'node:http2'
3-
import { Readable } from 'node:stream'
43
import type { FetchCallback } from './types'
54
import './globals'
6-
import { writeFromReadableStream } from './utils'
5+
import { cacheKey } from './response'
6+
import { newRequest } from './request'
7+
import { writeFromReadableStream, buildOutgoingHttpHeaders } from './utils'
78

89
const regBuffer = /^no$/i
910
const regContentType = /^(application\/json\b|text\/(?!event-stream\b))/i
1011

11-
export const getRequestListener = (fetchCallback: FetchCallback) => {
12-
return async (
13-
incoming: IncomingMessage | Http2ServerRequest,
14-
outgoing: ServerResponse | Http2ServerResponse
15-
) => {
16-
const method = incoming.method || 'GET'
17-
const url = `http://${incoming.headers.host}${incoming.url}`
12+
const handleFetchError = (e: unknown): Response =>
13+
new Response(null, {
14+
status:
15+
e instanceof Error && (e.name === 'TimeoutError' || e.constructor.name === 'TimeoutError')
16+
? 504 // timeout error emits 504 timeout
17+
: 500,
18+
})
1819

19-
const headerRecord: [string, string][] = []
20-
const len = incoming.rawHeaders.length
21-
for (let i = 0; i < len; i += 2) {
22-
headerRecord.push([incoming.rawHeaders[i], incoming.rawHeaders[i + 1]])
23-
}
20+
const handleResponseError = (e: unknown, outgoing: ServerResponse | Http2ServerResponse) => {
21+
const err = (e instanceof Error ? e : new Error('unknown error', { cause: e })) as Error & {
22+
code: string
23+
}
24+
if (err.code === 'ERR_STREAM_PREMATURE_CLOSE') {
25+
console.info('The user aborted a request.')
26+
} else {
27+
console.error(e)
28+
outgoing.destroy(err)
29+
}
30+
}
2431

25-
const init = {
26-
method: method,
27-
headers: headerRecord,
28-
} as RequestInit
32+
const responseViaCache = (
33+
res: Response,
34+
outgoing: ServerResponse | Http2ServerResponse
35+
): undefined | Promise<undefined> => {
36+
const [status, body, header] = (res as any)[cacheKey]
37+
if (typeof body === 'string') {
38+
header['content-length'] ||= '' + Buffer.byteLength(body)
39+
outgoing.writeHead(status, header)
40+
outgoing.end(body)
41+
} else {
42+
outgoing.writeHead(status, header)
43+
return writeFromReadableStream(body, outgoing)?.catch(
44+
(e) => handleResponseError(e, outgoing) as undefined
45+
)
46+
}
47+
}
2948

30-
if (!(method === 'GET' || method === 'HEAD')) {
31-
// lazy-consume request body
32-
init.body = Readable.toWeb(incoming) as ReadableStream<Uint8Array>
33-
// node 18 fetch needs half duplex mode when request body is stream
34-
;(init as any).duplex = 'half'
49+
const responseViaResponseObject = async (
50+
res: Response | Promise<Response>,
51+
outgoing: ServerResponse | Http2ServerResponse
52+
) => {
53+
if (res instanceof Promise) {
54+
res = await res.catch(handleFetchError)
55+
}
56+
if (cacheKey in res) {
57+
try {
58+
return responseViaCache(res as Response, outgoing)
59+
} catch (e: unknown) {
60+
return handleResponseError(e, outgoing)
3561
}
62+
}
3663

37-
let res: Response
64+
const resHeaderRecord: OutgoingHttpHeaders = buildOutgoingHttpHeaders(res.headers)
3865

66+
if (res.body) {
3967
try {
40-
res = (await fetchCallback(new Request(url, init))) as Response
41-
} catch (e: unknown) {
42-
res = new Response(null, { status: 500 })
43-
if (e instanceof Error) {
44-
// timeout error emits 504 timeout
45-
if (e.name === 'TimeoutError' || e.constructor.name === 'TimeoutError') {
46-
res = new Response(null, { status: 504 })
47-
}
68+
/**
69+
* If content-encoding is set, we assume that the response should be not decoded.
70+
* Else if transfer-encoding is set, we assume that the response should be streamed.
71+
* Else if content-length is set, we assume that the response content has been taken care of.
72+
* Else if x-accel-buffering is set to no, we assume that the response should be streamed.
73+
* Else if content-type is not application/json nor text/* but can be text/event-stream,
74+
* we assume that the response should be streamed.
75+
*/
76+
if (
77+
resHeaderRecord['transfer-encoding'] ||
78+
resHeaderRecord['content-encoding'] ||
79+
resHeaderRecord['content-length'] ||
80+
// nginx buffering variant
81+
(resHeaderRecord['x-accel-buffering'] &&
82+
regBuffer.test(resHeaderRecord['x-accel-buffering'] as string)) ||
83+
!regContentType.test(resHeaderRecord['content-type'] as string)
84+
) {
85+
outgoing.writeHead(res.status, resHeaderRecord)
86+
await writeFromReadableStream(res.body, outgoing)
87+
} else {
88+
const buffer = await res.arrayBuffer()
89+
resHeaderRecord['content-length'] = buffer.byteLength
90+
outgoing.writeHead(res.status, resHeaderRecord)
91+
outgoing.end(new Uint8Array(buffer))
4892
}
93+
} catch (e: unknown) {
94+
handleResponseError(e, outgoing)
4995
}
96+
} else {
97+
outgoing.writeHead(res.status, resHeaderRecord)
98+
outgoing.end()
99+
}
100+
}
101+
102+
export const getRequestListener = (fetchCallback: FetchCallback) => {
103+
return (
104+
incoming: IncomingMessage | Http2ServerRequest,
105+
outgoing: ServerResponse | Http2ServerResponse
106+
) => {
107+
let res
50108

51-
const resHeaderRecord: OutgoingHttpHeaders = {}
52-
const cookies = []
53-
for (const [k, v] of res.headers) {
54-
if (k === 'set-cookie') {
55-
cookies.push(v)
109+
// `fetchCallback()` requests a Request object, but global.Request is expensive to generate,
110+
// so generate a pseudo Request object with only the minimum required information.
111+
const req = newRequest(incoming)
112+
113+
try {
114+
res = fetchCallback(req) as Response | Promise<Response>
115+
if (cacheKey in res) {
116+
// synchronous, cacheable response
117+
return responseViaCache(res as Response, outgoing)
118+
}
119+
} catch (e: unknown) {
120+
if (!res) {
121+
res = handleFetchError(e)
56122
} else {
57-
resHeaderRecord[k] = v
123+
return handleResponseError(e, outgoing)
58124
}
59125
}
60-
if (cookies.length > 0) {
61-
resHeaderRecord['set-cookie'] = cookies
62-
}
63126

64-
if (res.body) {
65-
try {
66-
/**
67-
* If content-encoding is set, we assume that the response should be not decoded.
68-
* Else if transfer-encoding is set, we assume that the response should be streamed.
69-
* Else if content-length is set, we assume that the response content has been taken care of.
70-
* Else if x-accel-buffering is set to no, we assume that the response should be streamed.
71-
* Else if content-type is not application/json nor text/* but can be text/event-stream,
72-
* we assume that the response should be streamed.
73-
*/
74-
if (
75-
resHeaderRecord['transfer-encoding'] ||
76-
resHeaderRecord['content-encoding'] ||
77-
resHeaderRecord['content-length'] ||
78-
// nginx buffering variant
79-
(resHeaderRecord['x-accel-buffering'] &&
80-
regBuffer.test(resHeaderRecord['x-accel-buffering'] as string)) ||
81-
!regContentType.test(resHeaderRecord['content-type'] as string)
82-
) {
83-
outgoing.writeHead(res.status, resHeaderRecord)
84-
await writeFromReadableStream(res.body, outgoing)
85-
} else {
86-
const buffer = await res.arrayBuffer()
87-
resHeaderRecord['content-length'] = buffer.byteLength
88-
outgoing.writeHead(res.status, resHeaderRecord)
89-
outgoing.end(new Uint8Array(buffer))
90-
}
91-
} catch (e: unknown) {
92-
const err = (e instanceof Error ? e : new Error('unknown error', { cause: e })) as Error & {
93-
code: string
94-
}
95-
if (err.code === 'ERR_STREAM_PREMATURE_CLOSE') {
96-
console.info('The user aborted a request.')
97-
} else {
98-
console.error(e)
99-
outgoing.destroy(err)
100-
}
101-
}
102-
} else {
103-
outgoing.writeHead(res.status, resHeaderRecord)
104-
outgoing.end()
105-
}
127+
return responseViaResponseObject(res, outgoing)
106128
}
107129
}

src/request.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
// Define prototype for lightweight pseudo Request object
2+
3+
import { Readable } from 'node:stream'
4+
import type { IncomingMessage } from 'node:http'
5+
import type { Http2ServerRequest } from 'node:http2'
6+
7+
const newRequestFromIncoming = (
8+
method: string,
9+
url: string,
10+
incoming: IncomingMessage | Http2ServerRequest
11+
): Request => {
12+
const headerRecord: [string, string][] = []
13+
const len = incoming.rawHeaders.length
14+
for (let i = 0; i < len; i += 2) {
15+
headerRecord.push([incoming.rawHeaders[i], incoming.rawHeaders[i + 1]])
16+
}
17+
18+
const init = {
19+
method: method,
20+
headers: headerRecord,
21+
} as RequestInit
22+
23+
if (!(method === 'GET' || method === 'HEAD')) {
24+
// lazy-consume request body
25+
init.body = Readable.toWeb(incoming) as ReadableStream<Uint8Array>
26+
// node 18 fetch needs half duplex mode when request body is stream
27+
;(init as any).duplex = 'half'
28+
}
29+
30+
return new Request(url, init)
31+
}
32+
33+
const getRequestCache = Symbol('getRequestCache')
34+
const requestCache = Symbol('requestCache')
35+
const incomingKey = Symbol('incomingKey')
36+
37+
const requestPrototype: Record<string | symbol, any> = {
38+
get method() {
39+
return this[incomingKey].method || 'GET'
40+
},
41+
42+
get url() {
43+
return `http://${this[incomingKey].headers.host}${this[incomingKey].url}`
44+
},
45+
46+
[getRequestCache]() {
47+
return (this[requestCache] ||= newRequestFromIncoming(this.method, this.url, this[incomingKey]))
48+
},
49+
}
50+
;[
51+
'body',
52+
'bodyUsed',
53+
'cache',
54+
'credentials',
55+
'destination',
56+
'headers',
57+
'integrity',
58+
'mode',
59+
'redirect',
60+
'referrer',
61+
'referrerPolicy',
62+
'signal',
63+
].forEach((k) => {
64+
Object.defineProperty(requestPrototype, k, {
65+
get() {
66+
return this[getRequestCache]()[k]
67+
},
68+
})
69+
})
70+
;['arrayBuffer', 'blob', 'clone', 'formData', 'json', 'text'].forEach((k) => {
71+
Object.defineProperty(requestPrototype, k, {
72+
value: function () {
73+
return this[getRequestCache]()[k]()
74+
},
75+
})
76+
})
77+
Object.setPrototypeOf(requestPrototype, global.Request.prototype)
78+
79+
export const newRequest = (incoming: IncomingMessage | Http2ServerRequest) => {
80+
const req = Object.create(requestPrototype)
81+
req[incomingKey] = incoming
82+
return req
83+
};

src/response.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
// Define lightweight pseudo Response class and replace global.Response with it.
2+
3+
import type { OutgoingHttpHeaders } from 'node:http'
4+
import { buildOutgoingHttpHeaders } from './utils'
5+
6+
const responseCache = Symbol('responseCache')
7+
export const cacheKey = Symbol('cache')
8+
9+
export const globalResponse = global.Response
10+
export class Response {
11+
#body?: BodyInit | null
12+
#init?: ResponseInit;
13+
14+
// @ts-ignore
15+
private get cache(): typeof globalResponse {
16+
delete (this as any)[cacheKey]
17+
return ((this as any)[responseCache] ||= new globalResponse(this.#body, this.#init))
18+
}
19+
20+
constructor(body?: BodyInit | null, init?: ResponseInit) {
21+
this.#body = body
22+
this.#init = init
23+
24+
if (typeof body === 'string' || body instanceof ReadableStream) {
25+
let headers = (init?.headers || { 'content-type': 'text/plain;charset=UTF-8' }) as
26+
| Record<string, string>
27+
| Headers
28+
| OutgoingHttpHeaders
29+
if (headers instanceof Headers) {
30+
headers = buildOutgoingHttpHeaders(headers)
31+
}
32+
33+
(this as any)[cacheKey] = [init?.status || 200, body, headers]
34+
}
35+
}
36+
}
37+
;[
38+
'body',
39+
'bodyUsed',
40+
'headers',
41+
'ok',
42+
'redirected',
43+
'status',
44+
'statusText',
45+
'trailers',
46+
'type',
47+
'url',
48+
].forEach((k) => {
49+
Object.defineProperty(Response.prototype, k, {
50+
get() {
51+
return this.cache[k]
52+
},
53+
})
54+
})
55+
;['arrayBuffer', 'blob', 'clone', 'formData', 'json', 'text'].forEach((k) => {
56+
Object.defineProperty(Response.prototype, k, {
57+
value: function () {
58+
return this.cache[k]()
59+
},
60+
})
61+
})
62+
Object.setPrototypeOf(Response, globalResponse)
63+
Object.setPrototypeOf(Response.prototype, globalResponse.prototype)
64+
Object.defineProperty(global, 'Response', {
65+
value: Response,
66+
})

0 commit comments

Comments
 (0)