mirror of
https://github.com/hanzoai/next.js.git
synced 2026-09-24 17:00:51 +00:00
Recreation of https://github.com/vercel/next.js/pull/95370 ### What's the problem? A `POST` (or `PUT`/`PATCH`) request that passes through middleware returning `NextResponse.next()` hangs indefinitely when the downstream handler reads the body via Node's `Readable.toWeb()`. The request never completes and eventually times out. Reproduction: https://github.com/abir-taheer/next-js-readable-stream-bug ### Root cause When middleware runs, `runMiddleware` clones the request body and later calls `finalize()`, which grafts the buffered stream back onto the original `IncomingMessage` via `replaceRequestBody()` (`packages/next/src/server/body-streams.ts`). `replaceRequestBody` copies the buffered stream's enumerable properties onto the request. The buffered stream (`p2`) was a `PassThrough` — a `Duplex` — so its writable-side internals (`_writableState` plus the enumerable `Writable` methods like `write`/`end`) were copied onto the `IncomingMessage`. Because that `_writableState.finished` is `false`, Node stream utilities that inspect it — including `Readable.toWeb()`, which uses `finished()`/end-of-stream detection — treat the request as a still-open writable stream and wait forever. `NextResponse.rewrite()` is unaffected (it builds a new internal request and skips this path), and `GET`/`HEAD` requests are fine because there is no body to clone. ### The fix `p2` is only ever fed with `.push()`, so it never needs a writable side. Making it a plain `Readable` instead of a `PassThrough` keeps the finalized request a pure `Readable`, so `Readable.toWeb()` (and any other duck-typing based on `_writableState`) behaves correctly. No behavior change for the existing consumers, which only read the stream. ### Testing Added `test/unit/body-streams.test.ts`, which drives the real clone → `finalize()` flow and asserts the finalized request: - is no longer writable (`_writableState` is `undefined`), and - is fully consumable via `Readable.toWeb()` (this hangs before the fix). Fixes #95335 <!-- NEXT_JS_LLM_PR --> Co-authored-by: UditDewan <[email protected]> Co-authored-by: Baradhan-Madhu <[email protected]>
80 lines
2.6 KiB
TypeScript
80 lines
2.6 KiB
TypeScript
import type { IncomingMessage } from 'http'
|
|
import { Readable } from 'stream'
|
|
import { getCloneableBody } from 'next/dist/server/body-streams'
|
|
|
|
function mockIncoming(body: string): IncomingMessage {
|
|
const readable = new Readable({ read() {} })
|
|
process.nextTick(() => {
|
|
readable.push(Buffer.from(body))
|
|
readable.push(null)
|
|
})
|
|
return readable as unknown as IncomingMessage
|
|
}
|
|
|
|
async function readAll(stream: Readable): Promise<Buffer> {
|
|
const chunks: Buffer[] = []
|
|
for await (const chunk of stream) {
|
|
chunks.push(Buffer.from(chunk))
|
|
}
|
|
return Buffer.concat(chunks)
|
|
}
|
|
|
|
async function readAllWeb(stream: Readable, timeoutMs = 1000): Promise<Buffer> {
|
|
let timeout: ReturnType<typeof setTimeout>
|
|
const timeoutPromise = new Promise<never>((_, reject) => {
|
|
timeout = setTimeout(() => {
|
|
reject(new Error('Timed out reading stream via Readable.toWeb()'))
|
|
}, timeoutMs)
|
|
})
|
|
|
|
try {
|
|
return await Promise.race([
|
|
(async () => {
|
|
const web = Readable.toWeb(stream)
|
|
const reader = web.getReader()
|
|
const out: Buffer[] = []
|
|
|
|
for (;;) {
|
|
const { done, value } = await reader.read()
|
|
if (done) break
|
|
out.push(Buffer.from(value))
|
|
}
|
|
|
|
return Buffer.concat(out)
|
|
})(),
|
|
timeoutPromise,
|
|
])
|
|
} finally {
|
|
clearTimeout(timeout!)
|
|
}
|
|
}
|
|
|
|
describe('getCloneableBody', () => {
|
|
// Regression test for https://github.com/vercel/next.js/issues/95335
|
|
//
|
|
// After middleware clones the request body and `finalize()` swaps the buffered
|
|
// stream back onto the request, the request must stay a plain Readable. The
|
|
// buffered stream used to be a Duplex (PassThrough) whose writable-side
|
|
// internals (`_writableState`, `write`, `end`, ...) were copied onto the
|
|
// IncomingMessage, so a downstream handler reading a POST body via
|
|
// `Readable.toWeb()` saw an unfinished writable side and hung forever.
|
|
it('leaves the finalized request body consumable via Readable.toWeb()', async () => {
|
|
const body = 'hello world'
|
|
const req = mockIncoming(body)
|
|
|
|
const cloneable = getCloneableBody(req)
|
|
const middlewareCopy = cloneable.cloneBodyStream()
|
|
|
|
// Middleware consumes its own copy of the body (e.g. via NextResponse.next()).
|
|
expect((await readAll(middlewareCopy)).toString()).toBe(body)
|
|
|
|
await cloneable.finalize()
|
|
|
|
// The request must not masquerade as a writable stream after finalize.
|
|
expect((req as any)._writableState).toBeUndefined()
|
|
expect((req as any).write).toBeUndefined()
|
|
expect((req as any).end).toBeUndefined()
|
|
expect((await readAllWeb(req as unknown as Readable)).toString()).toBe(body)
|
|
})
|
|
})
|