Allow reading request bodies in middlewares (#34294)
Related: - resolves #30953
This commit is contained in:
parent
ba78437cff
commit
1edd8519d6
7 changed files with 256 additions and 12 deletions
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
87
packages/next/server/body-streams.ts
Normal file
87
packages/next/server/body-streams.ts
Normal 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
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
144
test/production/reading-request-body-in-middleware/index.test.ts
Normal file
144
test/production/reading-request-body-in-middleware/index.test.ts
Normal 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')
|
||||||
|
})
|
||||||
|
})
|
|
@ -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==
|
||||||
|
|
Loading…
Reference in a new issue