diff --git a/packages/next/src/server/lib/patch-fetch.ts b/packages/next/src/server/lib/patch-fetch.ts index 0eeec7013e73..12f82741de7f 100644 --- a/packages/next/src/server/lib/patch-fetch.ts +++ b/packages/next/src/server/lib/patch-fetch.ts @@ -803,15 +803,27 @@ export function createPatchedFetcher( if (isRequestInput) { const reqInput: Request = input as any - const reqOptions: RequestInit = { - body: (reqInput as any)._ogBody || reqInput.body, - } + const reqOptions: RequestInit = {} for (const field of requestInputFields) { // @ts-expect-error custom fields reqOptions[field] = reqInput[field] } - input = new Request(reqInput.url, reqOptions) + + const ogBody = (reqInput as any)._ogBody + if (ogBody !== undefined) { + reqOptions.body = ogBody + input = new Request(reqInput.url, reqOptions) + } else { + // When _ogBody is absent, the body stream hasn't been consumed. + // Pass the original Request object instead of reqInput.url to + // preserve the internal body source, which Node 24.14+ (undici) + // requires to be non-null for requests with a body. + if (isStale) { + reqOptions.signal = null + } + input = new Request(reqInput, reqOptions) + } } else if (init) { const { _ogBody, body, signal, ...otherInput } = init as RequestInit & { _ogBody?: any } diff --git a/test/e2e/app-dir/patch-fetch-request-body/app/api/test-post/route.ts b/test/e2e/app-dir/patch-fetch-request-body/app/api/test-post/route.ts new file mode 100644 index 000000000000..c18a2b17fdbc --- /dev/null +++ b/test/e2e/app-dir/patch-fetch-request-body/app/api/test-post/route.ts @@ -0,0 +1,16 @@ +import { NextResponse } from 'next/server' + +export async function POST() { + const req = new Request( + `http://localhost:${process.env.EXTERNAL_SERVER_PORT}/post`, + { + method: 'POST', + body: JSON.stringify({ key: 'value' }), + headers: { 'Content-Type': 'application/json' }, + } + ) + + const res = await fetch(req) + const data = await res.json() + return NextResponse.json({ status: res.status, data }) +} diff --git a/test/e2e/app-dir/patch-fetch-request-body/app/layout.tsx b/test/e2e/app-dir/patch-fetch-request-body/app/layout.tsx new file mode 100644 index 000000000000..888614deda3b --- /dev/null +++ b/test/e2e/app-dir/patch-fetch-request-body/app/layout.tsx @@ -0,0 +1,8 @@ +import { ReactNode } from 'react' +export default function Root({ children }: { children: ReactNode }) { + return ( + + {children} + + ) +} diff --git a/test/e2e/app-dir/patch-fetch-request-body/app/page.tsx b/test/e2e/app-dir/patch-fetch-request-body/app/page.tsx new file mode 100644 index 000000000000..ff7159d9149f --- /dev/null +++ b/test/e2e/app-dir/patch-fetch-request-body/app/page.tsx @@ -0,0 +1,3 @@ +export default function Page() { + return

hello world

+} diff --git a/test/e2e/app-dir/patch-fetch-request-body/next.config.js b/test/e2e/app-dir/patch-fetch-request-body/next.config.js new file mode 100644 index 000000000000..807126e4cf0b --- /dev/null +++ b/test/e2e/app-dir/patch-fetch-request-body/next.config.js @@ -0,0 +1,6 @@ +/** + * @type {import('next').NextConfig} + */ +const nextConfig = {} + +module.exports = nextConfig diff --git a/test/e2e/app-dir/patch-fetch-request-body/patch-fetch-request-body.test.ts b/test/e2e/app-dir/patch-fetch-request-body/patch-fetch-request-body.test.ts new file mode 100644 index 000000000000..ec114f2c578d --- /dev/null +++ b/test/e2e/app-dir/patch-fetch-request-body/patch-fetch-request-body.test.ts @@ -0,0 +1,53 @@ +import { nextTestSetup } from 'e2e-utils' +import { findPort } from 'next-test-utils' +import http from 'http' + +describe('patch-fetch-request-body', () => { + let externalServerPort: number + let externalServer: http.Server + + const { next, skipped } = nextTestSetup({ + files: __dirname, + skipStart: true, + skipDeployment: true, + }) + + if (skipped) { + return + } + + beforeAll(async () => { + externalServerPort = await findPort() + + externalServer = http.createServer((req, res) => { + req.resume() + req.on('end', () => { + res.writeHead(401, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ status: 401 })) + }) + }) + + await new Promise((resolve, reject) => { + externalServer.listen(externalServerPort, () => resolve()) + externalServer.once('error', reject) + }) + + next.env.EXTERNAL_SERVER_PORT = String(externalServerPort) + await next.start() + }) + + afterAll(() => { + externalServer?.close() + }) + + // On Node 24.14+, patch-fetch previously reconstructed the Request using + // reqInput.url which lost the internal body source. undici then threw + // "TypeError: expected non-null body source" when sending the request. + it('should preserve Request body source for uncached POST requests', async () => { + const res = await next.fetch('/api/test-post', { method: 'POST' }) + expect(res.status).toBe(200) + + const json = await res.json() + expect(json.status).toBe(401) + }) +})