Skip to content

Commit a8edc96

Browse files
authored
perf: keep using the lightweight Response object when retrieving headers, status, and ok, and then drop the getInternalBody function. (#242)
* fix: tweaks interface of `getInternalBody()` * perf: keep using lightweight Response object when retrieving headers * refactor: organize internal cache data type. * perf: status and ok should also be completed in cache only, if possible, to improve performance * fix: clone headers to avoid sharing the same object between parent and child * feat: remove `getInternalBody` function * feat: support more response body types for caching * test: add test for fallback to GlobalResponse object * refactor: change to an appropriate name from LiteResponse to LightResponse
1 parent 1eb73c6 commit a8edc96

File tree

4 files changed

+87
-85
lines changed

4 files changed

+87
-85
lines changed

src/listener.ts

Lines changed: 19 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@ import {
66
Request as LightweightRequest,
77
toRequestError,
88
} from './request'
9-
import { cacheKey, getInternalBody, Response as LightweightResponse } from './response'
9+
import { cacheKey, Response as LightweightResponse } from './response'
10+
import type { InternalCache } from './response'
1011
import type { CustomErrorHandler, FetchCallback, HttpBindings } from './types'
1112
import { writeFromReadableStream, buildOutgoingHttpHeaders } from './utils'
1213
import { X_ALREADY_SENT } from './utils/response/constants'
@@ -44,18 +45,30 @@ const handleResponseError = (e: unknown, outgoing: ServerResponse | Http2ServerR
4445
}
4546
}
4647

