diff --git a/packages/next/server/next-server.ts b/packages/next/server/next-server.ts index ad0bdca7a0ab06c..365299445175b3d 100644 --- a/packages/next/server/next-server.ts +++ b/packages/next/server/next-server.ts @@ -8,6 +8,8 @@ import type { ParsedNextUrl } from '../shared/lib/router/utils/parse-next-url' import type { PrerenderManifest } from '../build' import type { Rewrite } from '../lib/load-custom-routes' import type { BaseNextRequest, BaseNextResponse } from './base-http' +import type { ReadableStream } from 'next/dist/compiled/web-streams-polyfill/ponyfill' +import { TransformStream } from 'next/dist/compiled/web-streams-polyfill/ponyfill' import { execOnce } from '../shared/lib/utils' import { @@ -1239,6 +1241,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' + ? teeableStream(requestToBodyStream(params.request.body)) + : undefined for (const middleware of this.middleware || []) { if (middleware.match(params.parsedUrl.pathname)) { @@ -1248,6 +1255,7 @@ export default class NextNodeServer extends BaseServer { } await this.ensureMiddleware(middleware.page, middleware.ssr) + const currentBody = originalBody?.duplicate() const middlewareInfo = this.getMiddlewareInfo(middleware.page) @@ -1257,7 +1265,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, @@ -1265,6 +1273,7 @@ export default class NextNodeServer extends BaseServer { }, url: url, page: page, + body: currentBody, }, useCache: !this.nextConfig.experimental.runtime, onWarning: (warning: Error) => { @@ -1337,3 +1346,36 @@ export default class NextNodeServer extends BaseServer { } } } + +/** + * Creates a ReadableStream from a Node.js HTTP request + */ +function requestToBodyStream( + request: IncomingMessage +): ReadableStream { + 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 +} + +/** + * A simple utility to take an original stream and have + * an API to duplicate it without closing it or mutate any variables + */ +function teeableStream(originalStream: ReadableStream): { + duplicate(): ReadableStream +} { + return { + duplicate() { + const [stream1, stream2] = originalStream.tee() + originalStream = stream1 + return stream2 + }, + } +} diff --git a/packages/next/server/web/adapter.ts b/packages/next/server/web/adapter.ts index ff7f3559453c35c..e7760db808e40aa 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 as unknown as ReadableStream, 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..a8315e74e5dc6f1 100644 --- a/packages/next/server/web/types.ts +++ b/packages/next/server/web/types.ts @@ -1,4 +1,5 @@ import type { I18NConfig } from '../config-shared' +import type { ReadableStream } from 'next/dist/compiled/web-streams-polyfill/ponyfill' import type { NextRequest } from '../web/spec-extension/request' import type { NextFetchEvent } from '../web/spec-extension/fetch-event' import type { NextResponse } from './spec-extension/response' @@ -39,6 +40,7 @@ export interface RequestData { params?: { [key: string]: string } } url: string + body?: ReadableStream } export interface FetchEventResult { diff --git a/packages/next/types/misc.d.ts b/packages/next/types/misc.d.ts index 555d3ad83d6e763..8f283c6606f1fa4 100644 --- a/packages/next/types/misc.d.ts +++ b/packages/next/types/misc.d.ts @@ -331,6 +331,11 @@ declare module 'next/dist/compiled/comment-json' { export = m } +declare module 'next/dist/compiled/web-streams-polyfill/ponyfill' { + import m from 'web-streams-polyfill/ponyfill' + export = m +} + declare module 'pnp-webpack-plugin' { import webpack from 'webpack4' 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..c0c54eef74e6bda --- /dev/null +++ b/test/production/reading-request-body-in-middleware/index.test.ts @@ -0,0 +1,127 @@ +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: { + 'src/readBody.js': ` + export async function readBody(reader, input = reader.read(), body = "") { + const { value, done } = await input; + const inputText = new TextDecoder().decode(value); + body += inputText; + if (done) { + return body; + } + const next = await reader.read(); + return readBody(reader, next, body); + } + `, + + 'pages/_middleware.js': ` + const { NextResponse } = require('next/server'); + import { readBody } from '../src/readBody'; + + export default async function middleware(request) { + if (!request.body) { + return new Response('No body', { status: 400 }); + } + + const reader = await request.body.getReader(); + const body = await readBody(reader); + const json = JSON.parse(body); + + if (request.nextUrl.searchParams.has("next")) { + return NextResponse.next(); + } + + return new Response(JSON.stringify({ + root: true, + ...json, + }), { + status: 200, + headers: { + 'content-type': 'application/json', + }, + }) + } + `, + + 'pages/nested/_middleware.js': ` + const { NextResponse } = require('next/server'); + import { readBody } from '../../src/readBody'; + + export default async function middleware(request) { + if (!request.body) { + return new Response('No body', { status: 400 }); + } + + const reader = await request.body.getReader(); + const body = await readBody(reader); + const json = JSON.parse(body); + + return new Response(JSON.stringify({ + root: false, + ...json, + }), { + status: 200, + headers: { + 'content-type': 'application/json', + }, + }) + } + `, + }, + 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, + }) + }) +}) diff --git a/yarn.lock b/yarn.lock index f557f48cfb93824..ae1696cf33c32e5 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==