diff --git a/packages/next/server/base-http/node.ts b/packages/next/server/base-http/node.ts index 5d5c54ce0064330..d1d720d8098987b 100644 --- a/packages/next/server/base-http/node.ts +++ b/packages/next/server/base-http/node.ts @@ -7,6 +7,11 @@ import { NEXT_REQUEST_META, RequestMeta } from '../request-meta' import { BaseNextRequest, BaseNextResponse } from './index' +type Req = IncomingMessage & { + [NEXT_REQUEST_META]?: RequestMeta + cookies?: NextApiRequestCookies +} + export class NodeNextRequest extends BaseNextRequest { public headers = this._req.headers; @@ -21,12 +26,11 @@ export class NodeNextRequest extends BaseNextRequest { return this._req } - constructor( - private _req: IncomingMessage & { - [NEXT_REQUEST_META]?: RequestMeta - cookies?: NextApiRequestCookies - } - ) { + set originalRequest(value: Req) { + this._req = value + } + + constructor(private _req: Req) { super(_req.method!.toUpperCase(), _req.url!, _req) } diff --git a/packages/next/server/body-streams.ts b/packages/next/server/body-streams.ts new file mode 100644 index 000000000000000..5ce9a0b3abde2f8 --- /dev/null +++ b/packages/next/server/body-streams.ts @@ -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 + +/** + * Creates a ReadableStream from a Node.js HTTP request + */ +function requestToBodyStream(request: IncomingMessage): BodyStream { + const transform = new TransformStream({ + 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 +} + +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( + 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( + 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 + }, + } +} diff --git a/packages/next/server/next-server.ts b/packages/next/server/next-server.ts index 4c7779481822445..b3c1dcb5cb9e9ed 100644 --- a/packages/next/server/next-server.ts +++ b/packages/next/server/next-server.ts @@ -38,7 +38,7 @@ import { PagesManifest } from '../build/webpack/plugins/pages-manifest-plugin' import { recursiveReadDirSync } from './lib/recursive-readdir-sync' import { format as formatUrl, UrlWithParsedQuery } from 'url' 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 { run } from './web/sandbox' @@ -73,6 +73,7 @@ import { loadEnvConfig } from '@next/env' import { getCustomRoute } from './server-route-utils' import { urlQueryToSearchParams } from '../shared/lib/router/utils/querystring' import ResponseCache from '../server/response-cache' +import { clonableBodyForRequest } from './body-streams' export * from './base-server' @@ -485,7 +486,7 @@ export default class NextNodeServer extends BaseServer { parsedUrl.search = stringifyQuery(req, query) const target = formatUrl(parsedUrl) - const proxy = new Proxy({ + const proxy = new HttpProxy({ target, changeOrigin: true, ignorePath: true, @@ -1236,6 +1237,11 @@ export default class NextNodeServer extends BaseServer { const allHeaders = new Headers() 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 || []) { if (middleware.match(params.parsedUrl.pathname)) { @@ -1245,7 +1251,6 @@ export default class NextNodeServer extends BaseServer { } await this.ensureMiddleware(middleware.page, middleware.ssr) - const middlewareInfo = this.getMiddlewareInfo(middleware.page) result = await run({ @@ -1254,7 +1259,7 @@ export default class NextNodeServer extends BaseServer { env: middlewareInfo.env, request: { headers: params.request.headers, - method: params.request.method || 'GET', + method, nextConfig: { basePath: this.nextConfig.basePath, i18n: this.nextConfig.i18n, @@ -1262,6 +1267,7 @@ export default class NextNodeServer extends BaseServer { }, url: url, page: page, + body: originalBody?.cloneBodyStream(), }, useCache: !this.nextConfig.experimental.runtime, onWarning: (warning: Error) => { @@ -1298,6 +1304,8 @@ export default class NextNodeServer extends BaseServer { } } + originalBody?.finalize() + return result } diff --git a/packages/next/server/web/adapter.ts b/packages/next/server/web/adapter.ts index ff7f3559453c35c..6252ea738f5cc1f 100644 --- a/packages/next/server/web/adapter.ts +++ b/packages/next/server/web/adapter.ts @@ -16,6 +16,7 @@ export async function adapter(params: { page: params.page, input: params.request.url, init: { + body: params.request.body, geo: params.request.geo, headers: fromNodeHeaders(params.request.headers), ip: params.request.ip, diff --git a/packages/next/server/web/types.ts b/packages/next/server/web/types.ts index 5a6d48fde63b057..029a1024d462018 100644 --- a/packages/next/server/web/types.ts +++ b/packages/next/server/web/types.ts @@ -39,6 +39,7 @@ export interface RequestData { params?: { [key: string]: string } } url: string + body?: ReadableStream } export interface FetchEventResult { diff --git a/test/production/reading-request-body-in-middleware/index.test.ts b/test/production/reading-request-body-in-middleware/index.test.ts new file mode 100644 index 000000000000000..0f1d61ccfa92ee7 --- /dev/null +++ b/test/production/reading-request-body-in-middleware/index.test.ts @@ -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') + }) +}) diff --git a/yarn.lock b/yarn.lock index 1ee4e9b20c7e89b..3b2318f55b1a45a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -20812,8 +20812,7 @@ webpack-bundle-analyzer@4.3.0: source-list-map "^2.0.0" source-map "~0.6.1" -"webpack-sources3@npm:webpack-sources@3.2.3", webpack-sources@^3.2.3: - name webpack-sources3 +"webpack-sources3@npm:webpack-sources@3.2.3", webpack-sources@^3.2.2, webpack-sources@^3.2.3: version "3.2.3" resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-3.2.3.tgz#2d4daab8451fd4b240cc27055ff6a0c2ccea0cde" integrity sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==