diff --git a/packages/next/build/analysis/extract-const-value.ts b/packages/next/build/analysis/extract-const-value.ts index 4d7190b7d773e..3af4cb247017b 100644 --- a/packages/next/build/analysis/extract-const-value.ts +++ b/packages/next/build/analysis/extract-const-value.ts @@ -9,6 +9,7 @@ import type { NullLiteral, NumericLiteral, ObjectExpression, + RegExpLiteral, StringLiteral, VariableDeclaration, } from '@swc/core' @@ -119,6 +120,10 @@ function isKeyValueProperty(node: Node): node is KeyValueProperty { return node.type === 'KeyValueProperty' } +function isRegExpLiteral(node: Node): node is RegExpLiteral { + return node.type === 'RegExpLiteral' +} + class UnsupportedValueError extends Error {} class NoSuchDeclarationError extends Error {} @@ -134,6 +139,9 @@ function extractValue(node: Node): any { } else if (isNumericLiteral(node)) { // e.g. 123 return node.value + } else if (isRegExpLiteral(node)) { + // e.g. /abc/i + return new RegExp(node.pattern, node.flags) } else if (isIdentifier(node)) { switch (node.value) { case 'undefined': diff --git a/packages/next/build/analysis/get-page-static-info.ts b/packages/next/build/analysis/get-page-static-info.ts index 1daab5c055c11..6d3c387d5cf8b 100644 --- a/packages/next/build/analysis/get-page-static-info.ts +++ b/packages/next/build/analysis/get-page-static-info.ts @@ -3,11 +3,18 @@ import type { NextConfig } from '../../server/config-shared' import { tryToExtractExportedConstValue } from './extract-const-value' import { parseModule } from './parse-module' import { promises as fs } from 'fs' +import { tryToParsePath } from '../../lib/try-to-parse-path' +import { MIDDLEWARE_FILE } from '../../lib/constants' + +interface MiddlewareConfig { + pathMatcher: RegExp +} export interface PageStaticInfo { runtime?: PageRuntime ssg?: boolean ssr?: boolean + middleware?: Partial } /** @@ -21,38 +28,36 @@ export async function getPageStaticInfo(params: { nextConfig: Partial pageFilePath: string isDev?: boolean + page?: string }): Promise { const { isDev, pageFilePath, nextConfig } = params const fileContent = (await tryToReadFile(pageFilePath, !isDev)) || '' - if (/runtime|getStaticProps|getServerSideProps/.test(fileContent)) { + if (/runtime|getStaticProps|getServerSideProps|matching/.test(fileContent)) { const swcAST = await parseModule(pageFilePath, fileContent) const { ssg, ssr } = checkExports(swcAST) const config = tryToExtractExportedConstValue(swcAST, 'config') || {} - if (config?.runtime === 'edge') { - return { - runtime: config.runtime, - ssr: ssr, - ssg: ssg, - } - } + const runtime = + config?.runtime === 'edge' + ? 'edge' + : // For Node.js runtime, we do static optimization. + config?.runtime === 'nodejs' + ? ssr || ssg + ? 'nodejs' + : undefined + : // When the runtime is required because there is ssr or ssg we fallback + ssr || ssg + ? nextConfig.experimental?.runtime + : undefined - // For Node.js runtime, we do static optimization. - if (config?.runtime === 'nodejs') { - return { - runtime: ssr || ssg ? config.runtime : undefined, - ssr: ssr, - ssg: ssg, - } - } + const middlewareConfig = + params.page === MIDDLEWARE_FILE && getMiddlewareConfig(config) - // When the runtime is required because there is ssr or ssg we fallback - if (ssr || ssg) { - return { - runtime: nextConfig.experimental?.runtime, - ssr: ssr, - ssg: ssg, - } + return { + ssr, + ssg, + ...(middlewareConfig && { middleware: middlewareConfig }), + ...(runtime && { runtime }), } } @@ -117,3 +122,44 @@ async function tryToReadFile(filePath: string, shouldThrow: boolean) { } } } + +function getMiddlewareConfig(config: any): Partial { + const result: Partial = {} + + if (config.matching) { + result.pathMatcher = new RegExp( + getMiddlewareRegExpStrings(config.matching).join('|') + ) + } + + console.log(result) + + return result +} + +function getMiddlewareRegExpStrings(matching: string | string[]): string[] { + if (Array.isArray(matching)) { + return matching.flatMap((x) => getMiddlewareRegExpStrings(x)) + } + + if (typeof matching !== 'string') { + throw new Error( + '`matching` must be a path pattern or an array of path patterns' + ) + } + + let matcher = matching.startsWith('/') ? matching : `/${matching}` + const parsedPage = tryToParsePath(matcher) + + matcher = `/_next/data/:__nextjsBuildId__${matcher}.json` + const parsedDataRoute = tryToParsePath(matcher) + + const regexes = [parsedPage.regexStr, parsedDataRoute.regexStr].filter( + (x): x is string => !!x + ) + if (regexes.length < 2) { + throw new Error("Can't parse matcher") + } else { + return regexes + } +} diff --git a/packages/next/build/entries.ts b/packages/next/build/entries.ts index 01a3f9e5c2268..cc9a7ca66ab40 100644 --- a/packages/next/build/entries.ts +++ b/packages/next/build/entries.ts @@ -154,11 +154,14 @@ export function getEdgeServerEntry(opts: { isServerComponent: boolean page: string pages: { [page: string]: string } + middleware?: { pathMatcher?: RegExp } }) { if (opts.page === MIDDLEWARE_FILE) { const loaderParams: MiddlewareLoaderOptions = { absolutePagePath: opts.absolutePagePath, page: opts.page, + matcherRegexp: + opts.middleware?.pathMatcher && opts.middleware.pathMatcher.source, } return `next-middleware-loader?${stringify(loaderParams)}!` @@ -327,6 +330,7 @@ export async function createEntrypoints(params: CreateEntrypointsParams) { nextConfig: config, pageFilePath, isDev, + page, }) runDependingOnPageType({ @@ -376,6 +380,7 @@ export async function createEntrypoints(params: CreateEntrypointsParams) { isDev: false, isServerComponent, page, + middleware: staticInfo?.middleware, }) }, }) diff --git a/packages/next/build/webpack/loaders/get-module-build-info.ts b/packages/next/build/webpack/loaders/get-module-build-info.ts index 36e4380778c87..453d3bf275421 100644 --- a/packages/next/build/webpack/loaders/get-module-build-info.ts +++ b/packages/next/build/webpack/loaders/get-module-build-info.ts @@ -22,6 +22,7 @@ export interface RouteMeta { export interface EdgeMiddlewareMeta { page: string + matcherRegexp?: string } export interface EdgeSSRMeta { diff --git a/packages/next/build/webpack/loaders/next-middleware-loader.ts b/packages/next/build/webpack/loaders/next-middleware-loader.ts index 4a76f02923010..3110b7ea7c6fc 100644 --- a/packages/next/build/webpack/loaders/next-middleware-loader.ts +++ b/packages/next/build/webpack/loaders/next-middleware-loader.ts @@ -5,13 +5,16 @@ import { MIDDLEWARE_FILE } from '../../../lib/constants' export type MiddlewareLoaderOptions = { absolutePagePath: string page: string + matcherRegexp?: string } export default function middlewareLoader(this: any) { - const { absolutePagePath, page }: MiddlewareLoaderOptions = this.getOptions() + const { absolutePagePath, page, matcherRegexp }: MiddlewareLoaderOptions = + this.getOptions() const stringifiedPagePath = stringifyRequest(this, absolutePagePath) const buildInfo = getModuleBuildInfo(this._module) buildInfo.nextEdgeMiddleware = { + matcherRegexp, page: page.replace(new RegExp(`${MIDDLEWARE_FILE}$`), '') || '/', } diff --git a/packages/next/build/webpack/plugins/middleware-plugin.ts b/packages/next/build/webpack/plugins/middleware-plugin.ts index 1b3d58ab74e5f..8b7f8c9ae0346 100644 --- a/packages/next/build/webpack/plugins/middleware-plugin.ts +++ b/packages/next/build/webpack/plugins/middleware-plugin.ts @@ -394,13 +394,14 @@ function getCreateAssets(params: { const { namedRegex } = getNamedMiddlewareRegex(page, { catchAll: !metadata.edgeSSR, }) + const regexp = metadata?.edgeMiddleware?.matcherRegexp ?? namedRegex middlewareManifest.middleware[page] = { env: Array.from(metadata.env), files: getEntryFiles(entrypoint.getFiles(), metadata), name: entrypoint.name, page: page, - regexp: namedRegex, + regexp, wasm: Array.from(metadata.wasmBindings), } } diff --git a/packages/next/server/dev/next-dev-server.ts b/packages/next/server/dev/next-dev-server.ts index 0b733bd28fb3c..e342027e3c9fc 100644 --- a/packages/next/server/dev/next-dev-server.ts +++ b/packages/next/server/dev/next-dev-server.ts @@ -282,6 +282,7 @@ export default class DevServer extends Server { wp.on('aggregated', async () => { const routedMiddleware: string[] = [] + let middlewareMatcher: RegExp | undefined const routedPages: string[] = [] const knownFiles = wp.getTimeInfoEntries() const appPaths: Record = {} @@ -307,8 +308,15 @@ export default class DevServer extends Server { extensions: this.nextConfig.pageExtensions, }) + const staticInfo = await getPageStaticInfo({ + pageFilePath: fileName, + nextConfig: this.nextConfig, + page: rootFile, + }) + if (rootFile === MIDDLEWARE_FILE) { - routedMiddleware.push(`/`) + middlewareMatcher = staticInfo.middleware?.pathMatcher + routedMiddleware.push('/') continue } @@ -343,11 +351,6 @@ export default class DevServer extends Server { continue } - const staticInfo = await getPageStaticInfo({ - pageFilePath: fileName, - nextConfig: this.nextConfig, - }) - runDependingOnPageType({ page: pageName, pageRuntime: staticInfo.runtime, @@ -362,13 +365,19 @@ export default class DevServer extends Server { } this.appPathRoutes = appPaths - this.middleware = getSortedRoutes(routedMiddleware).map((page) => ({ - match: getRouteMatcher( - getMiddlewareRegex(page, { catchAll: !ssrMiddleware.has(page) }) - ), - page, - ssr: ssrMiddleware.has(page), - })) + this.middleware = getSortedRoutes(routedMiddleware).map((page) => { + return { + match: getRouteMatcher( + page === '/' && middlewareMatcher + ? { re: middlewareMatcher, groups: {} } + : getMiddlewareRegex(page, { + catchAll: !ssrMiddleware.has(page), + }) + ), + page, + ssr: ssrMiddleware.has(page), + } + }) try { // we serve a separate manifest with all pages for the client in diff --git a/packages/next/server/next-server.ts b/packages/next/server/next-server.ts index 969bf533211c1..59de23fa5190f 100644 --- a/packages/next/server/next-server.ts +++ b/packages/next/server/next-server.ts @@ -12,7 +12,10 @@ import type { BaseNextRequest, BaseNextResponse } from './base-http' import type { PagesManifest } from '../build/webpack/plugins/pages-manifest-plugin' import type { PayloadOptions } from './send-payload' import type { NextParsedUrlQuery, NextUrlWithParsedQuery } from './request-meta' -import type { Params } from '../shared/lib/router/utils/route-matcher' +import type { + Params, + RouteMatch, +} from '../shared/lib/router/utils/route-matcher' import fs from 'fs' import { join, relative, resolve, sep } from 'path' @@ -66,14 +69,12 @@ import { relativizeURL } from '../shared/lib/router/utils/relativize-url' import { prepareDestination } from '../shared/lib/router/utils/prepare-destination' import { normalizeLocalePath } from '../shared/lib/i18n/normalize-locale-path' import { getRouteMatcher } from '../shared/lib/router/utils/route-matcher' -import { MIDDLEWARE_FILENAME } from '../lib/constants' 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 { removeTrailingSlash } from '../shared/lib/router/utils/remove-trailing-slash' import { clonableBodyForRequest } from './body-streams' -import { getMiddlewareRegex } from '../shared/lib/router/utils/route-regex' import { getNextPathnameInfo } from '../shared/lib/router/utils/get-next-pathname-info' export * from './base-server' @@ -1021,11 +1022,7 @@ export default class NextNodeServer extends BaseServer { )) return manifest.sortedMiddleware.map((page) => ({ - match: getRouteMatcher( - getMiddlewareRegex(page, { - catchAll: manifest?.middleware?.[page].name === MIDDLEWARE_FILENAME, - }) - ), + match: getMiddlewareMatcher(manifest.middleware[page]), page, })) } @@ -1412,3 +1409,21 @@ export default class NextNodeServer extends BaseServer { } } } + +const MiddlewareMatcherCache = new WeakMap< + MiddlewareManifest['middleware'][string], + RouteMatch +>() + +function getMiddlewareMatcher( + info: MiddlewareManifest['middleware'][string] +): RouteMatch { + const stored = MiddlewareMatcherCache.get(info) + if (stored) { + return stored + } + + const matcher = getRouteMatcher({ re: new RegExp(info.regexp), groups: {} }) + MiddlewareMatcherCache.set(info, matcher) + return matcher +} diff --git a/test/e2e/middleware-can-set-the-matcher-in-its-config/index.test.ts b/test/e2e/middleware-can-set-the-matcher-in-its-config/index.test.ts new file mode 100644 index 0000000000000..18715a1110658 --- /dev/null +++ b/test/e2e/middleware-can-set-the-matcher-in-its-config/index.test.ts @@ -0,0 +1,134 @@ +import { createNext } from 'e2e-utils' +import { NextInstance } from 'test/lib/next-modes/base' +import { fetchViaHTTP } from 'next-test-utils' + +describe('Middleware can set the matcher in its config', () => { + let next: NextInstance + + beforeAll(async () => { + next = await createNext({ + files: { + 'pages/index.js': ` + export default function Page({ message }) { + return
+

root page

+

{message}

+
+ } + + export const getServerSideProps = () => { + return { + props: { + message: "Hello, world." + } + } + } + `, + 'pages/with-middleware/index.js': ` + export default function Page({ message }) { + return
+

This should run the middleware

+

{message}

+
+ } + + export const getServerSideProps = () => { + return { + props: { + message: "Hello, cruel world." + } + } + } + `, + 'pages/another-middleware/index.js': ` + export default function Page({ message }) { + return
+

This should also run the middleware

+

{message}

+
+ } + + export const getServerSideProps = () => { + return { + props: { + message: "Hello, magnificent world." + } + } + } + `, + 'middleware.js': ` + import { NextResponse } from 'next/server' + export const config = { + matching: ['/with-middleware/:path*', '/another-middleware/:path*'] + }; + export default (req) => { + const res = NextResponse.next(); + res.headers.set('X-From-Middleware', 'true'); + return res; + } + `, + }, + dependencies: {}, + }) + }) + afterAll(() => next.destroy()) + + it('does not add the header for root request', async () => { + const response = await fetchViaHTTP(next.url, '/') + expect(response.headers.get('X-From-Middleware')).toBeNull() + expect(await response.text()).toContain('root page') + }) + + it('adds the header for a matched path', async () => { + const response = await fetchViaHTTP(next.url, '/with-middleware') + expect(response.headers.get('X-From-Middleware')).toBe('true') + expect(await response.text()).toContain('This should run the middleware') + }) + + it('adds the header for a matched data path', async () => { + const response = await fetchViaHTTP( + next.url, + `/_next/data/${next.buildId}/with-middleware.json` + ) + expect(await response.json()).toMatchObject({ + pageProps: { + message: 'Hello, cruel world.', + }, + }) + expect(response.headers.get('X-From-Middleware')).toBe('true') + }) + + it('adds the header for another matched path', async () => { + const response = await fetchViaHTTP(next.url, '/another-middleware') + expect(response.headers.get('X-From-Middleware')).toBe('true') + expect(await response.text()).toContain( + 'This should also run the middleware' + ) + }) + + it('adds the header for another matched data path', async () => { + const response = await fetchViaHTTP( + next.url, + `/_next/data/${next.buildId}/another-middleware.json` + ) + expect(await response.json()).toMatchObject({ + pageProps: { + message: 'Hello, magnificent world.', + }, + }) + expect(response.headers.get('X-From-Middleware')).toBe('true') + }) + + it('does not add the header for root data request', async () => { + const response = await fetchViaHTTP( + next.url, + `/_next/data/${next.buildId}/index.json` + ) + expect(await response.json()).toMatchObject({ + pageProps: { + message: 'Hello, world.', + }, + }) + expect(response.headers.get('X-From-Middleware')).toBeNull() + }) +})