diff --git a/src/listener.ts b/src/listener.ts index f48f8c3..bf1a7d6 100644 --- a/src/listener.ts +++ b/src/listener.ts @@ -356,6 +356,7 @@ const responseViaResponseObject = async ( const values: Uint8Array[] = [] let done = false + let readError: { error: unknown } | undefined let currentReadPromise: Promise> | undefined = undefined if (resHeaderRecord['transfer-encoding'] !== 'chunked') { @@ -364,9 +365,11 @@ const responseViaResponseObject = async ( for (let i = 0; i < maxReadCount; i++) { currentReadPromise ||= reader.read() const chunk = await readWithoutBlocking(currentReadPromise).catch((e) => { - console.error(e) - done = true + readError = { error: e } }) + if (readError) { + break + } if (!chunk) { if (i === 1) { // XXX: In Node.js v24, some response bodies are not read all the way through until the next task queue, @@ -376,9 +379,7 @@ const responseViaResponseObject = async ( continue } - // Error occurred or currentReadPromise is not yet resolved. - // If an error occurs, immediately break the loop. - // If currentReadPromise is not yet resolved, pass it to writeFromReadableStreamDefaultReader. + // currentReadPromise is not yet resolved, so pass it to writeFromReadableStreamDefaultReader. break } currentReadPromise = undefined @@ -392,6 +393,14 @@ const responseViaResponseObject = async ( } } + if (readError) { + // The body failed before anything was sent, so it must not be reported as a + // complete (possibly empty) response. + handleResponseError(readError.error, outgoing) + ;(outgoing as OutgoingHasOutgoingEnded)[outgoingEnded]?.() + return + } + if (done && !('content-length' in resHeaderRecord)) { resHeaderRecord['content-length'] = values.reduce((acc, value) => acc + value.length, 0) } diff --git a/test/listener.test.ts b/test/listener.test.ts index 1dd17d0..654af9c 100644 --- a/test/listener.test.ts +++ b/test/listener.test.ts @@ -166,6 +166,41 @@ describe('Invalid request', () => { }) }) +describe('Response body errors - native Response', () => { + const requestListener = getRequestListener((req) => { + const path = new URL(req.url).pathname + return new GlobalResponse( + new ReadableStream({ + start(controller) { + if (path === '/after-chunk') { + controller.enqueue(new TextEncoder().encode('partial')) + } + controller.error(new Error('failed')) + }, + }) + ) + }) + const server = createServer(requestListener) + + beforeEach(() => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('Should not send a complete empty 200 when the body fails before its first chunk', async () => { + const res = await requestServer(server, { method: 'GET', path: '/' }) + expect(res.status).toBe(500) + }) + + it('Should not send a complete partial 200 when the body fails during the early read', async () => { + const res = await requestServer(server, { method: 'GET', path: '/after-chunk' }) + expect(res.status).toBe(500) + }) +}) + describe('Response headers', () => { const requestListener = getRequestListener(() => new Response(null), { hostname: 'example.com',