Skip to content

Commit 1edd851

Browse files
authored
Allow reading request bodies in middlewares (#34294)
Related: - resolves #30953
1 parent ba78437 commit 1edd851

File tree

7 files changed

+256
-12
lines changed

7 files changed

+256
-12
lines changed

packages/next/server/base-http/node.ts

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,11 @@ import { NEXT_REQUEST_META, RequestMeta } from '../request-meta'
77

88
import { BaseNextRequest, BaseNextResponse } from './index'
99

10+
type Req = IncomingMessage & {
11+
[NEXT_REQUEST_META]?: RequestMeta
12+
cookies?: NextApiRequestCookies
13+
}
14+
1015
export class NodeNextRequest extends BaseNextRequest<Readable> {
1116
public headers = this._req.headers;
1217

@@ -21,12 +26,11 @@ export class NodeNextRequest extends BaseNextRequest<Readable> {
2126
return this._req
2227
}
2328

24-
constructor(
25-
private _req: IncomingMessage & {
26-
[NEXT_REQUEST_META]?: RequestMeta
27-
cookies?: NextApiRequestCookies
28-
}
29-
) {
29+
set originalRequest(value: Req) {
30+
this._req = value
31+
}
32+
33+
constructor(private _req: Req) {
3034
super(_req.method!.toUpperCase(), _req.url!, _req)
3135
}
3236

packages/next/server/body-streams.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import type { IncomingMessage } from 'http'
2+
import { Readable } from 'stream'
3+
import { TransformStream } from 'next/dist/compiled/web-streams-polyfill'
4+
5+
type BodyStream = ReadableStream<Uint8Array>
6+
7+
/**
8+
* Creates a ReadableStream from a Node.js HTTP request
9+
*/
10+
function requestToBodyStream(request: IncomingMessage): BodyStream {
11+
const transform = new TransformStream<Uint8Array, Uint8Array>({
12+
start(controller) {
13+
request.on('data', (chunk) => controller.enqueue(chunk))
14+
request.on('end', () => controller.terminate())
15+
request.on('error', (err) => controller.error(err))
16+
},
17+
})
18+
19+
return transform.readable as unknown as ReadableStream<Uint8Array>
20+
}
21+
22+
function bodyStreamToNodeStream(bodyStream: BodyStream): Readable {
23+
const reader = bodyStream.getReader()
24+
return Readable.from(
25+
(async function* () {
26+
while (true) {
27+
const { done, value } = await reader.read()
28+
if (done) {
29+
return
30+
}
31+
yield value
32+
}
33+
})()
34+
)
35+
}
36+
37+
function replaceRequestBody<T extends IncomingMessage>(
38+
base: T,
39+
stream: Readable
40+
): T {
41+
for (const key in stream) {
42+
let v = stream[key as keyof Readable] as any
43+
if (typeof v === 'function') {
44+
v = v.bind(stream)
45+
}
46+
base[key as keyof T] = v
47+
}
48+
49+
return base
50+
}
51+
52+
/**
53+
* An interface that encapsulates body stream cloning
54+
* of an incoming request.
55+
*/
56+
export function clonableBodyForRequest<T extends IncomingMessage>(
57+
incomingMessage: T
58+
) {
59+
let bufferedBodyStream: BodyStream | null = null
60+
61+
return {
62+
/**
63+
* Replaces the original request body if necessary.
64+
* This is done because once we read the body from the original request,
65+
* we can't read it again.
66+
*/
67+
finalize(): void {
68+
if (bufferedBodyStream) {
69+
replaceRequestBody(
70+
incomingMessage,
71+
bodyStreamToNodeStream(bufferedBodyStream)
72+
)
73+
}
74+
},
75+
/**
76+
* Clones the body stream
77+
* to pass into a middleware
78+
*/
79+
cloneBodyStream(): BodyStream {
80+
const originalStream =
81+
bufferedBodyStream ?? requestToBodyStream(incomingMessage)
82+
const [stream1, stream2] = originalStream.tee()
83+
bufferedBodyStream = stream1
84+
return stream2
85+
},
86+
}
87+
}

packages/next/server/next-server.ts

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ import { PagesManifest } from '../build/webpack/plugins/pages-manifest-plugin'
3838
import { recursiveReadDirSync } from './lib/recursive-readdir-sync'
3939
import { format as formatUrl, UrlWithParsedQuery } from 'url'
4040
import compression from 'next/dist/compiled/compression'
41-
import Proxy from 'next/dist/compiled/http-proxy'
41+
import HttpProxy from 'next/dist/compiled/http-proxy'
4242
import { route } from './router'
4343
import { run } from './web/sandbox'
4444

@@ -73,6 +73,7 @@ import { loadEnvConfig } from '@next/env'
7373
import { getCustomRoute } from './server-route-utils'
7474
import { urlQueryToSearchParams } from '../shared/lib/router/utils/querystring'
7575
import ResponseCache from '../server/response-cache'
76+
import { clonableBodyForRequest } from './body-streams'
7677

7778
export * from './base-server'
7879

@@ -485,7 +486,7 @@ export default class NextNodeServer extends BaseServer {
485486
parsedUrl.search = stringifyQuery(req, query)
486487

487488
const target = formatUrl(parsedUrl)
488-
const proxy = new Proxy({
489+
const proxy = new HttpProxy({
489490
target,
490491
changeOrigin: true,
491492
ignorePath: true,
@@ -1236,6 +1237,11 @@ export default class NextNodeServer extends BaseServer {
12361237

12371238
const allHeaders = new Headers()
12381239
let result: FetchEventResult | null = null
1240+
const method = (params.request.method || 'GET').toUpperCase()
1241+
let originalBody =
1242+
method !== 'GET' && method !== 'HEAD'
1243+
? clonableBodyForRequest(params.request.body)
1244+
: undefined
12391245

12401246
for (const middleware of this.middleware || []) {
12411247
if (middleware.match(params.parsedUrl.pathname)) {
@@ -1245,7 +1251,6 @@ export default class NextNodeServer extends BaseServer {
12451251
}
12461252

12471253
await this.ensureMiddleware(middleware.page, middleware.ssr)
1248-
12491254
const middlewareInfo = this.getMiddlewareInfo(middleware.page)
12501255

12511256
result = await run({
@@ -1254,14 +1259,15 @@ export default class NextNodeServer extends BaseServer {
12541259
env: middlewareInfo.env,
12551260
request: {
12561261
headers: params.request.headers,
1257-
method: params.request.method || 'GET',
1262+
method,
12581263
nextConfig: {
12591264
basePath: this.nextConfig.basePath,
12601265
i18n: this.nextConfig.i18n,
12611266
trailingSlash: this.nextConfig.trailingSlash,
12621267
},
12631268
url: url,
12641269
page: page,
1270+
body: originalBody?.cloneBodyStream(),
12651271
},
12661272
useCache: !this.nextConfig.experimental.runtime,
12671273
onWarning: (warning: Error) => {
@@ -1298,6 +1304,8 @@ export default class NextNodeServer extends BaseServer {
12981304
}
12991305
}
13001306

1307+
originalBody?.finalize()
1308+
13011309
return result
13021310
}
13031311

packages/next/server/web/adapter.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ export async function adapter(params: {
1616
page: params.page,
1717
input: params.request.url,
1818
init: {
19+
body: params.request.body,
1920
geo: params.request.geo,
2021
headers: fromNodeHeaders(params.request.headers),
2122
ip: params.request.ip,

packages/next/server/web/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ export interface RequestData {
3939
params?: { [key: string]: string }
4040
}
4141
url: string
42+
body?: ReadableStream<Uint8Array>
4243
}
4344

4445
export interface FetchEventResult {
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
import { createNext } from 'e2e-utils'
2+
import { NextInstance } from 'test/lib/next-modes/base'
3+
import { fetchViaHTTP } from 'next-test-utils'
4+
5+
describe('reading request body in middleware', () => {
6+
let next: NextInstance
7+
8+
beforeAll(async () => {
9+
next = await createNext({
10+
files: {
11+
'pages/_middleware.js': `
12+
const { NextResponse } = require('next/server');
13+
14+
export default async function middleware(request) {
15+
if (!request.body) {
16+
return new Response('No body', { status: 400 });
17+
}
18+
19+
const json = await request.json();
20+
21+
if (request.nextUrl.searchParams.has("next")) {
22+
const res = NextResponse.next();
23+
res.headers.set('x-from-root-middleware', '1');
24+
return res;
25+
}
26+
27+
return new Response(JSON.stringify({
28+
root: true,
29+
...json,
30+
}), {
31+
status: 200,
32+
headers: {
33+
'content-type': 'application/json',
34+
},
35+
})
36+
}
37+
`,
38+
39+
'pages/nested/_middleware.js': `
40+
const { NextResponse } = require('next/server');
41+
42+
export default async function middleware(request) {
43+
if (!request.body) {
44+
return new Response('No body', { status: 400 });
45+
}
46+
47+
const json = await request.json();
48+
49+
return new Response(JSON.stringify({
50+
root: false,
51+
...json,
52+
}), {
53+
status: 200,
54+
headers: {
55+
'content-type': 'application/json',
56+
},
57+
})
58+
}
59+
`,
60+
61+
'pages/api/hi.js': `
62+
export default function hi(req, res) {
63+
res.json({
64+
...req.body,
65+
api: true,
66+
})
67+
}
68+
`,
69+
},
70+
dependencies: {},
71+
})
72+
})
73+
afterAll(() => next.destroy())
74+
75+
it('rejects with 400 for get requests', async () => {
76+
const response = await fetchViaHTTP(next.url, '/')
77+
expect(response.status).toEqual(400)
78+
})
79+
80+
it('returns root: true for root calls', async () => {
81+
const response = await fetchViaHTTP(
82+
next.url,
83+
'/',
84+
{},
85+
{
86+
method: 'POST',
87+
body: JSON.stringify({
88+
foo: 'bar',
89+
}),
90+
}
91+
)
92+
expect(response.status).toEqual(200)
93+
expect(await response.json()).toEqual({
94+
foo: 'bar',
95+
root: true,
96+
})
97+
})
98+
99+
it('reads the same body on both middlewares', async () => {
100+
const response = await fetchViaHTTP(
101+
next.url,
102+
'/nested/hello',
103+
{
104+
next: '1',
105+
},
106+
{
107+
method: 'POST',
108+
body: JSON.stringify({
109+
foo: 'bar',
110+
}),
111+
}
112+
)
113+
expect(response.status).toEqual(200)
114+
expect(await response.json()).toEqual({
115+
foo: 'bar',
116+
root: false,
117+
})
118+
})
119+
120+
it('passes the body to the api endpoint', async () => {
121+
const response = await fetchViaHTTP(
122+
next.url,
123+
'/api/hi',
124+
{
125+
next: '1',
126+
},
127+
{
128+
method: 'POST',
129+
headers: {
130+
'content-type': 'application/json',
131+
},
132+
body: JSON.stringify({
133+
foo: 'bar',
134+
}),
135+
}
136+
)
137+
expect(response.status).toEqual(200)
138+
expect(await response.json()).toEqual({
139+
foo: 'bar',
140+
api: true,
141+
})
142+
expect(response.headers.get('x-from-root-middleware')).toEqual('1')
143+
})
144+
})

yarn.lock

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20812,8 +20812,7 @@ [email protected]:
2081220812
source-list-map "^2.0.0"
2081320813
source-map "~0.6.1"
2081420814

20815-
"webpack-sources3@npm:[email protected]", webpack-sources@^3.2.3:
20816-
name webpack-sources3
20815+
"webpack-sources3@npm:[email protected]", webpack-sources@^3.2.2, webpack-sources@^3.2.3:
2081720816
version "3.2.3"
2081820817
resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-3.2.3.tgz#2d4daab8451fd4b240cc27055ff6a0c2ccea0cde"
2081920818
integrity sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==

0 commit comments

Comments
 (0)