Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 16 additions & 4 deletions packages/next/src/server/lib/patch-fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is different than the original implementation. Can you add a comment explaining why this is needed.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a comment explaining the branch.

reqOptions.signal = null
}
input = new Request(reqInput, reqOptions)
}
} else if (init) {
const { _ogBody, body, signal, ...otherInput } =
init as RequestInit & { _ogBody?: any }
Expand Down
Original file line number Diff line number Diff line change
@@ -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 })
}
8 changes: 8 additions & 0 deletions test/e2e/app-dir/patch-fetch-request-body/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { ReactNode } from 'react'
export default function Root({ children }: { children: ReactNode }) {
return (
<html>
<body>{children}</body>
</html>
)
}
3 changes: 3 additions & 0 deletions test/e2e/app-dir/patch-fetch-request-body/app/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default function Page() {
return <p>hello world</p>
}
6 changes: 6 additions & 0 deletions test/e2e/app-dir/patch-fetch-request-body/next.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
/**
* @type {import('next').NextConfig}
*/
const nextConfig = {}

module.exports = nextConfig
Original file line number Diff line number Diff line change
@@ -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<void>((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)
})
})