diff --git a/packages/next/server/dev/next-dev-server.ts b/packages/next/server/dev/next-dev-server.ts index 7e971f6eb89afde..5912772b10a58e2 100644 --- a/packages/next/server/dev/next-dev-server.ts +++ b/packages/next/server/dev/next-dev-server.ts @@ -475,9 +475,6 @@ export default class DevServer extends Server { }): Promise { try { const result = await super.runMiddleware(params) - result?.promise.catch((error) => - this.logErrorWithOriginalStack(error, 'unhandledRejection', 'client') - ) result?.waitUntil.catch((error) => this.logErrorWithOriginalStack(error, 'unhandledRejection', 'client') ) diff --git a/packages/next/server/next-server.ts b/packages/next/server/next-server.ts index 0cc26446adb206b..0d9149777e1f863 100644 --- a/packages/next/server/next-server.ts +++ b/packages/next/server/next-server.ts @@ -102,6 +102,7 @@ import isError from '../lib/is-error' import { getMiddlewareInfo } from './require' import { parseUrl as simpleParseUrl } from '../shared/lib/router/utils/parse-url' import { MIDDLEWARE_ROUTE } from '../lib/constants' +import { NextResponse } from './web/spec-extension/response' import { run } from './web/sandbox' import type { FetchEventResult } from './web/types' import type { MiddlewareManifest } from '../build/webpack/plugins/middleware-plugin' @@ -621,6 +622,9 @@ export default class Server { } } + const subreq = params.request.headers[`x-middleware-subrequest`] + const subrequests = typeof subreq === 'string' ? subreq.split(':') : [] + let result: FetchEventResult | null = null for (const middleware of this.middleware || []) { @@ -639,6 +643,14 @@ export default class Server { serverless: this._isLikeServerless, }) + if (subrequests.includes(middlewareInfo.name)) { + result = { + response: NextResponse.next(), + waitUntil: Promise.resolve(), + } + continue + } + result = await run({ name: middlewareInfo.name, paths: middlewareInfo.paths, @@ -656,10 +668,6 @@ export default class Server { }) if (!this.renderOpts.dev) { - result.promise.catch((error) => { - console.error(`Uncaught: middleware error after responding`, error) - }) - result.waitUntil.catch((error) => { console.error(`Uncaught: middleware waitUntil errored`, error) }) diff --git a/packages/next/server/web/adapter.ts b/packages/next/server/web/adapter.ts index e723b7eca50feeb..32e210c4c5921a9 100644 --- a/packages/next/server/web/adapter.ts +++ b/packages/next/server/web/adapter.ts @@ -1,36 +1,51 @@ import type { RequestData, FetchEventResult } from './types' - +import { DeprecationError } from './error' import { fromNodeHeaders } from './utils' import { NextFetchEvent } from './spec-extension/fetch-event' -import { NextRequest } from './spec-extension/request' +import { NextRequest, RequestInit } from './spec-extension/request' import { NextResponse } from './spec-extension/response' -import { waitUntilSymbol, responseSymbol } from './spec-compliant/fetch-event' +import { waitUntilSymbol } from './spec-compliant/fetch-event' export async function adapter(params: { - handler: (event: NextFetchEvent) => void | Promise + handler: (request: NextRequest, event: NextFetchEvent) => Promise request: RequestData }): Promise { const url = params.request.url.startsWith('/') ? `https://${params.request.headers.host}${params.request.url}` : params.request.url - const event = new NextFetchEvent( - new NextRequest(url, { - geo: params.request.geo, - headers: fromNodeHeaders(params.request.headers), - ip: params.request.ip, - method: params.request.method, - nextConfig: params.request.nextConfig, - page: params.request.page, - }) - ) + const request = new NextRequestHint(url, { + geo: params.request.geo, + headers: fromNodeHeaders(params.request.headers), + ip: params.request.ip, + method: params.request.method, + nextConfig: params.request.nextConfig, + page: params.request.page, + }) - const handled = params.handler(event) - const original = await event[responseSymbol] + const event = new NextFetchEvent(request) + const original = await params.handler(request, event) return { - promise: Promise.resolve(handled), response: original || NextResponse.next(), waitUntil: Promise.all(event[waitUntilSymbol]), } } + +class NextRequestHint extends NextRequest { + constructor(input: Request | string, init: RequestInit = {}) { + super(input, init) + } + + get request() { + throw new DeprecationError() + } + + respondWith() { + throw new DeprecationError() + } + + waitUntil() { + throw new DeprecationError() + } +} diff --git a/packages/next/server/web/error.ts b/packages/next/server/web/error.ts new file mode 100644 index 000000000000000..8efcdfb0836444b --- /dev/null +++ b/packages/next/server/web/error.ts @@ -0,0 +1,10 @@ +export class DeprecationError extends Error { + constructor() { + super(`Middleware now accepts an async API directly with the form: + + export function middleware(request, event) { + return new Response("Hello " + request.url) + } + `) + } +} diff --git a/packages/next/server/web/sandbox/sandbox.ts b/packages/next/server/web/sandbox/sandbox.ts index 51b95cb80c409ea..98eb9700bf8e0bf 100644 --- a/packages/next/server/web/sandbox/sandbox.ts +++ b/packages/next/server/web/sandbox/sandbox.ts @@ -1,4 +1,4 @@ -import type { RequestData, FetchEventResult } from '../types' +import type { RequestData, FetchEventResult, NodeHeaders } from '../types' import { Blob, File, FormData } from 'next/dist/compiled/formdata-node' import { dirname } from 'path' import { ReadableStream } from 'next/dist/compiled/web-streams-polyfill' @@ -57,7 +57,20 @@ export async function run(params: { }, Crypto: polyfills.Crypto, crypto: new polyfills.Crypto(), - fetch, + fetch: (input: RequestInfo, init: RequestInit = {}) => { + const url = getFetchURL(input, params.request.headers) + init.headers = getFetchHeaders(params.name, init) + if (isRequestLike(input)) { + return fetch(url, { + ...init, + headers: { + ...Object.fromEntries(input.headers), + ...Object.fromEntries(init.headers), + }, + }) + } + return fetch(url, init) + }, File, FormData, process: { env: { ...process.env } }, @@ -168,3 +181,29 @@ function sandboxRequire(referrer: string, specifier: string) { module.loaded = true return module.exports } + +function getFetchHeaders(middleware: string, init: RequestInit) { + const headers = new Headers(init.headers ?? {}) + const prevsub = headers.get(`x-middleware-subrequest`) || '' + const value = prevsub.split(':').concat(middleware).join(':') + headers.set(`x-middleware-subrequest`, value) + headers.set(`user-agent`, `Next.js Middleware`) + return headers +} + +function getFetchURL(input: RequestInfo, headers: NodeHeaders = {}): string { + const initurl = isRequestLike(input) ? input.url : input + if (initurl.startsWith('/')) { + const host = headers.host?.toString() + const localhost = + host === '127.0.0.1' || + host === 'localhost' || + host?.startsWith('localhost:') + return `${localhost ? 'http' : 'https'}://${host}${initurl}` + } + return initurl +} + +function isRequestLike(obj: unknown): obj is Request { + return Boolean(obj && typeof obj === 'object' && 'url' in obj) +} diff --git a/packages/next/server/web/spec-extension/fetch-event.ts b/packages/next/server/web/spec-extension/fetch-event.ts index 09613d33da53959..1d6d704ed3bdb85 100644 --- a/packages/next/server/web/spec-extension/fetch-event.ts +++ b/packages/next/server/web/spec-extension/fetch-event.ts @@ -1,3 +1,4 @@ +import { DeprecationError } from '../error' import { FetchEvent } from '../spec-compliant/fetch-event' import { NextRequest } from './request' @@ -7,4 +8,8 @@ export class NextFetchEvent extends FetchEvent { super(request) this.request = request } + + respondWith() { + throw new DeprecationError() + } } diff --git a/packages/next/server/web/spec-extension/request.ts b/packages/next/server/web/spec-extension/request.ts index 2a8e80f1b06f7c9..3bae487549cf53d 100644 --- a/packages/next/server/web/spec-extension/request.ts +++ b/packages/next/server/web/spec-extension/request.ts @@ -91,7 +91,7 @@ export class NextRequest extends Request { } } -interface RequestInit extends globalThis.RequestInit { +export interface RequestInit extends globalThis.RequestInit { geo?: { city?: string country?: string diff --git a/packages/next/server/web/types.ts b/packages/next/server/web/types.ts index b6f04fe5d4f7e74..3dc2518e25e87d9 100644 --- a/packages/next/server/web/types.ts +++ b/packages/next/server/web/types.ts @@ -26,7 +26,6 @@ export interface RequestData { } export interface FetchEventResult { - promise: Promise response: Response waitUntil: Promise } diff --git a/test/integration/middleware-base-path/pages/_middleware.js b/test/integration/middleware-base-path/pages/_middleware.js index 3b229fa441eb02f..a4614ceeb547a7e 100644 --- a/test/integration/middleware-base-path/pages/_middleware.js +++ b/test/integration/middleware-base-path/pages/_middleware.js @@ -1,14 +1,14 @@ import { NextResponse } from 'next/server' -export function middleware(event) { - const url = event.request.nextUrl +export async function middleware(request) { + const url = request.nextUrl if (url.pathname === '/redirect-with-basepath' && !url.basePath) { url.basePath = '/root' - event.respondWith(NextResponse.redirect(url)) + return NextResponse.redirect(url) } if (url.pathname === '/redirect-with-basepath') { url.pathname = '/about' - event.respondWith(NextResponse.rewrite(url)) + return NextResponse.rewrite(url) } } diff --git a/test/integration/middleware-core/pages/interface/_middleware.js b/test/integration/middleware-core/pages/interface/_middleware.js index 428f94281052aeb..fc3d19c63ac609e 100644 --- a/test/integration/middleware-core/pages/interface/_middleware.js +++ b/test/integration/middleware-core/pages/interface/_middleware.js @@ -1,14 +1,12 @@ -export function middleware(event) { - event.respondWith( - new Response(null, { - headers: { - 'req-url-basepath': event.request.nextUrl.basePath, - 'req-url-pathname': event.request.nextUrl.pathname, - 'req-url-params': JSON.stringify(event.request.page.params), - 'req-url-page': event.request.page.name, - 'req-url-query': event.request.nextUrl.searchParams.get('foo'), - 'req-url-locale': event.request.nextUrl.locale, - }, - }) - ) +export function middleware(request) { + return new Response(null, { + headers: { + 'req-url-basepath': request.nextUrl.basePath, + 'req-url-pathname': request.nextUrl.pathname, + 'req-url-params': JSON.stringify(request.page.params), + 'req-url-page': request.page.name, + 'req-url-query': request.nextUrl.searchParams.get('foo'), + 'req-url-locale': request.nextUrl.locale, + }, + }) } diff --git a/test/integration/middleware-core/pages/redirects/_middleware.js b/test/integration/middleware-core/pages/redirects/_middleware.js index 92dd9cf3970931b..e97cb99011b04a2 100644 --- a/test/integration/middleware-core/pages/redirects/_middleware.js +++ b/test/integration/middleware-core/pages/redirects/_middleware.js @@ -1,9 +1,5 @@ -export function middleware(event) { - event.respondWith(handleRequest(event)) -} - -async function handleRequest(event) { - const url = event.request.nextUrl +export async function middleware(request) { + const url = request.nextUrl if (url.searchParams.get('foo') === 'bar') { url.pathname = '/redirects/new-home' diff --git a/test/integration/middleware-core/pages/responses/_middleware.js b/test/integration/middleware-core/pages/responses/_middleware.js index 1c7535bf6cb852c..246ab0a18d479af 100644 --- a/test/integration/middleware-core/pages/responses/_middleware.js +++ b/test/integration/middleware-core/pages/responses/_middleware.js @@ -2,71 +2,74 @@ import { createElement } from 'react' import { renderToString } from 'react-dom/server.browser' import { NextResponse } from 'next/server' -export async function middleware(event) { +export async function middleware(request, ev) { // eslint-disable-next-line no-undef const { readable, writable } = new TransformStream() - const url = event.request.nextUrl + const url = request.nextUrl const writer = writable.getWriter() const encoder = new TextEncoder() const next = NextResponse.next() - // Sends a header - if (url.pathname === '/responses/header') { - next.headers.set('x-first-header', 'valid') - event.respondWith(next) - } - // Header based on query param if (url.searchParams.get('nested-header') === 'true') { next.headers.set('x-nested-header', 'valid') - event.respondWith(next) + } + + // Sends a header + if (url.pathname === '/responses/header') { + next.headers.set('x-first-header', 'valid') + return next } // Streams a basic response if (url.pathname === '/responses/stream-a-response') { - event.respondWith(new Response(readable)) - writer.write(encoder.encode('this is a streamed ')) - writer.write(encoder.encode('response')) - writer.close() - return + ev.waitUntil( + (async () => { + writer.write(encoder.encode('this is a streamed ')) + writer.write(encoder.encode('response')) + writer.close() + })() + ) + + return new Response(readable) } if (url.pathname === '/responses/bad-status') { - event.respondWith( - new Response('Auth required', { - headers: { 'WWW-Authenticate': 'Basic realm="Secure Area"' }, - status: 401, - }) - ) + return new Response('Auth required', { + headers: { 'WWW-Authenticate': 'Basic realm="Secure Area"' }, + status: 401, + }) } if (url.pathname === '/responses/stream-long') { - event.respondWith(new Response(readable)) - writer.write(encoder.encode('this is a streamed '.repeat(10))) - await sleep(2000) - writer.write(encoder.encode('after 2 seconds '.repeat(10))) - await sleep(2000) - writer.write(encoder.encode('after 4 seconds '.repeat(10))) - await sleep(2000) - writer.close() - return + ev.waitUntil( + (async () => { + writer.write(encoder.encode('this is a streamed '.repeat(10))) + await sleep(2000) + writer.write(encoder.encode('after 2 seconds '.repeat(10))) + await sleep(2000) + writer.write(encoder.encode('after 4 seconds '.repeat(10))) + await sleep(2000) + writer.close() + })() + ) + + return new Response(readable) } // Sends response if (url.pathname === '/responses/send-response') { - return event.respondWith(new Response(JSON.stringify({ message: 'hi!' }))) + return new Response(JSON.stringify({ message: 'hi!' })) } // Render React component if (url.pathname === '/responses/react') { - return event.respondWith( - new Response( - renderToString( - createElement( - 'h1', - {}, - 'SSR with React! Hello, ' + url.searchParams.get('name') - ) + return new Response( + renderToString( + createElement( + 'h1', + {}, + 'SSR with React! Hello, ' + url.searchParams.get('name') ) ) ) @@ -74,21 +77,27 @@ export async function middleware(event) { // Stream React component if (url.pathname === '/responses/react-stream') { - event.respondWith(new Response(readable)) - writer.write( - encoder.encode(renderToString(createElement('h1', {}, 'I am a stream'))) - ) - await sleep(500) - writer.write( - encoder.encode( - renderToString(createElement('p', {}, 'I am another stream')) - ) + ev.waitUntil( + (async () => { + writer.write( + encoder.encode( + renderToString(createElement('h1', {}, 'I am a stream')) + ) + ) + await sleep(500) + writer.write( + encoder.encode( + renderToString(createElement('p', {}, 'I am another stream')) + ) + ) + writer.close() + })() ) - writer.close() - return + + return new Response(readable) } - event.respondWith(next) + return next } function sleep(time) { diff --git a/test/integration/middleware-core/pages/rewrites/_middleware.js b/test/integration/middleware-core/pages/rewrites/_middleware.js index 4c1b04285f82f31..291b2be08af2790 100644 --- a/test/integration/middleware-core/pages/rewrites/_middleware.js +++ b/test/integration/middleware-core/pages/rewrites/_middleware.js @@ -1,31 +1,31 @@ import { NextResponse } from 'next/server' -export function middleware(event) { - const url = event.request.nextUrl +export async function middleware(request) { + const url = request.nextUrl if (url.pathname === '/rewrites/rewrite-to-ab-test') { - let bucket = event.request.cookies.bucket + let bucket = request.cookies.bucket if (!bucket) { bucket = Math.random() >= 0.5 ? 'a' : 'b' const response = NextResponse.rewrite(`/rewrites/${bucket}`) response.cookie('bucket', bucket) - return event.respondWith(response) + return response } - return event.respondWith(NextResponse.rewrite(`/rewrites/${bucket}`)) + return NextResponse.rewrite(`/rewrites/${bucket}`) } if (url.pathname === '/rewrites/rewrite-me-to-about') { - return event.respondWith(NextResponse.rewrite('/rewrites/about')) + return NextResponse.rewrite('/rewrites/about') } if (url.pathname === '/rewrites/rewrite-me-to-vercel') { - return event.respondWith(NextResponse.rewrite('https://vercel.com')) + return NextResponse.rewrite('https://vercel.com') } if (url.pathname === '/rewrites/rewrite-me-without-hard-navigation') { url.pathname = '/rewrites/about' url.searchParams.set('middleware', 'foo') - event.respondWith(NextResponse.rewrite(url)) + return NextResponse.rewrite(url) } } diff --git a/test/integration/middleware-core/test/index.test.js b/test/integration/middleware-core/test/index.test.js index 5c4c8fbccd13514..121375f254f725e 100644 --- a/test/integration/middleware-core/test/index.test.js +++ b/test/integration/middleware-core/test/index.test.js @@ -309,21 +309,21 @@ function responseTests(locale = '') { expect($('.title').text()).toBe('Hello World') }) - it(`${locale} should respond with a header`, async () => { + it(`${locale} should respond with 2 nested headers`, async () => { const res = await fetchViaHTTP( context.appPort, - `${locale}/responses/header` + `${locale}/responses/header?nested-header=true` ) expect(res.headers.get('x-first-header')).toBe('valid') + expect(res.headers.get('x-nested-header')).toBe('valid') }) - it(`${locale} should respond with 2 nested headers`, async () => { + it(`${locale} should respond with a header`, async () => { const res = await fetchViaHTTP( context.appPort, - `${locale}/responses/header?nested-header=true` + `${locale}/responses/header` ) expect(res.headers.get('x-first-header')).toBe('valid') - expect(res.headers.get('x-nested-header')).toBe('valid') }) }