From 7c719277d8de7851f75d7364e6d8684ed05412c8 Mon Sep 17 00:00:00 2001 From: Taku Amano Date: Tue, 12 Aug 2025 20:42:48 +0900 Subject: [PATCH] fix: Skip content-length assignment when transfer-encoding is chunked. --- src/listener.ts | 63 ++++++++++++++++++++++++--------------------- test/server.test.ts | 22 ++++++++++++++++ 2 files changed, 55 insertions(+), 30 deletions(-) diff --git a/src/listener.ts b/src/listener.ts index fc38789..2724ffd 100644 --- a/src/listener.ts +++ b/src/listener.ts @@ -74,6 +74,7 @@ const responseViaCache = async ( header = buildOutgoingHttpHeaders(header) } + // in `responseViaCache`, if body is not stream, Transfer-Encoding is considered not chunked if (typeof body === 'string') { header['Content-Length'] = Buffer.byteLength(body) } else if (body instanceof Uint8Array) { @@ -131,43 +132,45 @@ const responseViaResponseObject = async ( let done = false let currentReadPromise: Promise> | undefined = undefined - // In the case of synchronous responses, usually a maximum of two (or three in special cases) readings is done - let maxReadCount = 2 - for (let i = 0; i < maxReadCount; i++) { - currentReadPromise ||= reader.read() - const chunk = await readWithoutBlocking(currentReadPromise).catch((e) => { - console.error(e) - done = true - }) - if (!chunk) { - if (i === 1 && resHeaderRecord['transfer-encoding'] !== 'chunked') { - // XXX: In Node.js v24, some response bodies are not read all the way through until the next task queue, - // so wait a moment and retry. (e.g. new Blob([new Uint8Array(contents)]) ) - await new Promise((resolve) => setTimeout(resolve)) - maxReadCount = 3 - continue + if (resHeaderRecord['transfer-encoding'] !== 'chunked') { + // In the case of synchronous responses, usually a maximum of two (or three in special cases) readings is done + let maxReadCount = 2 + for (let i = 0; i < maxReadCount; i++) { + currentReadPromise ||= reader.read() + const chunk = await readWithoutBlocking(currentReadPromise).catch((e) => { + console.error(e) + done = true + }) + 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, + // so wait a moment and retry. (e.g. new Blob([new Uint8Array(contents)]) ) + await new Promise((resolve) => setTimeout(resolve)) + maxReadCount = 3 + 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. + break } + currentReadPromise = undefined - // 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. - break + if (chunk.value) { + values.push(chunk.value) + } + if (chunk.done) { + done = true + break + } } - currentReadPromise = undefined - if (chunk.value) { - values.push(chunk.value) - } - if (chunk.done) { - done = true - break + if (done && !('content-length' in resHeaderRecord)) { + resHeaderRecord['content-length'] = values.reduce((acc, value) => acc + value.length, 0) } } - if (done && !('content-length' in resHeaderRecord)) { - resHeaderRecord['content-length'] = values.reduce((acc, value) => acc + value.length, 0) - } - outgoing.writeHead(res.status, resHeaderRecord) values.forEach((value) => { ;(outgoing as Writable).write(value) diff --git a/test/server.test.ts b/test/server.test.ts index 8a992ba..40456c7 100644 --- a/test/server.test.ts +++ b/test/server.test.ts @@ -185,6 +185,20 @@ describe('various response body types', () => { }) return new Response(stream) }) + app.get('/readable-stream-with-transfer-encoding', () => { + const stream = new ReadableStream({ + async start(controller) { + controller.enqueue('Hello!') // send one chunk synchronously + controller.close() + }, + }) + return new Response(stream, { + headers: { + 'content-type': 'text/plain; charset=UTF-8', + 'transfer-encoding': 'chunked', + }, + }) + }) const eventStreamPromise = new Promise((resolve) => { resolveEventStreamPromise = resolve }) @@ -295,6 +309,14 @@ describe('various response body types', () => { expect(expectedChunks.length).toBe(0) // all chunks are received }) + it('Should return 200 response - GET /readable-stream-with-transfer-encoding', async () => { + const res = await request(server).get('/readable-stream-with-transfer-encoding') + expect(res.status).toBe(200) + expect(res.headers['content-type']).toMatch('text/plain; charset=UTF-8') + expect(res.headers['transfer-encoding']).toBe('chunked') + expect(res.headers['content-length']).toBeUndefined() + }) + it('Should return 200 response - GET /event-stream', async () => { const expectedChunks = ['data: First!\n\n', 'data: Second!\n\n'] const resPromise = request(server)