From 52bd2e2eccfc5449b2255cdc80f5dbcbd878991f Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Tue, 29 Sep 2026 15:45:56 +0700 Subject: [PATCH] fix(fetch): cancel the body when toFetchResponse throws `toFetchBody` returns an event stream that is already pulling from its iterator with the keep-alive interval armed. If `toFetchHeaders` or the `Response` constructor then threw (a 204/205/304 status with a body, a status outside 200-599, an invalid header), the stream was never cancelled: the iterator was never released and the interval kept firing. Building the response now runs in a `try`, and on failure a stream body is cancelled with the error before rethrowing it. A rejected cancel (a locked stream) is ignored so it cannot hide the original error. Co-Authored-By: Claude --- packages/fetch/src/response.test.ts | 30 +++++++++++++++++++++++++++++ packages/fetch/src/response.ts | 24 ++++++++++++++++------- 2 files changed, 47 insertions(+), 7 deletions(-) diff --git a/packages/fetch/src/response.test.ts b/packages/fetch/src/response.test.ts index cd7adbcd..05f18fff 100644 --- a/packages/fetch/src/response.test.ts +++ b/packages/fetch/src/response.test.ts @@ -1,4 +1,5 @@ import type { StandardResponse } from '@standard-server/core' +import { AsyncIteratorClass } from '@standard-server/shared' import * as Body from './body' import * as Headers from './headers' import { toFetchResponse, toStandardLazyResponse } from './response' @@ -35,6 +36,35 @@ describe('toFetchResponse', () => { expect(toFetchHeadersSpy).toBeCalledTimes(1) expect(toFetchHeadersSpy).toBeCalledWith(toFetchBodySpy.mock.results[0]!.value[1]) }) + + describe('releases the body when the response cannot be built', () => { + it('event-stream body when the status cannot have a body', async () => { + const next = vi.fn(() => new Promise(() => {})) + const cleanup = vi.fn() + + expect(() => toFetchResponse({ status: 204, headers: {}, body: new AsyncIteratorClass(next, cleanup) })).toThrow(TypeError) + + // the event stream starts pulling right away, so it must be cancelled to stop + // the keep-alive interval and release the pending iterator + await vi.waitFor(() => expect(cleanup).toHaveBeenCalledWith({ kind: 'cancelled' })) + expect(next).toHaveBeenCalledTimes(1) + }) + + it('stream body when a header is invalid', () => { + const cancel = vi.fn() + + expect(() => toFetchResponse({ status: 200, headers: { 'x-custom-header': 'a\nb' }, body: new ReadableStream({ cancel }) })).toThrow(TypeError) + expect(cancel).toHaveBeenCalledWith(expect.any(TypeError)) + }) + + it('throws the original error when the stream cannot be cancelled', () => { + const body = new ReadableStream() + body.getReader() + + // a locked stream rejects `cancel()`, which must not surface as an unhandled rejection + expect(() => toFetchResponse({ status: 200, headers: {}, body })).toThrow(/locked/) + }) + }) }) describe('toStandardLazyResponse', () => { diff --git a/packages/fetch/src/response.ts b/packages/fetch/src/response.ts index 050a1e7d..bb4de310 100644 --- a/packages/fetch/src/response.ts +++ b/packages/fetch/src/response.ts @@ -11,15 +11,25 @@ export function toFetchResponse( options: ToFetchResponseOptions = {}, ): Response { const [body, standardHeaders] = toFetchBody(standardResponse.body, standardResponse.headers, options) - const response = new Response(body, { - headers: toFetchHeaders(standardHeaders), - status: standardResponse.status, - }) - // not sure why, but some tests (@hono/node-server) fail without pre-accessing body - void response.body + try { + const response = new Response(body, { + headers: toFetchHeaders(standardHeaders), + status: standardResponse.status, + }) - return response + // not sure why, but some tests (@hono/node-server) fail without pre-accessing body + void response.body + + return response + } + catch (error) { + if (body instanceof ReadableStream) { + body.cancel(error).catch(() => {}) + } + + throw error + } } export function toStandardLazyResponse(