Skip to content
Merged
48 changes: 19 additions & 29 deletions src/listener.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ import {
Request as LightweightRequest,
toRequestError,
} from './request'
import { cacheKey, getInternalBody, Response as LightweightResponse } from './response'
import { cacheKey, Response as LightweightResponse } from './response'
import type { InternalCache } from './response'
import type { CustomErrorHandler, FetchCallback, HttpBindings } from './types'
import { writeFromReadableStream, buildOutgoingHttpHeaders } from './utils'
import { X_ALREADY_SENT } from './utils/response/constants'
Expand Down Expand Up @@ -44,18 +45,30 @@ const handleResponseError = (e: unknown, outgoing: ServerResponse | Http2ServerR
}
}

const responseViaCache = (
const responseViaCache = async (
res: Response,
outgoing: ServerResponse | Http2ServerResponse
): undefined | Promise<undefined | void> => {
): Promise<undefined | void> => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const [status, body, header] = (res as any)[cacheKey]
let [status, body, header] = (res as any)[cacheKey] as InternalCache
if (header instanceof Headers) {
header = buildOutgoingHttpHeaders(header)
}

if (typeof body === 'string') {
header['Content-Length'] = Buffer.byteLength(body)
outgoing.writeHead(status, header)
} else if (body instanceof Uint8Array) {
header['Content-Length'] = body.byteLength
} else if (body instanceof Blob) {
header['Content-Length'] = body.size
}

outgoing.writeHead(status, header)
if (typeof body === 'string' || body instanceof Uint8Array) {
outgoing.end(body)
} else if (body instanceof Blob) {
outgoing.end(new Uint8Array(await body.arrayBuffer()))
} else {
outgoing.writeHead(status, header)
return writeFromReadableStream(body, outgoing)?.catch(
(e) => handleResponseError(e, outgoing) as undefined
)
Expand Down Expand Up @@ -89,29 +102,6 @@ const responseViaResponseObject = async (

const resHeaderRecord: OutgoingHttpHeaders = buildOutgoingHttpHeaders(res.headers)

// eslint-disable-next-line @typescript-eslint/no-explicit-any
const internalBody = getInternalBody(res as any)
if (internalBody) {
const { length, source, stream } = internalBody
if (source instanceof Uint8Array && source.byteLength !== length) {
// maybe `source` is detached, so we should send via res.body
} else {
// send via internal raw data
if (length) {
resHeaderRecord['content-length'] = length
}
outgoing.writeHead(res.status, resHeaderRecord)
if (typeof source === 'string' || source instanceof Uint8Array) {
outgoing.end(source)
} else if (source instanceof Blob) {
outgoing.end(new Uint8Array(await source.arrayBuffer()))
} else {
await writeFromReadableStream(stream, outgoing)
}
return
}
}

if (res.body) {
/**
* If content-encoding is set, we assume that the response should be not decoded.
Expand Down
101 changes: 47 additions & 54 deletions src/response.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,29 +2,33 @@
// Define lightweight pseudo Response class and replace global.Response with it.

import type { OutgoingHttpHeaders } from 'node:http'
import { buildOutgoingHttpHeaders } from './utils'

interface InternalBody {
source: string | Uint8Array | FormData | Blob | null
stream: ReadableStream
length: number | null
}

const responseCache = Symbol('responseCache')
const getResponseCache = Symbol('getResponseCache')
export const cacheKey = Symbol('cache')

export type InternalCache = [
number,
string | ReadableStream,
Record<string, string> | Headers | OutgoingHttpHeaders,
]
interface LightResponse {
[responseCache]?: globalThis.Response
[cacheKey]?: InternalCache
}

export const GlobalResponse = global.Response
export class Response {
#body?: BodyInit | null
#init?: ResponseInit;

[getResponseCache](): typeof GlobalResponse {
delete (this as any)[cacheKey]
return ((this as any)[responseCache] ||= new GlobalResponse(this.#body, this.#init))
[getResponseCache](): globalThis.Response {
delete (this as LightResponse)[cacheKey]
return ((this as LightResponse)[responseCache] ||= new GlobalResponse(this.#body, this.#init))
}

constructor(body?: BodyInit | null, init?: ResponseInit) {
let headers: HeadersInit
this.#body = body
if (init instanceof Response) {
const cachedGlobalResponse = (init as any)[responseCache]
Expand All @@ -35,36 +39,48 @@ export class Response {
return
} else {
this.#init = init.#init
// clone headers to avoid sharing the same object between parent and child
headers = new Headers((init.#init as ResponseInit).headers)
}
} else {
this.#init = init
}

if (typeof body === 'string' || typeof (body as ReadableStream)?.getReader !== 'undefined') {
let headers = (init?.headers || { 'content-type': 'text/plain; charset=UTF-8' }) as
| Record<string, string>
| Headers
| OutgoingHttpHeaders
if (headers instanceof Headers) {
headers = buildOutgoingHttpHeaders(headers)
}

if (
typeof body === 'string' ||
typeof (body as ReadableStream)?.getReader !== 'undefined' ||
body instanceof Blob ||
body instanceof Uint8Array
) {
headers ||= init?.headers || { 'content-type': 'text/plain; charset=UTF-8' }
;(this as any)[cacheKey] = [init?.status || 200, body, headers]
}
}

get headers(): Headers {
const cache = (this as LightResponse)[cacheKey] as InternalCache
if (cache) {
if (!(cache[2] instanceof Headers)) {
cache[2] = new Headers(cache[2] as HeadersInit)
}
return cache[2]
}
return this[getResponseCache]().headers
}

get status() {
return (
((this as LightResponse)[cacheKey] as InternalCache | undefined)?.[0] ??
this[getResponseCache]().status
)
}

get ok() {
const status = this.status
return status >= 200 && status < 300
}
}
;[
'body',
'bodyUsed',
'headers',
'ok',
'redirected',
'status',
'statusText',
'trailers',
'type',
'url',
].forEach((k) => {
;['body', 'bodyUsed', 'redirected', 'statusText', 'trailers', 'type', 'url'].forEach((k) => {
Object.defineProperty(Response.prototype, k, {
get() {
return this[getResponseCache]()[k]
Expand All @@ -80,26 +96,3 @@ export class Response {
})
Object.setPrototypeOf(Response, GlobalResponse)
Object.setPrototypeOf(Response.prototype, GlobalResponse.prototype)

const stateKey = Reflect.ownKeys(new GlobalResponse()).find(
(k) => typeof k === 'symbol' && k.toString() === 'Symbol(state)'
) as symbol | undefined
if (!stateKey) {
console.warn('Failed to find Response internal state key')
}

export function getInternalBody(
response: Response | typeof GlobalResponse
): InternalBody | undefined {
if (!stateKey) {
return
}

if (response instanceof Response) {
response = (response as any)[getResponseCache]()
}

const state = (response as any)[stateKey] as { body?: InternalBody } | undefined

return (state && state.body) || undefined
}
21 changes: 20 additions & 1 deletion test/response.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { createServer } from 'node:http'
import type { Server } from 'node:http'
import type { AddressInfo } from 'node:net'
import { GlobalResponse, Response as LightweightResponse } from '../src/response'
import { GlobalResponse, Response as LightweightResponse, cacheKey } from '../src/response'

Object.defineProperty(global, 'Response', {
value: LightweightResponse,
Expand Down Expand Up @@ -99,4 +99,23 @@ describe('Response', () => {
)
expect(await childResponse.text()).toEqual('HONO')
})

describe('Fallback to GlobalResponse object', () => {
it('Should return value from internal cache', () => {
const res = new Response('Hello! Node!')
res.headers.set('x-test', 'test')
expect(res.headers.get('x-test')).toEqual('test')
expect(res.status).toEqual(200)
expect(res.ok).toEqual(true)
expect(cacheKey in res).toBe(true)
})

it('Should return value from generated GlobalResponse object', () => {
const res = new Response('Hello! Node!', {
statusText: 'OK',
})
expect(res.statusText).toEqual('OK')
expect(cacheKey in res).toBe(false)
})
})
})
2 changes: 1 addition & 1 deletion test/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ describe('Basic', () => {
})
})

describe('via internal body', () => {
describe('various response body types', () => {
const app = new Hono()
app.use('*', async (c, next) => {
await next()
Expand Down