Skip to content

Commit

Permalink
Allow reading request bodies in middlewares
Browse files Browse the repository at this point in the history
  • Loading branch information
Schniz committed Feb 14, 2022
1 parent 7be6359 commit 0a841a5
Show file tree
Hide file tree
Showing 6 changed files with 179 additions and 3 deletions.
44 changes: 43 additions & 1 deletion packages/next/server/next-server.ts
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)) {
Expand All @@ -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)

Expand All @@ -1257,14 +1265,15 @@ 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,
trailingSlash: this.nextConfig.trailingSlash,
},
url: url,
page: page,
body: currentBody,
},
useCache: !this.nextConfig.experimental.runtime,
onWarning: (warning: Error) => {
Expand Down Expand Up @@ -1337,3 +1346,36 @@ export default class NextNodeServer extends BaseServer {
}
}
}

/**
* Creates a ReadableStream from a Node.js HTTP request
*/
function requestToBodyStream(
request: IncomingMessage
): ReadableStream<Uint8Array> {
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
}

/**
* A simple utility to take an original stream and have
* an API to duplicate it without closing it or mutate any variables
*/
function teeableStream<T>(originalStream: ReadableStream<T>): {
duplicate(): ReadableStream<T>
} {
return {
duplicate() {
const [stream1, stream2] = originalStream.tee()
originalStream = stream1
return stream2
},
}
}
1 change: 1 addition & 0 deletions packages/next/server/web/adapter.ts
Expand Up @@ -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<Uint8Array>,
geo: params.request.geo,
headers: fromNodeHeaders(params.request.headers),
ip: params.request.ip,
Expand Down
2 changes: 2 additions & 0 deletions 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'
Expand Down Expand Up @@ -39,6 +40,7 @@ export interface RequestData {
params?: { [key: string]: string }
}
url: string
body?: ReadableStream<Uint8Array>
}

export interface FetchEventResult {
Expand Down
5 changes: 5 additions & 0 deletions packages/next/types/misc.d.ts
Expand Up @@ -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'

Expand Down
127 changes: 127 additions & 0 deletions 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,
})
})
})
3 changes: 1 addition & 2 deletions yarn.lock
Expand Up @@ -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==
Expand Down

0 comments on commit 0a841a5

Please sign in to comment.