47-
const responseViaCache = (
48+
const responseViaCache = async (
4849
res: Response,
4950
outgoing: ServerResponse | Http2ServerResponse
50-
): undefined | Promise<undefined | void> => {
51+
): Promise<undefined | void> => {
5152
// eslint-disable-next-line @typescript-eslint/no-explicit-any
52-
const [status, body, header] = (res as any)[cacheKey]
53+
let [status, body, header] = (res as any)[cacheKey] as InternalCache
54+
if (header instanceof Headers) {
55+
header = buildOutgoingHttpHeaders(header)
56+
}
57+
5358
if (typeof body === 'string') {
5459
header['Content-Length'] = Buffer.byteLength(body)
55-
outgoing.writeHead(status, header)
60+
} else if (body instanceof Uint8Array) {
61+
header['Content-Length'] = body.byteLength
62+
} else if (body instanceof Blob) {
63+
header['Content-Length'] = body.size
64+
}
65+
66+
outgoing.writeHead(status, header)
67+
if (typeof body === 'string' || body instanceof Uint8Array) {
5668
outgoing.end(body)
69+
} else if (body instanceof Blob) {
70+
outgoing.end(new Uint8Array(await body.arrayBuffer()))
5771
} else {
58-
outgoing.writeHead(status, header)
5972
return writeFromReadableStream(body, outgoing)?.catch(
6073
(e) => handleResponseError(e, outgoing) as undefined
6174
)
@@ -89,29 +102,6 @@ const responseViaResponseObject = async (
89102

90103
const resHeaderRecord: OutgoingHttpHeaders = buildOutgoingHttpHeaders(res.headers)
91104

92-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
93-
const internalBody = getInternalBody(res as any)
94-
if (internalBody) {
95-
const { length, source, stream } = internalBody
96-
if (source instanceof Uint8Array && source.byteLength !== length) {
97-
// maybe `source` is detached, so we should send via res.body
98-
} else {
99-
// send via internal raw data
100-
if (length) {
101-
resHeaderRecord['content-length'] = length
102-
}
103-
outgoing.writeHead(res.status, resHeaderRecord)
104-
if (typeof source === 'string' || source instanceof Uint8Array) {
105-
outgoing.end(source)
106-
} else if (source instanceof Blob) {
107-
outgoing.end(new Uint8Array(await source.arrayBuffer()))
108-
} else {
109-
await writeFromReadableStream(stream, outgoing)
110-
}
111-
return
112-
}
113-
}
114-
115105
if (res.body) {
116106
/**
117107
* If content-encoding is set, we assume that the response should be not decoded.

src/response.ts

Lines changed: 47 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -2,29 +2,33 @@
22
// Define lightweight pseudo Response class and replace global.Response with it.
33

44
import type { OutgoingHttpHeaders } from 'node:http'
5-
import { buildOutgoingHttpHeaders } from './utils'
6-
7-
interface InternalBody {
8-
source: string | Uint8Array | FormData | Blob | null
9-
stream: ReadableStream
10-
length: number | null
11-
}
125

136
const responseCache = Symbol('responseCache')
147
const getResponseCache = Symbol('getResponseCache')
158
export const cacheKey = Symbol('cache')
169

10+
export type InternalCache = [
11+
number,
12+
string | ReadableStream,
13+
Record<string, string> | Headers | OutgoingHttpHeaders,
14+
]
15+
interface LightResponse {
16+
[responseCache]?: globalThis.Response
17+
[cacheKey]?: InternalCache
18+
}
19+
1720
export const GlobalResponse = global.Response
1821
export class Response {
1922
#body?: BodyInit | null
2023
#init?: ResponseInit;
2124

22-
[getResponseCache](): typeof GlobalResponse {
23-
delete (this as any)[cacheKey]
24-
return ((this as any)[responseCache] ||= new GlobalResponse(this.#body, this.#init))
25+
[getResponseCache](): globalThis.Response {
26+
delete (this as LightResponse)[cacheKey]
27+
return ((this as LightResponse)[responseCache] ||= new GlobalResponse(this.#body, this.#init))
2528
}
2629

2730
constructor(body?: BodyInit | null, init?: ResponseInit) {
31+
let headers: HeadersInit
2832
this.#body = body
2933
if (init instanceof Response) {
3034
const cachedGlobalResponse = (init as any)[responseCache]
@@ -35,36 +39,48 @@ export class Response {
3539
return
3640
} else {
3741
this.#init = init.#init
42+
// clone headers to avoid sharing the same object between parent and child
43+
headers = new Headers((init.#init as ResponseInit).headers)
3844
}
3945
} else {
4046
this.#init = init
4147
}
4248

43-
if (typeof body === 'string' || typeof (body as ReadableStream)?.getReader !== 'undefined') {
44-
let headers = (init?.headers || { 'content-type': 'text/plain; charset=UTF-8' }) as
45-
| Record<string, string>
46-
| Headers
47-
| OutgoingHttpHeaders
48-
if (headers instanceof Headers) {
49-
headers = buildOutgoingHttpHeaders(headers)
50-
}
51-
49+
if (
50+
typeof body === 'string' ||
51+
typeof (body as ReadableStream)?.getReader !== 'undefined' ||
52+
body instanceof Blob ||
53+
body instanceof Uint8Array
54+
) {
55+
headers ||= init?.headers || { 'content-type': 'text/plain; charset=UTF-8' }
5256
;(this as any)[cacheKey] = [init?.status || 200, body, headers]
5357
}
5458
}
59+
60+
get headers(): Headers {
61+
const cache = (this as LightResponse)[cacheKey] as InternalCache
62+
if (cache) {
63+
if (!(cache[2] instanceof Headers)) {
64+
cache[2] = new Headers(cache[2] as HeadersInit)
65+
}
66+
return cache[2]
67+
}
68+
return this[getResponseCache]().headers
69+
}
70+
71+
get status() {
72+
return (
73+
((this as LightResponse)[cacheKey] as InternalCache | undefined)?.[0] ??
74+
this[getResponseCache]().status
75+
)
76+
}
77+
78+
get ok() {
79+
const status = this.status
80+
return status >= 200 && status < 300
81+
}
5582
}
56-
;[
57-
'body',
58-
'bodyUsed',
59-
'headers',
60-
'ok',
61-
'redirected',
62-
'status',
63-
'statusText',
64-
'trailers',
65-
'type',
66-
'url',
67-
].forEach((k) => {
83+
;['body', 'bodyUsed', 'redirected', 'statusText', 'trailers', 'type', 'url'].forEach((k) => {
6884
Object.defineProperty(Response.prototype, k, {
6985
get() {
7086
return this[getResponseCache]()[k]
@@ -80,26 +96,3 @@ export class Response {
8096
})
8197
Object.setPrototypeOf(Response, GlobalResponse)
8298
Object.setPrototypeOf(Response.prototype, GlobalResponse.prototype)
83-
84-
const stateKey = Reflect.ownKeys(new GlobalResponse()).find(
85-
(k) => typeof k === 'symbol' && k.toString() === 'Symbol(state)'
86-
) as symbol | undefined
87-
if (!stateKey) {
88-
console.warn('Failed to find Response internal state key')
89-
}
90-
91-
export function getInternalBody(
92-
response: Response | typeof GlobalResponse
93-
): InternalBody | undefined {
94-
if (!stateKey) {
95-
return
96-
}
97-
98-
if (response instanceof Response) {
99-
response = (response as any)[getResponseCache]()
100-
}
101-
102-
const state = (response as any)[stateKey] as { body?: InternalBody } | undefined
103-
104-
return (state && state.body) || undefined
105-
}

test/response.test.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { createServer } from 'node:http'
22
import type { Server } from 'node:http'
33
import type { AddressInfo } from 'node:net'
4-
import { GlobalResponse, Response as LightweightResponse } from '../src/response'
4+
import { GlobalResponse, Response as LightweightResponse, cacheKey } from '../src/response'
55

66
Object.defineProperty(global, 'Response', {
77
value: LightweightResponse,
@@ -99,4 +99,23 @@ describe('Response', () => {
9999
)
100100
expect(await childResponse.text()).toEqual('HONO')
101101
})
102+
103+
describe('Fallback to GlobalResponse object', () => {
104+
it('Should return value from internal cache', () => {
105+
const res = new Response('Hello! Node!')
106+
res.headers.set('x-test', 'test')
107+
expect(res.headers.get('x-test')).toEqual('test')
108+
expect(res.status).toEqual(200)
109+
expect(res.ok).toEqual(true)
110+
expect(cacheKey in res).toBe(true)
111+
})
112+
113+
it('Should return value from generated GlobalResponse object', () => {
114+
const res = new Response('Hello! Node!', {
115+
statusText: 'OK',
116+
})
117+
expect(res.statusText).toEqual('OK')
118+
expect(cacheKey in res).toBe(false)
119+
})
120+
})
102121
})

test/server.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ describe('Basic', () => {
107107
})
108108
})
109109

110-
describe('via internal body', () => {
110+
describe('various response body types', () => {
111111
const app = new Hono()
112112
app.use('*', async (c, next) => {
113113
await next()

0 commit comments

Comments
 (0)