Allow reading request bodies in middlewares (#34294)

Related:

- resolves #30953
This commit is contained in:
Gal Schlezinger 2022-02-17 13:32:36 +02:00 committed by GitHub
parent ba78437cff
commit 1edd8519d6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 256 additions and 12 deletions

View file

@ -7,6 +7,11 @@ import { NEXT_REQUEST_META, RequestMeta } from '../request-meta'
import { BaseNextRequest, BaseNextResponse } from './index' import { BaseNextRequest, BaseNextResponse } from './index'
type Req = IncomingMessage & {
[NEXT_REQUEST_META]?: RequestMeta
cookies?: NextApiRequestCookies
}
export class NodeNextRequest extends BaseNextRequest<Readable> { export class NodeNextRequest extends BaseNextRequest<Readable> {
public headers = this._req.headers; public headers = this._req.headers;
@ -21,12 +26,11 @@ export class NodeNextRequest extends BaseNextRequest<Readable> {
return this._req return this._req
} }
constructor( set originalRequest(value: Req) {
private _req: IncomingMessage & { this._req = value
[NEXT_REQUEST_META]?: RequestMeta }
cookies?: NextApiRequestCookies
} constructor(private _req: Req) {
) {
super(_req.method!.toUpperCase(), _req.url!, _req) super(_req.method!.toUpperCase(), _req.url!, _req)
} }

View file

@ -0,0 +1,87 @@
import type { IncomingMessage } from 'http'
import { Readable } from 'stream'
import { TransformStream } from 'next/dist/compiled/web-streams-polyfill'
type BodyStream = ReadableStream<Uint8Array>
/**
* Creates a ReadableStream from a Node.js HTTP request
*/
function requestToBodyStream(request: IncomingMessage): BodyStream {
const transform = new TransformStream<Uint8Array, Uint8Array>({
start(controller) {
request.on('data', (chunk) => controller.enqueue(chunk))
request.on('end', () => controller.terminate())
request.on('error', (err) => controller.error(err))
},
})
return transform.readable as unknown as ReadableStream<Uint8Array>
}
function bodyStreamToNodeStream(bodyStream: BodyStream): Readable {
const reader = bodyStream.getReader()
return Readable.from(
(async function* () {
while (true) {
const { done, value } = await reader.read()
if (done) {
return
}
yield value
}
})()
)
}
function replaceRequestBody<T extends IncomingMessage>(
base: T,
stream: Readable
): T {
for (const key in stream) {
let v = stream[key as keyof Readable] as any
if (typeof v === 'function') {
v = v.bind(stream)
}
base[key as keyof T] = v
}
return base
}
/**
* An interface that encapsulates body stream cloning
* of an incoming request.
*/
export function clonableBodyForRequest<T extends IncomingMessage>(
incomingMessage: T
) {
let bufferedBodyStream: BodyStream | null = null
return {
/**
* Replaces the original request body if necessary.
* This is done because once we read the body from the original request,
* we can't read it again.
*/
finalize(): void {
if (bufferedBodyStream) {
replaceRequestBody(
incomingMessage,
bodyStreamToNodeStream(bufferedBodyStream)
)
}
},
/**
* Clones the body stream
* to pass into a middleware
*/
cloneBodyStream(): BodyStream {
const originalStream =
bufferedBodyStream ?? requestToBodyStream(incomingMessage)
const [stream1, stream2] = originalStream.tee()
bufferedBodyStream = stream1
return stream2
},
}
}

View file

@ -38,7 +38,7 @@ import { PagesManifest } from '../build/webpack/plugins/pages-manifest-plugin'
import { recursiveReadDirSync } from './lib/recursive-readdir-sync' import { recursiveReadDirSync } from './lib/recursive-readdir-sync'
import { format as formatUrl, UrlWithParsedQuery } from 'url' import { format as formatUrl, UrlWithParsedQuery } from 'url'
import compression from 'next/dist/compiled/compression' import compression from 'next/dist/compiled/compression'
import Proxy from 'next/dist/compiled/http-proxy' import HttpProxy from 'next/dist/compiled/http-proxy'
import { route } from './router' import { route } from './router'
import { run } from './web/sandbox' import { run } from './web/sandbox'
@ -73,6 +73,7 @@ import { loadEnvConfig } from '@next/env'
import { getCustomRoute } from './server-route-utils' import { getCustomRoute } from './server-route-utils'
import { urlQueryToSearchParams } from '../shared/lib/router/utils/querystring' import { urlQueryToSearchParams } from '../shared/lib/router/utils/querystring'
import ResponseCache from '../server/response-cache' import ResponseCache from '../server/response-cache'
import { clonableBodyForRequest } from './body-streams'
export * from './base-server' export * from './base-server'
@ -485,7 +486,7 @@ export default class NextNodeServer extends BaseServer {
parsedUrl.search = stringifyQuery(req, query) parsedUrl.search = stringifyQuery(req, query)
const target = formatUrl(parsedUrl) const target = formatUrl(parsedUrl)
const proxy = new Proxy({ const proxy = new HttpProxy({
target, target,
changeOrigin: true, changeOrigin: true,
ignorePath: true, ignorePath: true,
@ -1236,6 +1237,11 @@ export default class NextNodeServer extends BaseServer {
const allHeaders = new Headers() const allHeaders = new Headers()
let result: FetchEventResult | null = null let result: FetchEventResult | null = null
const method = (params.request.method || 'GET').toUpperCase()
let originalBody =
method !== 'GET' && method !== 'HEAD'
? clonableBodyForRequest(params.request.body)
: undefined
for (const middleware of this.middleware || []) { for (const middleware of this.middleware || []) {
if (middleware.match(params.parsedUrl.pathname)) { if (middleware.match(params.parsedUrl.pathname)) {
@ -1245,7 +1251,6 @@ export default class NextNodeServer extends BaseServer {
} }
await this.ensureMiddleware(middleware.page, middleware.ssr) await this.ensureMiddleware(middleware.page, middleware.ssr)
const middlewareInfo = this.getMiddlewareInfo(middleware.page) const middlewareInfo = this.getMiddlewareInfo(middleware.page)
result = await run({ result = await run({
@ -1254,7 +1259,7 @@ export default class NextNodeServer extends BaseServer {
env: middlewareInfo.env, env: middlewareInfo.env,
request: { request: {
headers: params.request.headers, headers: params.request.headers,
method: params.request.method || 'GET', method,
nextConfig: { nextConfig: {
basePath: this.nextConfig.basePath, basePath: this.nextConfig.basePath,
i18n: this.nextConfig.i18n, i18n: this.nextConfig.i18n,
@ -1262,6 +1267,7 @@ export default class NextNodeServer extends BaseServer {
}, },
url: url, url: url,
page: page, page: page,
body: originalBody?.cloneBodyStream(),
}, },
useCache: !this.nextConfig.experimental.runtime, useCache: !this.nextConfig.experimental.runtime,
onWarning: (warning: Error) => { onWarning: (warning: Error) => {
@ -1298,6 +1304,8 @@ export default class NextNodeServer extends BaseServer {
} }
} }
originalBody?.finalize()
return result return result
} }

View file

@ -16,6 +16,7 @@ export async function adapter(params: {
page: params.page, page: params.page,
input: params.request.url, input: params.request.url,
init: { init: {
body: params.request.body,
geo: params.request.geo, geo: params.request.geo,
headers: fromNodeHeaders(params.request.headers), headers: fromNodeHeaders(params.request.headers),
ip: params.request.ip, ip: params.request.ip,

View file

@ -39,6 +39,7 @@ export interface RequestData {
params?: { [key: string]: string } params?: { [key: string]: string }
} }
url: string url: string
body?: ReadableStream<Uint8Array>
} }
export interface FetchEventResult { export interface FetchEventResult {

View file

@ -0,0 +1,144 @@
import { createNext } from 'e2e-utils'
import { NextInstance } from 'test/lib/next-modes/base'
import { fetchViaHTTP } from 'next-test-utils'
describe('reading request body in middleware', () => {
let next: NextInstance
beforeAll(async () => {
next = await createNext({
files: {
'pages/_middleware.js': `
const { NextResponse } = require('next/server');
export default async function middleware(request) {
if (!request.body) {
return new Response('No body', { status: 400 });
}
const json = await request.json();
if (request.nextUrl.searchParams.has("next")) {
const res = NextResponse.next();
res.headers.set('x-from-root-middleware', '1');
return res;
}
return new Response(JSON.stringify({
root: true,
...json,
}), {
status: 200,
headers: {
'content-type': 'application/json',
},
})
}
`,
'pages/nested/_middleware.js': `
const { NextResponse } = require('next/server');
export default async function middleware(request) {
if (!request.body) {
return new Response('No body', { status: 400 });
}
const json = await request.json();
return new Response(JSON.stringify({
root: false,
...json,
}), {
status: 200,
headers: {
'content-type': 'application/json',
},
})
}
`,
'pages/api/hi.js': `
export default function hi(req, res) {
res.json({
...req.body,
api: true,
})
}
`,
},
dependencies: {},
})
})
afterAll(() => next.destroy())
it('rejects with 400 for get requests', async () => {
const response = await fetchViaHTTP(next.url, '/')
expect(response.status).toEqual(400)
})
it('returns root: true for root calls', async () => {
const response = await fetchViaHTTP(
next.url,
'/',
{},
{
method: 'POST',
body: JSON.stringify({
foo: 'bar',
}),
}
)
expect(response.status).toEqual(200)
expect(await response.json()).toEqual({
foo: 'bar',
root: true,
})
})
it('reads the same body on both middlewares', async () => {
const response = await fetchViaHTTP(
next.url,
'/nested/hello',
{
next: '1',
},
{
method: 'POST',
body: JSON.stringify({
foo: 'bar',
}),
}
)
expect(response.status).toEqual(200)
expect(await response.json()).toEqual({
foo: 'bar',
root: false,
})
})
it('passes the body to the api endpoint', async () => {
const response = await fetchViaHTTP(
next.url,
'/api/hi',
{
next: '1',
},
{
method: 'POST',
headers: {
'content-type': 'application/json',
},
body: JSON.stringify({
foo: 'bar',
}),
}
)
expect(response.status).toEqual(200)
expect(await response.json()).toEqual({
foo: 'bar',
api: true,
})
expect(response.headers.get('x-from-root-middleware')).toEqual('1')
})
})

View file

@ -20812,8 +20812,7 @@ webpack-bundle-analyzer@4.3.0:
source-list-map "^2.0.0" source-list-map "^2.0.0"
source-map "~0.6.1" source-map "~0.6.1"
"webpack-sources3@npm:webpack-sources@3.2.3", webpack-sources@^3.2.3: "webpack-sources3@npm:webpack-sources@3.2.3", webpack-sources@^3.2.2, webpack-sources@^3.2.3:
name webpack-sources3
version "3.2.3" version "3.2.3"
resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-3.2.3.tgz#2d4daab8451fd4b240cc27055ff6a0c2ccea0cde" resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-3.2.3.tgz#2d4daab8451fd4b240cc27055ff6a0c2ccea0cde"
integrity sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w== integrity sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==