From 9edc8f6432c67841f7e85db46d5842e0852c6b5a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 04:01:52 +0000 Subject: [PATCH 1/6] fix(node, fastify): answer http2 HEAD requests Node ends the writable side of an http2 HEAD stream before the request is handled, but its headers can still be sent. `canWriteToNodeResponse` took that ended stream as a finished response, so HEAD requests got no response at all (the fastify auto HEAD route fell back to a bare 200) and their abort signal started out aborted. - canWriteToNodeResponse: for http2, only require the stream to stay open and the response itself not to have ended - sendStandardResponse (node): send only the headers on an http2 HEAD stream, and release a stream body instead of piping it, which stalled without ever closing `res` - sendStandardResponse (fastify): hand fastify an empty stream in place of a stream body it would pipe into the ended stream and stall on - toAbortSignal: listen to the http2 stream's `close`, since Node skips the response's `close` for a HEAD stream closing before `res.end()` Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019TvbqaqZZZK5rU6p35jamj --- packages/fastify/src/response.test.ts | 100 ++++++++++++++++++++++++++ packages/fastify/src/response.ts | 14 +++- packages/node/src/response.test.ts | 94 ++++++++++++++++++++++++ packages/node/src/response.ts | 13 +++- packages/node/src/signal.test.ts | 77 ++++++++++++++++++++ packages/node/src/signal.ts | 6 +- packages/node/src/utils.test.ts | 34 +++++++++ packages/node/src/utils.ts | 7 +- 8 files changed, 338 insertions(+), 7 deletions(-) diff --git a/packages/fastify/src/response.test.ts b/packages/fastify/src/response.test.ts index 12b4e12f..2f4f922d 100644 --- a/packages/fastify/src/response.test.ts +++ b/packages/fastify/src/response.test.ts @@ -1,4 +1,5 @@ import { Buffer } from 'node:buffer' +import http2 from 'node:http2' import { Readable, Writable } from 'node:stream' import FastifyCookie from '@fastify/cookie' import * as StandardServerNode from '@standard-server/node' @@ -590,4 +591,103 @@ describe('sendStandardResponse', () => { expect(reply.send).not.toHaveBeenCalled() }) }) + + // Node ends the writable side of an http2 HEAD stream before the request is handled + describe('http2 HEAD request', () => { + it('sends the status and headers on an auto-exposed HEAD route', async ({ onTestFinished }) => { + let sending: Promise | undefined + + const fastify = Fastify({ http2: true }) + onTestFinished(() => fastify.close()) + + fastify.get('/', async (req, reply) => { + sending = sendStandardResponse(reply, { + status: 201, + headers: { 'x-custom-header': 'custom-value' }, + body: new Blob(['foo']), + }) + + await sending + }) + + const response = await requestHttp2Head(await fastify.listen({ port: 0, host: '127.0.0.1' })) + + await expect(sending).resolves.toBeUndefined() + + expect(response.headers).toMatchObject({ + ':status': 201, + 'content-length': '3', + 'x-custom-header': 'custom-value', + }) + expect(response.body).toBe('') + }) + + it('resolves and releases a stream body it cannot send on an explicit HEAD route', async ({ onTestFinished }) => { + let clean = false + const body = (async function* () { + try { + while (true) { + yield 'foo' + await new Promise(r => setTimeout(r, 10)) + } + } + finally { + clean = true + } + })() + + let sending: Promise | undefined + + const fastify = Fastify({ http2: true }) + onTestFinished(() => fastify.close()) + + fastify.head('/', async (req, reply) => { + sending = sendStandardResponse(reply, { + status: 201, + headers: { 'x-custom-header': 'custom-value' }, + body, + }) + + await sending + }) + + const response = await requestHttp2Head(await fastify.listen({ port: 0, host: '127.0.0.1' })) + + await expect(sending).resolves.toBeUndefined() + + expect(response.headers).toMatchObject({ + ':status': 201, + 'content-type': 'text/event-stream', + 'x-custom-header': 'custom-value', + }) + expect(response.body).toBe('') + + await vi.waitFor(() => { + expect(clean).toBe(true) + }) + }) + }) }) + +async function requestHttp2Head(origin: string): Promise<{ headers: http2.IncomingHttpHeaders, body: string }> { + const client = http2.connect(origin) + + try { + const reqStream = client.request({ ':path': '/', ':method': 'HEAD' }) + + const chunks: Buffer[] = [] + reqStream.on('data', (chunk: Buffer) => chunks.push(chunk)) + + const headers = await new Promise((resolve, reject) => { + reqStream.once('response', resolve) + reqStream.once('error', reject) + }) + + await new Promise(r => reqStream.once('close', r)) + + return { headers, body: Buffer.concat(chunks).toString() } + } + finally { + client.close() + } +} diff --git a/packages/fastify/src/response.ts b/packages/fastify/src/response.ts index 4b304530..5a55e2dd 100644 --- a/packages/fastify/src/response.ts +++ b/packages/fastify/src/response.ts @@ -1,6 +1,7 @@ import type { StandardResponse } from '@standard-server/core' import type { ToNodeHttpBodyOptions } from '@standard-server/node' import type { AnyFastifyReply } from './types' +import { Readable } from 'node:stream' import { canWriteToNodeResponse, getNodeResponseError, toNodeHttpBody } from '@standard-server/node' export interface SendStandardResponseOptions extends ToNodeHttpBodyOptions { @@ -46,8 +47,17 @@ export async function sendStandardResponse( } } - // fastify pipes and cleans up the stream body itself, no manual piping needed - reply.send(resBody) + // Node ends the writable side of an http2 HEAD stream up front, so a stream body can't be + // piped there (fastify stalls without ever closing `reply.raw`): release it and send an + // empty one, which both explicit and auto-exposed HEAD routes end cleanly with the headers + if (typeof resBody === 'object' && 'stream' in reply.raw && reply.raw.stream.writableEnded) { + resBody.destroy() + reply.send(Readable.from([])) + } + else { + // fastify pipes and cleans up the stream body itself, no manual piping needed + reply.send(resBody) + } } catch (error) { if (typeof resBody === 'object' && !resBody.closed) { diff --git a/packages/node/src/response.test.ts b/packages/node/src/response.test.ts index 2801b6eb..92250100 100644 --- a/packages/node/src/response.test.ts +++ b/packages/node/src/response.test.ts @@ -1,6 +1,9 @@ import type { StandardResponse } from '@standard-server/core' import type { IncomingMessage, ServerResponse } from 'node:http' +import type { Http2ServerRequest, Http2ServerResponse } from 'node:http2' +import type { AddressInfo } from 'node:net' import { Buffer } from 'node:buffer' +import http2 from 'node:http2' import Stream from 'node:stream' import request from 'supertest' import * as Body from './body' @@ -498,4 +501,95 @@ describe('sendStandardResponse', () => { expect((responseStream as any).setHeader).not.toHaveBeenCalled() }) }) + + // Node ends the writable side of an http2 HEAD stream before the request is handled + describe('http2 HEAD request', () => { + it('sends the status and headers without the body', async () => { + let res: Http2ServerResponse | undefined + let sending: Promise | undefined + + const response = await requestHttp2Head((_, _res) => { + res = _res + sending = sendStandardResponse(res, { + status: 201, + headers: { 'x-custom-header': 'custom-value' }, + body: { foo: 'bar' }, + }) + }) + + await expect(sending).resolves.toBeUndefined() + + expect(response.headers).toMatchObject({ + ':status': 201, + 'content-type': 'application/json', + 'x-custom-header': 'custom-value', + }) + expect(response.body).toBe('') + + // the body was never written to the ended stream + expect(res!.stream.errored).toBe(null) + }) + + it('resolves and releases a stream body it cannot send', async () => { + let clean = false + const body = (async function* () { + try { + while (true) { + yield 'foo' + await new Promise(r => setTimeout(r, 10)) + } + } + finally { + clean = true + } + })() + + let sending: Promise | undefined + + const response = await requestHttp2Head((_, res) => { + sending = sendStandardResponse(res, { status: 201, headers: {}, body }) + }) + + await expect(sending).resolves.toBeUndefined() + + expect(response.headers).toMatchObject({ + ':status': 201, + 'content-type': 'text/event-stream', + }) + expect(response.body).toBe('') + + await vi.waitFor(() => { + expect(clean).toBe(true) + }) + }) + }) }) + +async function requestHttp2Head( + listener: (req: Http2ServerRequest, res: Http2ServerResponse) => void, +): Promise<{ headers: http2.IncomingHttpHeaders, body: string }> { + const server = http2.createServer(listener) + await new Promise(r => server.listen(0, r)) + + const client = http2.connect(`http://localhost:${(server.address() as AddressInfo).port}`) + + try { + const reqStream = client.request({ ':path': '/', ':method': 'HEAD' }) + + const chunks: Buffer[] = [] + reqStream.on('data', (chunk: Buffer) => chunks.push(chunk)) + + const headers = await new Promise((resolve, reject) => { + reqStream.once('response', resolve) + reqStream.once('error', reject) + }) + + await new Promise(r => reqStream.once('close', r)) + + return { headers, body: Buffer.concat(chunks).toString() } + } + finally { + client.close() + await new Promise(r => server.close(r)) + } +} diff --git a/packages/node/src/response.ts b/packages/node/src/response.ts index 376add7d..5fd385c3 100644 --- a/packages/node/src/response.ts +++ b/packages/node/src/response.ts @@ -47,7 +47,11 @@ export async function sendStandardResponse( } } - if (resBody === undefined) { + // Node ends the writable side of an http2 HEAD stream up front, so its body can't be written: + // a write fails with ERR_STREAM_WRITE_AFTER_END, and a pipe stalls without ever closing `res` + const isHeadersOnly = 'stream' in res && res.stream.writableEnded + + if (resBody === undefined || (typeof resBody === 'string' && isHeadersOnly)) { // NOTE: Lambda functions don't allow passing undefined to `res.end` res.end() } @@ -64,7 +68,12 @@ export async function sendStandardResponse( // WARNING: errors that occur here are silently ignored and not reported to the Promise resBody.once('error', error => res.destroy(error)) - resBody.pipe(res) + if (isHeadersOnly) { + res.end() // the `close` listener above releases the unsent body + } + else { + resBody.pipe(res) + } } } catch (error) { diff --git a/packages/node/src/signal.test.ts b/packages/node/src/signal.test.ts index a052f1b2..5ac19b7a 100644 --- a/packages/node/src/signal.test.ts +++ b/packages/node/src/signal.test.ts @@ -171,4 +171,81 @@ describe('toAbortSignal', async () => { await handled }) + + describe('on http2 HEAD response, whose stream Node already ended', () => { + it('without abort', async ({ onTestFinished }) => { + const server = http2.createServer() + onTestFinished(() => new Promise(r => server.close(r))) + + const handled = new Promise((resolve, reject) => { + server.on('request', async (req, res) => { + try { + const signal = toAbortSignal(res) + + expect(signal.aborted).toBe(false) + + res.end() + await new Promise(r => res.stream.once('close', () => r())) + + expect(signal.aborted).toBe(false) + + resolve() + } + catch (error) { + reject(error) + } + }) + }) + + await new Promise(r => server.listen(0, r)) + const port = (server.address() as any).port + + const client = http2.connect(`http://localhost:${port}`) + const reqStream = client.request({ ':path': '/', ':method': 'HEAD' }) + reqStream.resume() + reqStream.once('close', () => client.close()) + + await handled + }) + + it('aborted by client before the response ends', async ({ onTestFinished }) => { + const server = http2.createServer() + onTestFinished(() => new Promise(r => server.close(r))) + + const handled = new Promise((resolve, reject) => { + server.on('request', async (req, res) => { + try { + const signal = toAbortSignal(res) + + expect(signal.aborted).toBe(false) + + // Node emits no `close` on an http2 HEAD response that never ended + await new Promise(r => res.stream.once('close', () => r())) + + expect(signal.aborted).toBe(true) + expect(signal.reason).toEqual(new AbortError('Writable stream closed before it finished writing')) + + resolve() + } + catch (error) { + reject(error) + } + }) + }) + + await new Promise(r => server.listen(0, r)) + const port = (server.address() as any).port + + const client = http2.connect(`http://localhost:${port}`) + const reqStream = client.request({ ':path': '/', ':method': 'HEAD' }) + reqStream.once('error', () => {}) + + setTimeout(() => { + reqStream.close(http2.constants.NGHTTP2_CANCEL) + client.close() + }, 50) + + await handled + }) + }) }) diff --git a/packages/node/src/signal.ts b/packages/node/src/signal.ts index 852d7e19..891a034e 100644 --- a/packages/node/src/signal.ts +++ b/packages/node/src/signal.ts @@ -19,7 +19,11 @@ export function toAbortSignal(stream: Stream.Writable | NodeHttpResponse): Abort else { stream.once('error', error => controller.abort(error)) - stream.once('close', () => { + // Node skips the http2 response's `close` when a HEAD stream closes before `res.end()`, + // so listen to the underlying stream instead + const closable = 'stream' in stream ? stream.stream : stream + + closable.once('close', () => { if (!stream.writableFinished || !stream.writableEnded) { controller.abort(new AbortError('Writable stream closed before it finished writing')) } diff --git a/packages/node/src/utils.test.ts b/packages/node/src/utils.test.ts index a8362410..b64716fb 100644 --- a/packages/node/src/utils.test.ts +++ b/packages/node/src/utils.test.ts @@ -178,6 +178,40 @@ describe('canWriteToNodeResponse', () => { await handled }) + + it('on http2 HEAD response, whose stream Node already ended', async ({ onTestFinished }) => { + const server = http2.createServer() + onTestFinished(() => new Promise(r => server.close(r))) + + const handled = new Promise((resolve, reject) => { + server.on('request', async (req, res) => { + try { + expect(res.stream.writableEnded).toBe(true) + expect(canWriteToNodeResponse(res)).toBe(true) + + res.end() + + expect(canWriteToNodeResponse(res)).toBe(false) + + resolve() + } + catch (error) { + res.end() + reject(error) + } + }) + }) + + await new Promise(r => server.listen(0, r)) + const port = (server.address() as any).port + + const client = http2.connect(`http://localhost:${port}`) + const reqStream = client.request({ ':path': '/', ':method': 'HEAD' }) + reqStream.resume() + reqStream.once('close', () => client.close()) + + await handled + }) }) describe('getNodeResponseError', () => { diff --git a/packages/node/src/utils.ts b/packages/node/src/utils.ts index 66def831..fdc67183 100644 --- a/packages/node/src/utils.ts +++ b/packages/node/src/utils.ts @@ -74,8 +74,11 @@ export function canWriteToNodeResponse(res: Stream.Writable | NodeHttpResponse): return false } - if ('stream' in res && !_canWriteToStream(res.stream)) { - return false + if ('stream' in res) { + // Node ends the writable side of an http2 HEAD stream up front (`res.writableFinished` + // mirrors it), yet its headers can still be sent, so only the stream staying open + // and the response itself not ending matter here. + return !res.stream.closed && !res.stream.destroyed && !res.writableEnded } return _canWriteToStream(res) From 61df0627b74ae944958986651b5b8b47caf53072 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 14:20:00 +0000 Subject: [PATCH 2/6] refactor(node): read fewer response flags to detect a closed response A response can still be sent while its headers aren't sent, it hasn't ended, and its connection is open. `destroyed` alone tells the last one (Node sets it on http1 responses and http2 streams when the client goes away), and `writableEnded` covers `writableFinished`, which on http2 mirrors the stream Node ends up front for HEAD requests. toAbortSignal now follows one rule: abort when the connection closes before the response ends. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019TvbqaqZZZK5rU6p35jamj --- packages/node/src/signal.ts | 38 ++++++++++++++++++------------------- packages/node/src/utils.ts | 21 +++++++------------- 2 files changed, 25 insertions(+), 34 deletions(-) diff --git a/packages/node/src/signal.ts b/packages/node/src/signal.ts index 891a034e..a98699c5 100644 --- a/packages/node/src/signal.ts +++ b/packages/node/src/signal.ts @@ -1,33 +1,31 @@ import type Stream from 'node:stream' import type { NodeHttpResponse } from './types' import { AbortError } from '@standard-server/shared' -import { canWriteToNodeResponse, getNodeResponseError } from './utils' +import { getNodeResponseError } from './utils' -export function toAbortSignal(stream: Stream.Writable | NodeHttpResponse): AbortSignal { +/** + * Abort when the response closes before it ends. + */ +export function toAbortSignal(res: Stream.Writable | NodeHttpResponse): AbortSignal { const controller = new AbortController() - const error = getNodeResponseError(stream) + // On http2 the connection closes on the underlying stream: Node skips the response's own + // `close` when a HEAD stream closes before `res.end()` + const stream = 'stream' in res ? res.stream : res - if (error) { - controller.abort(error) - } - else if (!canWriteToNodeResponse(stream)) { - if (!stream.writableFinished || !stream.writableEnded) { - controller.abort(new AbortError('Writable stream closed before it finished writing')) + const onClose = () => { + if (!res.writableEnded) { + controller.abort(getNodeResponseError(res) ?? new AbortError('Writable stream closed before it finished writing')) } } - else { - stream.once('error', error => controller.abort(error)) - - // Node skips the http2 response's `close` when a HEAD stream closes before `res.end()`, - // so listen to the underlying stream instead - const closable = 'stream' in stream ? stream.stream : stream - closable.once('close', () => { - if (!stream.writableFinished || !stream.writableEnded) { - controller.abort(new AbortError('Writable stream closed before it finished writing')) - } - }) + if (stream.destroyed) { + onClose() + } + else { + // also keeps an 'error' nobody else listens to from crashing the process + res.once('error', error => controller.abort(error)) + stream.once('close', onClose) } return controller.signal diff --git a/packages/node/src/utils.ts b/packages/node/src/utils.ts index fdc67183..8ce40b5b 100644 --- a/packages/node/src/utils.ts +++ b/packages/node/src/utils.ts @@ -67,25 +67,18 @@ async function _drainIterator(iterator: AsyncIterator): Promise { } /** - * Check both the response itself and its underlying stream (http2) are still writable. + * Check a response can still be sent: its headers aren't sent, it hasn't ended, + * and its connection (the underlying stream on http2) is still open. + * + * Only `destroyed` is read from an http2 stream: Node ends its writable side up front + * for a HEAD request, whose headers can still be sent. */ export function canWriteToNodeResponse(res: Stream.Writable | NodeHttpResponse): boolean { - if ('headersSent' in res && res.headersSent) { + if (('headersSent' in res && res.headersSent) || res.writableEnded) { return false } - if ('stream' in res) { - // Node ends the writable side of an http2 HEAD stream up front (`res.writableFinished` - // mirrors it), yet its headers can still be sent, so only the stream staying open - // and the response itself not ending matter here. - return !res.stream.closed && !res.stream.destroyed && !res.writableEnded - } - - return _canWriteToStream(res) -} - -function _canWriteToStream(stream: Stream.Writable): boolean { - return !stream.closed && !stream.destroyed && !stream.writableFinished && !stream.writableEnded + return !('stream' in res ? res.stream : res).destroyed } /** From 2fa9aab93752a2159d2e31bf389e13e8521c6af0 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 14:27:18 +0000 Subject: [PATCH 3/6] refactor(node, fastify): simplify http2 HEAD handling - sendStandardResponse (node): handle an http2 HEAD response in the existing no-body branch, releasing a stream body there, and keep the pipe branch unchanged - canWriteToNodeResponse: a single expression - drop comments the http2 HEAD tests already cover Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019TvbqaqZZZK5rU6p35jamj --- packages/fastify/src/response.test.ts | 1 - packages/fastify/src/response.ts | 3 --- packages/node/src/response.test.ts | 2 -- packages/node/src/response.ts | 15 +++++---------- packages/node/src/signal.test.ts | 1 - packages/node/src/signal.ts | 6 ------ packages/node/src/utils.test.ts | 1 - packages/node/src/utils.ts | 14 ++++---------- 8 files changed, 9 insertions(+), 34 deletions(-) diff --git a/packages/fastify/src/response.test.ts b/packages/fastify/src/response.test.ts index 2f4f922d..db83b0c1 100644 --- a/packages/fastify/src/response.test.ts +++ b/packages/fastify/src/response.test.ts @@ -592,7 +592,6 @@ describe('sendStandardResponse', () => { }) }) - // Node ends the writable side of an http2 HEAD stream before the request is handled describe('http2 HEAD request', () => { it('sends the status and headers on an auto-exposed HEAD route', async ({ onTestFinished }) => { let sending: Promise | undefined diff --git a/packages/fastify/src/response.ts b/packages/fastify/src/response.ts index 5a55e2dd..6d3858e2 100644 --- a/packages/fastify/src/response.ts +++ b/packages/fastify/src/response.ts @@ -47,9 +47,6 @@ export async function sendStandardResponse( } } - // Node ends the writable side of an http2 HEAD stream up front, so a stream body can't be - // piped there (fastify stalls without ever closing `reply.raw`): release it and send an - // empty one, which both explicit and auto-exposed HEAD routes end cleanly with the headers if (typeof resBody === 'object' && 'stream' in reply.raw && reply.raw.stream.writableEnded) { resBody.destroy() reply.send(Readable.from([])) diff --git a/packages/node/src/response.test.ts b/packages/node/src/response.test.ts index 92250100..c01d015c 100644 --- a/packages/node/src/response.test.ts +++ b/packages/node/src/response.test.ts @@ -502,7 +502,6 @@ describe('sendStandardResponse', () => { }) }) - // Node ends the writable side of an http2 HEAD stream before the request is handled describe('http2 HEAD request', () => { it('sends the status and headers without the body', async () => { let res: Http2ServerResponse | undefined @@ -526,7 +525,6 @@ describe('sendStandardResponse', () => { }) expect(response.body).toBe('') - // the body was never written to the ended stream expect(res!.stream.errored).toBe(null) }) diff --git a/packages/node/src/response.ts b/packages/node/src/response.ts index 5fd385c3..2ad14948 100644 --- a/packages/node/src/response.ts +++ b/packages/node/src/response.ts @@ -47,11 +47,11 @@ export async function sendStandardResponse( } } - // Node ends the writable side of an http2 HEAD stream up front, so its body can't be written: - // a write fails with ERR_STREAM_WRITE_AFTER_END, and a pipe stalls without ever closing `res` - const isHeadersOnly = 'stream' in res && res.stream.writableEnded + if (resBody === undefined || ('stream' in res && res.stream.writableEnded)) { + if (typeof resBody === 'object') { + resBody.destroy() + } - if (resBody === undefined || (typeof resBody === 'string' && isHeadersOnly)) { // NOTE: Lambda functions don't allow passing undefined to `res.end` res.end() } @@ -68,12 +68,7 @@ export async function sendStandardResponse( // WARNING: errors that occur here are silently ignored and not reported to the Promise resBody.once('error', error => res.destroy(error)) - if (isHeadersOnly) { - res.end() // the `close` listener above releases the unsent body - } - else { - resBody.pipe(res) - } + resBody.pipe(res) } } catch (error) { diff --git a/packages/node/src/signal.test.ts b/packages/node/src/signal.test.ts index 5ac19b7a..bd2e638f 100644 --- a/packages/node/src/signal.test.ts +++ b/packages/node/src/signal.test.ts @@ -219,7 +219,6 @@ describe('toAbortSignal', async () => { expect(signal.aborted).toBe(false) - // Node emits no `close` on an http2 HEAD response that never ended await new Promise(r => res.stream.once('close', () => r())) expect(signal.aborted).toBe(true) diff --git a/packages/node/src/signal.ts b/packages/node/src/signal.ts index a98699c5..d8ba4510 100644 --- a/packages/node/src/signal.ts +++ b/packages/node/src/signal.ts @@ -3,14 +3,9 @@ import type { NodeHttpResponse } from './types' import { AbortError } from '@standard-server/shared' import { getNodeResponseError } from './utils' -/** - * Abort when the response closes before it ends. - */ export function toAbortSignal(res: Stream.Writable | NodeHttpResponse): AbortSignal { const controller = new AbortController() - // On http2 the connection closes on the underlying stream: Node skips the response's own - // `close` when a HEAD stream closes before `res.end()` const stream = 'stream' in res ? res.stream : res const onClose = () => { @@ -23,7 +18,6 @@ export function toAbortSignal(res: Stream.Writable | NodeHttpResponse): AbortSig onClose() } else { - // also keeps an 'error' nobody else listens to from crashing the process res.once('error', error => controller.abort(error)) stream.once('close', onClose) } diff --git a/packages/node/src/utils.test.ts b/packages/node/src/utils.test.ts index b64716fb..45a65567 100644 --- a/packages/node/src/utils.test.ts +++ b/packages/node/src/utils.test.ts @@ -196,7 +196,6 @@ describe('canWriteToNodeResponse', () => { resolve() } catch (error) { - res.end() reject(error) } }) diff --git a/packages/node/src/utils.ts b/packages/node/src/utils.ts index 8ce40b5b..7ee27d39 100644 --- a/packages/node/src/utils.ts +++ b/packages/node/src/utils.ts @@ -67,18 +67,12 @@ async function _drainIterator(iterator: AsyncIterator): Promise { } /** - * Check a response can still be sent: its headers aren't sent, it hasn't ended, - * and its connection (the underlying stream on http2) is still open. - * - * Only `destroyed` is read from an http2 stream: Node ends its writable side up front - * for a HEAD request, whose headers can still be sent. + * Check the response can still be sent. */ export function canWriteToNodeResponse(res: Stream.Writable | NodeHttpResponse): boolean { - if (('headersSent' in res && res.headersSent) || res.writableEnded) { - return false - } - - return !('stream' in res ? res.stream : res).destroyed + return !('headersSent' in res && res.headersSent) + && !res.writableEnded + && !('stream' in res ? res.stream : res).destroyed } /** From f2e5f29ce079679cf47eb27510f8e8355edacc82 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 14:40:29 +0000 Subject: [PATCH 4/6] refactor(node, fastify): release the body on connection close instead of checking HEAD Drop the http2 HEAD checks from sendStandardResponse: a HEAD handler is expected to return no body. A stream body is instead released when the connection closes (the underlying stream on http2, whose close Node still emits for HEAD), so a stream body sent to a HEAD request no longer runs forever or leaves the promise pending. This also stops fastify's auto-exposed HEAD route from draining an endless body forever. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019TvbqaqZZZK5rU6p35jamj --- packages/fastify/src/response.test.ts | 96 ++++++++++++++++----------- packages/fastify/src/response.ts | 21 +++--- packages/node/src/response.test.ts | 13 ++-- packages/node/src/response.ts | 12 ++-- 4 files changed, 77 insertions(+), 65 deletions(-) diff --git a/packages/fastify/src/response.test.ts b/packages/fastify/src/response.test.ts index db83b0c1..1b394d44 100644 --- a/packages/fastify/src/response.test.ts +++ b/packages/fastify/src/response.test.ts @@ -592,20 +592,41 @@ describe('sendStandardResponse', () => { }) }) - describe('http2 HEAD request', () => { - it('sends the status and headers on an auto-exposed HEAD route', async ({ onTestFinished }) => { + describe('head request with a stream body', () => { + it('releases the body on an auto-exposed HEAD route', async ({ onTestFinished }) => { + const { body, isReleased } = createEndlessBody() let sending: Promise | undefined - const fastify = Fastify({ http2: true }) + const fastify = Fastify() onTestFinished(() => fastify.close()) fastify.get('/', async (req, reply) => { - sending = sendStandardResponse(reply, { - status: 201, - headers: { 'x-custom-header': 'custom-value' }, - body: new Blob(['foo']), - }) + sending = sendStandardResponse(reply, { status: 201, headers: { 'x-custom-header': 'custom-value' }, body }) + await sending + }) + + await fastify.ready() + const res = await request(fastify.server).head('/') + + await expect(sending).resolves.toBeUndefined() + + expect(res.status).toBe(201) + expect(res.headers).toMatchObject({ 'x-custom-header': 'custom-value' }) + + await vi.waitFor(() => { + expect(isReleased()).toBe(true) + }) + }) + it('releases the body on an auto-exposed http2 HEAD route', async ({ onTestFinished }) => { + const { body, isReleased } = createEndlessBody() + let sending: Promise | undefined + + const fastify = Fastify({ http2: true }) + onTestFinished(() => fastify.close()) + + fastify.get('/', async (req, reply) => { + sending = sendStandardResponse(reply, { status: 201, headers: { 'x-custom-header': 'custom-value' }, body }) await sending }) @@ -613,40 +634,23 @@ describe('sendStandardResponse', () => { await expect(sending).resolves.toBeUndefined() - expect(response.headers).toMatchObject({ - ':status': 201, - 'content-length': '3', - 'x-custom-header': 'custom-value', - }) + expect(response.headers).toMatchObject({ ':status': 201, 'x-custom-header': 'custom-value' }) expect(response.body).toBe('') - }) - it('resolves and releases a stream body it cannot send on an explicit HEAD route', async ({ onTestFinished }) => { - let clean = false - const body = (async function* () { - try { - while (true) { - yield 'foo' - await new Promise(r => setTimeout(r, 10)) - } - } - finally { - clean = true - } - })() + await vi.waitFor(() => { + expect(isReleased()).toBe(true) + }) + }) + it('releases the body on an explicit http2 HEAD route', async ({ onTestFinished }) => { + const { body, isReleased } = createEndlessBody() let sending: Promise | undefined const fastify = Fastify({ http2: true }) onTestFinished(() => fastify.close()) fastify.head('/', async (req, reply) => { - sending = sendStandardResponse(reply, { - status: 201, - headers: { 'x-custom-header': 'custom-value' }, - body, - }) - + sending = sendStandardResponse(reply, { status: 201, headers: { 'x-custom-header': 'custom-value' }, body }) await sending }) @@ -654,20 +658,34 @@ describe('sendStandardResponse', () => { await expect(sending).resolves.toBeUndefined() - expect(response.headers).toMatchObject({ - ':status': 201, - 'content-type': 'text/event-stream', - 'x-custom-header': 'custom-value', - }) + expect(response.headers).toMatchObject({ ':status': 201, 'x-custom-header': 'custom-value' }) expect(response.body).toBe('') await vi.waitFor(() => { - expect(clean).toBe(true) + expect(isReleased()).toBe(true) }) }) }) }) +function createEndlessBody() { + let released = false + + const body = (async function* () { + try { + while (true) { + yield 'foo' + await new Promise(r => setTimeout(r, 10)) + } + } + finally { + released = true + } + })() + + return { body, isReleased: () => released } +} + async function requestHttp2Head(origin: string): Promise<{ headers: http2.IncomingHttpHeaders, body: string }> { const client = http2.connect(origin) diff --git a/packages/fastify/src/response.ts b/packages/fastify/src/response.ts index 6d3858e2..d39aaead 100644 --- a/packages/fastify/src/response.ts +++ b/packages/fastify/src/response.ts @@ -1,7 +1,6 @@ import type { StandardResponse } from '@standard-server/core' import type { ToNodeHttpBodyOptions } from '@standard-server/node' import type { AnyFastifyReply } from './types' -import { Readable } from 'node:stream' import { canWriteToNodeResponse, getNodeResponseError, toNodeHttpBody } from '@standard-server/node' export interface SendStandardResponseOptions extends ToNodeHttpBodyOptions { @@ -33,8 +32,16 @@ export async function sendStandardResponse( return } + const connection = 'stream' in reply.raw ? reply.raw.stream : reply.raw + reply.raw.once('error', reject) - reply.raw.once('close', resolve) + connection.once('close', () => { + if (typeof resBody === 'object' && !resBody.closed) { + resBody.destroy() + } + + resolve() + }) try { reply.status(standardResponse.status) @@ -47,14 +54,8 @@ export async function sendStandardResponse( } } - if (typeof resBody === 'object' && 'stream' in reply.raw && reply.raw.stream.writableEnded) { - resBody.destroy() - reply.send(Readable.from([])) - } - else { - // fastify pipes and cleans up the stream body itself, no manual piping needed - reply.send(resBody) - } + // fastify pipes the stream body itself, no manual piping needed + reply.send(resBody) } catch (error) { if (typeof resBody === 'object' && !resBody.closed) { diff --git a/packages/node/src/response.test.ts b/packages/node/src/response.test.ts index c01d015c..e9ddb4a9 100644 --- a/packages/node/src/response.test.ts +++ b/packages/node/src/response.test.ts @@ -503,16 +503,14 @@ describe('sendStandardResponse', () => { }) describe('http2 HEAD request', () => { - it('sends the status and headers without the body', async () => { - let res: Http2ServerResponse | undefined + it('sends the status and headers', async () => { let sending: Promise | undefined - const response = await requestHttp2Head((_, _res) => { - res = _res + const response = await requestHttp2Head((_, res) => { sending = sendStandardResponse(res, { status: 201, headers: { 'x-custom-header': 'custom-value' }, - body: { foo: 'bar' }, + body: undefined, }) }) @@ -520,15 +518,12 @@ describe('sendStandardResponse', () => { expect(response.headers).toMatchObject({ ':status': 201, - 'content-type': 'application/json', 'x-custom-header': 'custom-value', }) expect(response.body).toBe('') - - expect(res!.stream.errored).toBe(null) }) - it('resolves and releases a stream body it cannot send', async () => { + it('releases a stream body once the stream closes', async () => { let clean = false const body = (async function* () { try { diff --git a/packages/node/src/response.ts b/packages/node/src/response.ts index 2ad14948..34822bb1 100644 --- a/packages/node/src/response.ts +++ b/packages/node/src/response.ts @@ -33,8 +33,10 @@ export async function sendStandardResponse( return } + const connection = 'stream' in res ? res.stream : res + res.once('error', reject) - res.once('close', resolve) + connection.once('close', resolve) try { // DON'T use `res.writeHead` because it send response immediately in chunked mode @@ -47,11 +49,7 @@ export async function sendStandardResponse( } } - if (resBody === undefined || ('stream' in res && res.stream.writableEnded)) { - if (typeof resBody === 'object') { - resBody.destroy() - } - + if (resBody === undefined) { // NOTE: Lambda functions don't allow passing undefined to `res.end` res.end() } @@ -59,7 +57,7 @@ export async function sendStandardResponse( res.end(resBody) } else { - res.once('close', () => { + connection.once('close', () => { if (!resBody.closed) { resBody.destroy(getNodeResponseError(res) ?? undefined) } From c9e00876ae36e123410e778c6179140297cdb36e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 02:26:41 +0000 Subject: [PATCH 5/6] fix(node, fastify): listen for errors on the connection too On http2, Node never forwards stream errors to the response object, so `res.once('error', ...)` never fired and a stream that failed (a client reset with an error code, or a body written to a HEAD response) still resolved sendStandardResponse. Listen for `error` where `close` is already listened for: the underlying stream on http2. A plain client cancel is not a stream error in Node, so it still resolves. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019TvbqaqZZZK5rU6p35jamj --- packages/fastify/src/response.test.ts | 34 ++++++++++++++-- packages/fastify/src/response.ts | 2 +- packages/node/src/response.test.ts | 57 ++++++++++++++++++++++++++- packages/node/src/response.ts | 2 +- packages/node/src/signal.ts | 2 +- 5 files changed, 89 insertions(+), 8 deletions(-) diff --git a/packages/fastify/src/response.test.ts b/packages/fastify/src/response.test.ts index 1b394d44..0405ba3f 100644 --- a/packages/fastify/src/response.test.ts +++ b/packages/fastify/src/response.test.ts @@ -642,7 +642,7 @@ describe('sendStandardResponse', () => { }) }) - it('releases the body on an explicit http2 HEAD route', async ({ onTestFinished }) => { + it('rejects and releases the body on an explicit http2 HEAD route', async ({ onTestFinished }) => { const { body, isReleased } = createEndlessBody() let sending: Promise | undefined @@ -651,12 +651,12 @@ describe('sendStandardResponse', () => { fastify.head('/', async (req, reply) => { sending = sendStandardResponse(reply, { status: 201, headers: { 'x-custom-header': 'custom-value' }, body }) - await sending + await sending.catch(() => {}) }) const response = await requestHttp2Head(await fastify.listen({ port: 0, host: '127.0.0.1' })) - await expect(sending).resolves.toBeUndefined() + await expect(sending).rejects.toMatchObject({ code: 'ERR_STREAM_WRITE_AFTER_END' }) expect(response.headers).toMatchObject({ ':status': 201, 'x-custom-header': 'custom-value' }) expect(response.body).toBe('') @@ -666,6 +666,34 @@ describe('sendStandardResponse', () => { }) }) }) + + it('rejects with the stream error when the client resets an http2 stream with an error code', async ({ onTestFinished }) => { + const { body, isReleased } = createEndlessBody() + let sending: Promise | undefined + + const fastify = Fastify({ http2: true }) + onTestFinished(() => fastify.close()) + + fastify.get('/', async (req, reply) => { + sending = sendStandardResponse(reply, { status: 200, headers: {}, body }) + await sending.catch(() => {}) + }) + + const client = http2.connect(await fastify.listen({ port: 0, host: '127.0.0.1' })) + onTestFinished(() => client.close()) + + const reqStream = client.request({ ':path': '/' }) + reqStream.once('error', () => {}) + + await new Promise(r => reqStream.once('data', r)) + reqStream.close(http2.constants.NGHTTP2_INTERNAL_ERROR) + + await expect(sending).rejects.toMatchObject({ code: 'ERR_HTTP2_STREAM_ERROR' }) + + await vi.waitFor(() => { + expect(isReleased()).toBe(true) + }) + }) }) function createEndlessBody() { diff --git a/packages/fastify/src/response.ts b/packages/fastify/src/response.ts index d39aaead..3e00e2f6 100644 --- a/packages/fastify/src/response.ts +++ b/packages/fastify/src/response.ts @@ -34,7 +34,7 @@ export async function sendStandardResponse( const connection = 'stream' in reply.raw ? reply.raw.stream : reply.raw - reply.raw.once('error', reject) + connection.once('error', reject) connection.once('close', () => { if (typeof resBody === 'object' && !resBody.closed) { resBody.destroy() diff --git a/packages/node/src/response.test.ts b/packages/node/src/response.test.ts index e9ddb4a9..209230b4 100644 --- a/packages/node/src/response.test.ts +++ b/packages/node/src/response.test.ts @@ -523,7 +523,7 @@ describe('sendStandardResponse', () => { expect(response.body).toBe('') }) - it('releases a stream body once the stream closes', async () => { + it('rejects and releases a stream body it cannot send', async () => { let clean = false const body = (async function* () { try { @@ -541,9 +541,10 @@ describe('sendStandardResponse', () => { const response = await requestHttp2Head((_, res) => { sending = sendStandardResponse(res, { status: 201, headers: {}, body }) + sending.catch(() => {}) }) - await expect(sending).resolves.toBeUndefined() + await expect(sending).rejects.toMatchObject({ code: 'ERR_STREAM_WRITE_AFTER_END' }) expect(response.headers).toMatchObject({ ':status': 201, @@ -556,6 +557,58 @@ describe('sendStandardResponse', () => { }) }) }) + + describe('http2 stream reset by the client', () => { + it.each([ + ['resolves on a cancel', http2.constants.NGHTTP2_CANCEL, undefined], + ['rejects with the stream error on an error code', http2.constants.NGHTTP2_INTERNAL_ERROR, 'ERR_HTTP2_STREAM_ERROR'], + ])('%s', async (_, code, errorCode) => { + let clean = false + const body = (async function* () { + try { + while (true) { + yield 'foo' + await new Promise(r => setTimeout(r, 10)) + } + } + finally { + clean = true + } + })() + + let sending: Promise | undefined + + const server = http2.createServer((req, res) => { + sending = sendStandardResponse(res, { status: 200, headers: {}, body }) + }) + await new Promise(r => server.listen(0, r)) + + const client = http2.connect(`http://localhost:${(server.address() as AddressInfo).port}`) + + try { + const reqStream = client.request({ ':path': '/' }) + reqStream.once('error', () => {}) + + await new Promise(r => reqStream.once('data', r)) + reqStream.close(code) + + if (errorCode) { + await expect(sending).rejects.toMatchObject({ code: errorCode }) + } + else { + await expect(sending).resolves.toBeUndefined() + } + + await vi.waitFor(() => { + expect(clean).toBe(true) + }) + } + finally { + client.close() + await new Promise(r => server.close(r)) + } + }) + }) }) async function requestHttp2Head( diff --git a/packages/node/src/response.ts b/packages/node/src/response.ts index 34822bb1..d90a46ca 100644 --- a/packages/node/src/response.ts +++ b/packages/node/src/response.ts @@ -35,7 +35,7 @@ export async function sendStandardResponse( const connection = 'stream' in res ? res.stream : res - res.once('error', reject) + connection.once('error', reject) connection.once('close', resolve) try { diff --git a/packages/node/src/signal.ts b/packages/node/src/signal.ts index d8ba4510..0a932fcf 100644 --- a/packages/node/src/signal.ts +++ b/packages/node/src/signal.ts @@ -18,7 +18,7 @@ export function toAbortSignal(res: Stream.Writable | NodeHttpResponse): AbortSig onClose() } else { - res.once('error', error => controller.abort(error)) + stream.once('error', error => controller.abort(error)) stream.once('close', onClose) } From 1b4167d4f568ff7b02c8a2a3972372fb8e98008f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 02:28:44 +0000 Subject: [PATCH 6/6] test: cover the HEAD method in the e2e tests Add a HEAD request to the data transfer method table and a HEAD variant of the "never aborted" signal test, run against every adapter. Both fail on node-http2 and node-http2-secure before the http2 HEAD fix. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019TvbqaqZZZK5rU6p35jamj --- tests/data-transfer.test.ts | 1 + tests/signal-and-cancel.test.ts | 6 +++--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/data-transfer.test.ts b/tests/data-transfer.test.ts index a5174487..a1e865c1 100644 --- a/tests/data-transfer.test.ts +++ b/tests/data-transfer.test.ts @@ -56,6 +56,7 @@ describe.each([ ['/test', 'POST', {}, 200], ['/test?query=true', 'GET', { h1: 'v1', h2: 'v2' }, 201], ['/hi%2F', 'DELETE', { h3: 'v3' }, 500], + ['/test', 'HEAD', { h4: 'v4' }, 201], ])('url=$0, method=$1, headers=$2, status=$3', async (url, method, headers, status) => { clientServer.handler.mockResolvedValueOnce({ headers: { diff --git a/tests/signal-and-cancel.test.ts b/tests/signal-and-cancel.test.ts index 48fcc2de..3aba2e74 100644 --- a/tests/signal-and-cancel.test.ts +++ b/tests/signal-and-cancel.test.ts @@ -55,7 +55,7 @@ describe.each([ ] as const)('signal and cancel: $0', (adapter, createClientServer) => { const clientServer = createClientServer() - it('never aborted', async () => { + it.each(['GET', 'HEAD'])('never aborted: %s', async (method) => { let serverSignal!: AbortSignal clientServer.handler.mockImplementationOnce(async ({ signal }) => { @@ -66,14 +66,14 @@ describe.each([ return { headers: {}, status: 200, - body: 'Hello', + body: method === 'HEAD' ? undefined : 'Hello', } }) const response = await clientServer.request({ headers: {}, body: undefined, - method: 'GET', + method, url: '/', })