Skip to content

Commit 9367bbd

Browse files
committed
fix: don't reject with unusable body when full body was received
1 parent 114c15e commit 9367bbd

2 files changed

Lines changed: 120 additions & 12 deletions

File tree

src/request.ts

Lines changed: 51 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,40 @@ const readBodyWithFastPath = <T>(
261261
})
262262
}
263263

264+
const normalizeAbortError = (
265+
request: Record<string | symbol, any>,
266+
incoming: IncomingMessage | Http2ServerRequest
267+
): Error => {
268+
if (incoming.errored) {
269+
return incoming.errored
270+
}
271+
const reason = request[abortReasonKey]
272+
if (typeof reason !== 'undefined') {
273+
return reason instanceof Error ? reason : new Error(String(reason))
274+
}
275+
return new Error('Client connection prematurely closed.')
276+
}
277+
278+
// A client abort destroys the IncomingMessage, but does not clear its internal
279+
// buffer. For HTTP/1, `complete === true` guarantees the parser received the
280+
// entire message, so the unread body can still be drained synchronously.
281+
// (Not valid for HTTP/2, where `complete` is also true for aborted requests. see https://nodejs.org/api/http2.html#requestcomplete)
282+
const canBeRecoveredAfterAbort = (
283+
incoming: IncomingMessage | Http2ServerRequest
284+
): incoming is IncomingMessage => {
285+
return incoming.complete && incoming.httpVersionMajor === 1
286+
}
287+
288+
const readIncomingMessageInternalBuffer = (incoming: IncomingMessage) => {
289+
const chunks: Buffer[] = []
290+
let chunk: Buffer | string | null
291+
while ((chunk = incoming.read()) !== null) {
292+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))
293+
}
294+
const buffer = chunks.length === 1 ? chunks[0] : Buffer.concat(chunks)
295+
return buffer
296+
}
297+
264298
const readRawBodyIfAvailable = (request: Record<string | symbol, any>): Buffer | undefined => {
265299
const incoming = request[incomingKey] as IncomingMessage | Http2ServerRequest
266300
if ('rawBody' in incoming && (incoming as any).rawBody instanceof Buffer) {
@@ -283,7 +317,16 @@ const readBodyDirect = (request: Record<string | symbol, any>): Promise<Buffer>
283317

284318
const incoming = request[incomingKey] as IncomingMessage | Http2ServerRequest
285319
if (Readable.isDisturbed(incoming)) {
286-
return rejectBodyUnusable()
320+
if (incoming.readableDidRead) {
321+
return rejectBodyUnusable()
322+
}
323+
// Aborted (e.g. client disconnect) before anything read the stream.
324+
if (canBeRecoveredAfterAbort(incoming)) {
325+
const buffer = readIncomingMessageInternalBuffer(incoming)
326+
request[bodyBufferKey] = buffer
327+
return Promise.resolve(buffer)
328+
}
329+
return Promise.reject(normalizeAbortError(request, incoming))
287330
}
288331

289332
const promise = new Promise<Buffer>((resolve, reject) => {
@@ -319,17 +362,14 @@ const readBodyDirect = (request: Record<string | symbol, any>): Promise<Buffer>
319362
onEnd()
320363
return
321364
}
365+
if (canBeRecoveredAfterAbort(incoming)) {
366+
// Drain what the abort left in the internal buffer.
367+
readIncomingMessageInternalBuffer(incoming)
368+
onEnd()
369+
return
370+
}
322371
finish(() => {
323-
if (incoming.errored) {
324-
reject(incoming.errored)
325-
return
326-
}
327-
const reason = request[abortReasonKey]
328-
if (reason !== undefined) {
329-
reject(reason instanceof Error ? reason : new Error(String(reason)))
330-
return
331-
}
332-
reject(new Error('Client connection prematurely closed.'))
372+
reject(normalizeAbortError(request, incoming))
333373
})
334374
}
335375
const cleanup = () => {

test/listener.test.ts

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import request from 'supertest'
2-
import { createServer } from 'node:http'
2+
import type { IncomingMessage } from 'node:http'
3+
import { createServer, request as httpRequest } from 'node:http'
4+
import type { AddressInfo } from 'node:net'
35
import { getRequestListener } from '../src/listener'
46
import { GlobalRequest, Request as LightweightRequest, RequestError } from '../src/request'
57
import { GlobalResponse, Response as LightweightResponse } from '../src/response'
@@ -422,6 +424,72 @@ describe('Abort request - error path', () => {
422424
)
423425
})
424426

427+
describe('Abort request - direct body read', () => {
428+
// See https://github.com/honojs/node-server/issues/370
429+
const runClientDisconnectCase = async (clientBody: {
430+
contentLength: number
431+
write: string
432+
}): Promise<{ body: Promise<string> }> => {
433+
// Wrapped in an object so `await` does not flatten the inner promise
434+
let resolveBodyRead!: (result: { body: Promise<string> }) => void
435+
const bodyRead = new Promise<{ body: Promise<string> }>((r) => {
436+
resolveBodyRead = r
437+
})
438+
439+
const requestListener = getRequestListener(async (req: Request, env) => {
440+
const incoming = (env as { incoming: IncomingMessage }).incoming
441+
// Read only after the client disconnect has destroyed the stream
442+
if (!incoming.destroyed) {
443+
await new Promise((resolve) => incoming.once('close', resolve))
444+
}
445+
const body = req.text()
446+
body.catch(() => {}) // inspected via expect(); avoid unhandled rejection
447+
resolveBodyRead({ body })
448+
return new Response('ok')
449+
})
450+
const server = createServer(requestListener)
451+
await new Promise<void>((resolve) => server.listen(0, resolve))
452+
const port = (server.address() as AddressInfo).port
453+
454+
try {
455+
const clientReq = httpRequest({
456+
port,
457+
method: 'POST',
458+
path: '/',
459+
headers: {
460+
'content-type': 'text/plain',
461+
'content-length': clientBody.contentLength,
462+
},
463+
})
464+
clientReq.on('error', () => {})
465+
clientReq.write(clientBody.write)
466+
// Give the bytes time to reach the server's parser, then disconnect
467+
await new Promise((resolve) => setTimeout(resolve, 30))
468+
clientReq.destroy()
469+
return await withTimeout(bodyRead, 'handler did not read the body')
470+
} finally {
471+
server.close()
472+
}
473+
}
474+
475+
it('should return the full body when the client disconnects after sending it', async () => {
476+
const { body } = await runClientDisconnectCase({
477+
contentLength: Buffer.byteLength('hello world'),
478+
write: 'hello world',
479+
})
480+
await expect(body).resolves.toBe('hello world')
481+
})
482+
483+
it('should reject with an abort error, not "Body is unusable", for a truncated body', async () => {
484+
const { body } = await runClientDisconnectCase({
485+
contentLength: 100,
486+
write: 'partial',
487+
})
488+
await expect(body).rejects.toThrow(/aborted|prematurely closed/)
489+
await expect(body).rejects.not.toThrow('Body is unusable')
490+
})
491+
})
492+
425493
describe('Abort request - cacheable response path', () => {
426494
it.each([
427495
['string', () => new Response('fast path')],

0 commit comments

Comments
 (0)