Summary
This looks like a regression from #357. That PR added a materialization path that spreads this.#init:
new GlobalResponse(this.#body, {
...this.#init,
headers: liveHeaders,
})
This is safe when this.#init is a plain ResponseInit, but unsafe when it is a native Response. Native Response fields such as status and statusText are accessor properties, not own enumerable properties, so object spread drops them:
const native = new Response(null, { status: 302 })
console.log({ ...native }) // {}
As a result, materialization can effectively call new GlobalResponse(body, { headers }), which defaults to status 200.
With overrideGlobalObjects: true (the default), this can make @hono/node-server's lightweight Response lose its status during materialization. This is not specific to redirects: any non-default status from the native Response can be ignored and replaced with 200.
A response created from a native Response:
const lightweight = new Response(nativeResponse.body, nativeResponse)
initially reports 302, but after .body is read it can become 200.
Regression Range
Tested with Node.js 24.16.0 and hono@4.10.6:
@hono/node-server |
Result |
2.0.2 |
OK: 302 stays 302 |
2.0.3 |
Broken: 302 becomes 200 |
2.0.4 |
Broken: 302 becomes 200 |
Reproduction
const NativeResponse = globalThis.Response
const { serve } = require('@hono/node-server')
const server = serve(
{
fetch: () => new Response('ok'),
hostname: '127.0.0.1',
port: 0,
},
() => {}
)
server.close()
const nativeRedirect = new NativeResponse(null, {
status: 302,
statusText: '',
headers: {
Location: 'https://example.com/',
'Content-Length': '0',
},
})
const lightweightRedirect = new Response(nativeRedirect.body, nativeRedirect)
console.log('initial:', lightweightRedirect.status)
const rebuilt = new Response(lightweightRedirect.body, {
status: lightweightRedirect.status,
statusText: lightweightRedirect.statusText,
headers: lightweightRedirect.headers,
})
console.log('after body read:', lightweightRedirect.status)
console.log('rebuilt:', rebuilt.status)
Actual on 2.0.3 / 2.0.4:
initial: 302
after body read: 200
rebuilt: 200
Expected:
initial: 302
after body read: 302
rebuilt: 302
Suggested Fix
Do not spread a native Response as if it were a plain ResponseInit. Read metadata through accessors:
const init =
this.#init instanceof GlobalResponse
? {
status: this.#init.status,
statusText: this.#init.statusText,
headers: liveHeaders ?? this.#init.headers,
}
: liveHeaders
? { ...this.#init, headers: liveHeaders }
: this.#init
return new GlobalResponse(this.#body, init)
Related:
Summary
This looks like a regression from #357. That PR added a materialization path that spreads
this.#init:This is safe when
this.#initis a plainResponseInit, but unsafe when it is a nativeResponse. NativeResponsefields such asstatusandstatusTextare accessor properties, not own enumerable properties, so object spread drops them:As a result, materialization can effectively call
new GlobalResponse(body, { headers }), which defaults to status200.With
overrideGlobalObjects: true(the default), this can make@hono/node-server's lightweightResponselose its status during materialization. This is not specific to redirects: any non-default status from the nativeResponsecan be ignored and replaced with200.A response created from a native
Response:initially reports
302, but after.bodyis read it can become200.Regression Range
Tested with Node.js
24.16.0andhono@4.10.6:@hono/node-server2.0.2302stays3022.0.3302becomes2002.0.4302becomes200Reproduction
Actual on
2.0.3/2.0.4:Expected:
Suggested Fix
Do not spread a native
Responseas if it were a plainResponseInit. Read metadata through accessors:Related: