diff --git a/packages/next/build/analysis/get-page-static-info.ts b/packages/next/build/analysis/get-page-static-info.ts index 6e2389803f..cc18cc8d74 100644 --- a/packages/next/build/analysis/get-page-static-info.ts +++ b/packages/next/build/analysis/get-page-static-info.ts @@ -1,5 +1,6 @@ import { isServerRuntime } from '../../server/config-shared' import type { NextConfig } from '../../server/config-shared' +import type { Middleware, RouteHas } from '../../lib/load-custom-routes' import { extractExportedConstValue, UnsupportedValueError, @@ -9,10 +10,17 @@ import { promises as fs } from 'fs' import { tryToParsePath } from '../../lib/try-to-parse-path' import * as Log from '../output/log' import { SERVER_RUNTIME } from '../../lib/constants' -import { ServerRuntime } from '../../types' +import { ServerRuntime } from 'next/types' +import { checkCustomRoutes } from '../../lib/load-custom-routes' -interface MiddlewareConfig { - pathMatcher: RegExp +export interface MiddlewareConfig { + matchers: MiddlewareMatcher[] +} + +export interface MiddlewareMatcher { + regexp: string + locale?: false + has?: RouteHas[] } export interface PageStaticInfo { @@ -81,55 +89,63 @@ async function tryToReadFile(filePath: string, shouldThrow: boolean) { } } -function getMiddlewareRegExpStrings( +function getMiddlewareMatchers( matcherOrMatchers: unknown, nextConfig: NextConfig -): string[] { +): MiddlewareMatcher[] { + let matchers: unknown[] = [] if (Array.isArray(matcherOrMatchers)) { - return matcherOrMatchers.flatMap((matcher) => - getMiddlewareRegExpStrings(matcher, nextConfig) - ) + matchers = matcherOrMatchers + } else { + matchers.push(matcherOrMatchers) } const { i18n } = nextConfig - if (typeof matcherOrMatchers !== 'string') { - throw new Error( - '`matcher` must be a path matcher or an array of path matchers' - ) - } + let routes = matchers.map( + (m) => (typeof m === 'string' ? { source: m } : m) as Middleware + ) - let matcher: string = matcherOrMatchers + // check before we process the routes and after to ensure + // they are still valid + checkCustomRoutes(routes, 'middleware') - if (!matcher.startsWith('/')) { - throw new Error('`matcher`: path matcher must start with /') - } - const isRoot = matcher === '/' + routes = routes.map((r) => { + let { source } = r - if (i18n?.locales) { - matcher = `/:nextInternalLocale([^/.]{1,})${isRoot ? '' : matcher}` - } + const isRoot = source === '/' - matcher = `/:nextData(_next/data/[^/]{1,})?${matcher}${ - isRoot - ? `(${nextConfig.i18n ? '|\\.json|' : ''}/?index|/?index\\.json)?` - : '(.json)?' - }` + if (i18n?.locales && r.locale !== false) { + source = `/:nextInternalLocale([^/.]{1,})${isRoot ? '' : source}` + } - if (nextConfig.basePath) { - matcher = `${nextConfig.basePath}${matcher}` - } - const parsedPage = tryToParsePath(matcher) + source = `/:nextData(_next/data/[^/]{1,})?${source}${ + isRoot + ? `(${nextConfig.i18n ? '|\\.json|' : ''}/?index|/?index\\.json)?` + : '(.json)?' + }` - if (parsedPage.error) { - throw new Error(`Invalid path matcher: ${matcher}`) - } + if (nextConfig.basePath) { + source = `${nextConfig.basePath}${source}` + } - const regexes = [parsedPage.regexStr].filter((x): x is string => !!x) - if (regexes.length < 1) { - throw new Error("Can't parse matcher") - } else { - return regexes - } + return { ...r, source } + }) + + checkCustomRoutes(routes, 'middleware') + + return routes.map((r) => { + const { source, ...rest } = r + const parsedPage = tryToParsePath(source) + + if (parsedPage.error || !parsedPage.regexStr) { + throw new Error(`Invalid source: ${source}`) + } + + return { + ...rest, + regexp: parsedPage.regexStr, + } + }) } function getMiddlewareConfig( @@ -139,15 +155,7 @@ function getMiddlewareConfig( const result: Partial = {} if (config.matcher) { - result.pathMatcher = new RegExp( - getMiddlewareRegExpStrings(config.matcher, nextConfig).join('|') - ) - - if (result.pathMatcher.source.length > 4096) { - throw new Error( - `generated matcher config must be less than 4096 characters.` - ) - } + result.matchers = getMiddlewareMatchers(config.matcher, nextConfig) } return result diff --git a/packages/next/build/entries.ts b/packages/next/build/entries.ts index 210c7226c3..65b6256315 100644 --- a/packages/next/build/entries.ts +++ b/packages/next/build/entries.ts @@ -4,6 +4,10 @@ import type { EdgeSSRLoaderQuery } from './webpack/loaders/next-edge-ssr-loader' import type { NextConfigComplete } from '../server/config-shared' import type { ServerlessLoaderQuery } from './webpack/loaders/next-serverless-loader' import type { webpack } from 'next/dist/compiled/webpack/webpack' +import type { + MiddlewareConfig, + MiddlewareMatcher, +} from './analysis/get-page-static-info' import type { LoadedEnvFiles } from '@next/env' import chalk from 'next/dist/compiled/chalk' import { posix, join } from 'path' @@ -42,6 +46,7 @@ import { normalizePathSep } from '../shared/lib/page-path/normalize-path-sep' import { normalizePagePath } from '../shared/lib/page-path/normalize-page-path' import { serverComponentRegex } from './webpack/loaders/utils' import { ServerRuntime } from '../types' +import { encodeMatchers } from './webpack/loaders/next-middleware-loader' type ObjectValue = T extends { [key: string]: infer V } ? V : never @@ -163,7 +168,7 @@ export function getEdgeServerEntry(opts: { isServerComponent: boolean page: string pages: { [page: string]: string } - middleware?: { pathMatcher?: RegExp } + middleware?: Partial pagesType?: 'app' | 'pages' | 'root' appDirLoader?: string }) { @@ -171,12 +176,9 @@ export function getEdgeServerEntry(opts: { const loaderParams: MiddlewareLoaderOptions = { absolutePagePath: opts.absolutePagePath, page: opts.page, - // pathMatcher can have special characters that break the loader params - // parsing so we base64 encode/decode the string - matcherRegexp: Buffer.from( - (opts.middleware?.pathMatcher && opts.middleware.pathMatcher.source) || - '' - ).toString('base64'), + matchers: opts.middleware?.matchers + ? encodeMatchers(opts.middleware.matchers) + : '', } return `next-middleware-loader?${stringify(loaderParams)}!` @@ -347,7 +349,7 @@ export async function createEntrypoints(params: CreateEntrypointsParams) { const server: webpack.EntryObject = {} const client: webpack.EntryObject = {} const nestedMiddleware: string[] = [] - let middlewareRegex: string | undefined = undefined + let middlewareMatchers: MiddlewareMatcher[] | undefined = undefined const getEntryHandler = (mappings: Record, pagesType: 'app' | 'pages' | 'root') => @@ -402,7 +404,9 @@ export async function createEntrypoints(params: CreateEntrypointsParams) { }) if (isMiddlewareFile(page)) { - middlewareRegex = staticInfo.middleware?.pathMatcher?.source || '.*' + middlewareMatchers = staticInfo.middleware?.matchers ?? [ + { regexp: '.*' }, + ] if (target === 'serverless') { throw new MiddlewareInServerlessTargetError() @@ -493,7 +497,7 @@ export async function createEntrypoints(params: CreateEntrypointsParams) { client, server, edgeServer, - middlewareRegex, + middlewareMatchers, } } diff --git a/packages/next/build/index.ts b/packages/next/build/index.ts index 1cec41de0b..72ff1b2de1 100644 --- a/packages/next/build/index.ts +++ b/packages/next/build/index.ts @@ -849,7 +849,7 @@ export default async function build( runWebpackSpan, target, appDir, - middlewareRegex: entrypoints.middlewareRegex, + middlewareMatchers: entrypoints.middlewareMatchers, } const configs = await runWebpackSpan diff --git a/packages/next/build/webpack-config.ts b/packages/next/build/webpack-config.ts index e873787ae5..b79b0565e0 100644 --- a/packages/next/build/webpack-config.ts +++ b/packages/next/build/webpack-config.ts @@ -54,6 +54,7 @@ import type { SWC_TARGET_TRIPLE, } from './webpack/plugins/telemetry-plugin' import type { Span } from '../trace' +import type { MiddlewareMatcher } from './analysis/get-page-static-info' import { withoutRSCExtensions } from './utils' import browserslist from 'next/dist/compiled/browserslist' import loadJsConfig from './load-jsconfig' @@ -90,7 +91,7 @@ export function getDefineEnv({ hasReactRoot, isNodeServer, isEdgeServer, - middlewareRegex, + middlewareMatchers, hasServerComponents, }: { dev?: boolean @@ -100,7 +101,7 @@ export function getDefineEnv({ hasReactRoot?: boolean isNodeServer?: boolean isEdgeServer?: boolean - middlewareRegex?: string + middlewareMatchers?: MiddlewareMatcher[] config: NextConfigComplete hasServerComponents?: boolean }) { @@ -144,8 +145,8 @@ export function getDefineEnv({ isEdgeServer ? 'edge' : 'nodejs' ), }), - 'process.env.__NEXT_MIDDLEWARE_REGEX': JSON.stringify( - middlewareRegex || '' + 'process.env.__NEXT_MIDDLEWARE_MATCHERS': JSON.stringify( + middlewareMatchers || [] ), 'process.env.__NEXT_MANUAL_CLIENT_BASE_PATH': JSON.stringify( config.experimental.manualClientBasePath @@ -510,7 +511,7 @@ export default async function getBaseWebpackConfig( runWebpackSpan, target = COMPILER_NAMES.server, appDir, - middlewareRegex, + middlewareMatchers, }: { buildId: string config: NextConfigComplete @@ -525,7 +526,7 @@ export default async function getBaseWebpackConfig( runWebpackSpan: Span target?: string appDir?: string - middlewareRegex?: string + middlewareMatchers?: MiddlewareMatcher[] } ): Promise { const isClient = compilerType === COMPILER_NAMES.client @@ -1673,7 +1674,7 @@ export default async function getBaseWebpackConfig( hasReactRoot, isNodeServer, isEdgeServer, - middlewareRegex, + middlewareMatchers, hasServerComponents, }) ), 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 eccedc1484..fa43fa1701 100644 --- a/packages/next/build/webpack/loaders/get-module-build-info.ts +++ b/packages/next/build/webpack/loaders/get-module-build-info.ts @@ -1,3 +1,4 @@ +import type { MiddlewareMatcher } from '../../analysis/get-page-static-info' import { webpack } from 'next/dist/compiled/webpack/webpack' /** @@ -25,7 +26,7 @@ export interface RouteMeta { export interface EdgeMiddlewareMeta { page: string - matcherRegexp?: string + matchers?: MiddlewareMatcher[] } 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 d6f0b2389c..f125a5780a 100644 --- a/packages/next/build/webpack/loaders/next-middleware-loader.ts +++ b/packages/next/build/webpack/loaders/next-middleware-loader.ts @@ -1,3 +1,4 @@ +import type { MiddlewareMatcher } from '../../analysis/get-page-static-info' import { getModuleBuildInfo } from './get-module-build-info' import { stringifyRequest } from '../stringify-request' import { MIDDLEWARE_LOCATION_REGEXP } from '../../../lib/constants' @@ -5,23 +6,32 @@ import { MIDDLEWARE_LOCATION_REGEXP } from '../../../lib/constants' export type MiddlewareLoaderOptions = { absolutePagePath: string page: string - matcherRegexp?: string + matchers?: string +} + +// matchers can have special characters that break the loader params +// parsing so we base64 encode/decode the string +export function encodeMatchers(matchers: MiddlewareMatcher[]) { + return Buffer.from(JSON.stringify(matchers)).toString('base64') +} + +export function decodeMatchers(encodedMatchers: string) { + return JSON.parse( + Buffer.from(encodedMatchers, 'base64').toString() + ) as MiddlewareMatcher[] } export default function middlewareLoader(this: any) { const { absolutePagePath, page, - matcherRegexp: base64MatcherRegex, + matchers: encodedMatchers, }: MiddlewareLoaderOptions = this.getOptions() - const matcherRegexp = Buffer.from( - base64MatcherRegex || '', - 'base64' - ).toString() + const matchers = encodedMatchers ? decodeMatchers(encodedMatchers) : undefined const stringifiedPagePath = stringifyRequest(this, absolutePagePath) const buildInfo = getModuleBuildInfo(this._module) buildInfo.nextEdgeMiddleware = { - matcherRegexp, + matchers, page: page.replace(new RegExp(`/${MIDDLEWARE_LOCATION_REGEXP}$`), '') || '/', } diff --git a/packages/next/build/webpack/plugins/middleware-plugin.ts b/packages/next/build/webpack/plugins/middleware-plugin.ts index 11432fe8d7..82b94f8b41 100644 --- a/packages/next/build/webpack/plugins/middleware-plugin.ts +++ b/packages/next/build/webpack/plugins/middleware-plugin.ts @@ -3,6 +3,7 @@ import type { EdgeMiddlewareMeta, } from '../loaders/get-module-build-info' import type { EdgeSSRMeta } from '../loaders/get-module-build-info' +import type { MiddlewareMatcher } from '../../analysis/get-page-static-info' import { getNamedMiddlewareRegex } from '../../../shared/lib/router/utils/route-regex' import { getModuleBuildInfo } from '../loaders/get-module-build-info' import { getSortedRoutes } from '../../../shared/lib/router/utils' @@ -23,13 +24,13 @@ export interface EdgeFunctionDefinition { files: string[] name: string page: string - regexp: string + matchers: MiddlewareMatcher[] wasm?: AssetBinding[] assets?: AssetBinding[] } export interface MiddlewareManifest { - version: 1 + version: 2 sortedMiddleware: string[] middleware: { [page: string]: EdgeFunctionDefinition } functions: { [page: string]: EdgeFunctionDefinition } @@ -49,7 +50,7 @@ const middlewareManifest: MiddlewareManifest = { sortedMiddleware: [], middleware: {}, functions: {}, - version: 1, + version: 2, } /** @@ -138,14 +139,16 @@ function getCreateAssets(params: { const { namedRegex } = getNamedMiddlewareRegex(page, { catchAll: !metadata.edgeSSR && !metadata.edgeApiFunction, }) - const regexp = metadata?.edgeMiddleware?.matcherRegexp || namedRegex + const matchers = metadata?.edgeMiddleware?.matchers ?? [ + { regexp: namedRegex }, + ] const edgeFunctionDefinition: EdgeFunctionDefinition = { env: Array.from(metadata.env), files: getEntryFiles(entrypoint.getFiles(), metadata), name: entrypoint.name, page: page, - regexp, + matchers, wasm: Array.from(metadata.wasmBindings, ([name, filePath]) => ({ name, filePath, diff --git a/packages/next/client/page-loader.ts b/packages/next/client/page-loader.ts index 5599b3106d..644fffc2b9 100644 --- a/packages/next/client/page-loader.ts +++ b/packages/next/client/page-loader.ts @@ -1,5 +1,6 @@ import type { ComponentType } from 'react' import type { RouteLoader } from './route-loader' +import type { MiddlewareMatcher } from '../build/analysis/get-page-static-info' import { addBasePath } from './add-base-path' import { interpolateAs } from '../shared/lib/router/router' import getAssetPathFromRoute from '../shared/lib/router/utils/get-asset-path-from-route' @@ -11,7 +12,7 @@ import { createRouteLoader, getClientBuildManifest } from './route-loader' declare global { interface Window { - __DEV_MIDDLEWARE_MANIFEST?: { location?: string } + __DEV_MIDDLEWARE_MATCHERS?: MiddlewareMatcher[] __DEV_PAGES_MANIFEST?: { pages: string[] } __SSG_MANIFEST_CB?: () => void __SSG_MANIFEST?: Set @@ -30,7 +31,7 @@ export default class PageLoader { private assetPrefix: string private promisedSsgManifest: Promise> private promisedDevPagesManifest?: Promise - private promisedMiddlewareManifest?: Promise<{ location: string }> + private promisedMiddlewareMatchers?: Promise public routeLoader: RouteLoader @@ -80,32 +81,32 @@ export default class PageLoader { getMiddleware() { if (process.env.NODE_ENV === 'production') { - const middlewareRegex = process.env.__NEXT_MIDDLEWARE_REGEX - window.__MIDDLEWARE_MANIFEST = middlewareRegex - ? { location: middlewareRegex } + const middlewareMatchers = process.env.__NEXT_MIDDLEWARE_MATCHERS + window.__MIDDLEWARE_MATCHERS = middlewareMatchers + ? (middlewareMatchers as any as MiddlewareMatcher[]) : undefined - return window.__MIDDLEWARE_MANIFEST + return window.__MIDDLEWARE_MATCHERS } else { - if (window.__DEV_MIDDLEWARE_MANIFEST) { - return window.__DEV_MIDDLEWARE_MANIFEST + if (window.__DEV_MIDDLEWARE_MATCHERS) { + return window.__DEV_MIDDLEWARE_MATCHERS } else { - if (!this.promisedMiddlewareManifest) { + if (!this.promisedMiddlewareMatchers) { // TODO: Decide what should happen when fetching fails instead of asserting // @ts-ignore - this.promisedMiddlewareManifest = fetch( + this.promisedMiddlewareMatchers = fetch( `${this.assetPrefix}/_next/static/${this.buildId}/_devMiddlewareManifest.json` ) .then((res) => res.json()) - .then((manifest: { location?: string }) => { - window.__DEV_MIDDLEWARE_MANIFEST = manifest - return manifest + .then((matchers: MiddlewareMatcher[]) => { + window.__DEV_MIDDLEWARE_MATCHERS = matchers + return matchers }) .catch((err) => { console.log(`Failed to fetch _devMiddlewareManifest`, err) }) } // TODO Remove this assertion as this could be undefined - return this.promisedMiddlewareManifest! + return this.promisedMiddlewareMatchers! } } } diff --git a/packages/next/client/route-loader.ts b/packages/next/client/route-loader.ts index c540bef9ed..22eb2ae151 100644 --- a/packages/next/client/route-loader.ts +++ b/packages/next/client/route-loader.ts @@ -1,4 +1,5 @@ import type { ComponentType } from 'react' +import type { MiddlewareMatcher } from '../build/analysis/get-page-static-info' import getAssetPathFromRoute from '../shared/lib/router/utils/get-asset-path-from-route' import { __unsafeCreateTrustedScriptURL } from './trusted-types' import { requestIdleCallback } from './request-idle-callback' @@ -13,7 +14,7 @@ declare global { interface Window { __BUILD_MANIFEST?: Record __BUILD_MANIFEST_CB?: Function - __MIDDLEWARE_MANIFEST?: { location: string } + __MIDDLEWARE_MATCHERS?: MiddlewareMatcher[] __MIDDLEWARE_MANIFEST_CB?: Function } } diff --git a/packages/next/lib/load-custom-routes.ts b/packages/next/lib/load-custom-routes.ts index f9b6ee393c..0658b6fd1e 100644 --- a/packages/next/lib/load-custom-routes.ts +++ b/packages/next/lib/load-custom-routes.ts @@ -52,6 +52,12 @@ export type Redirect = { } ) +export type Middleware = { + source: string + locale?: false + has?: RouteHas[] +} + const allowedHasTypes = new Set(['header', 'cookie', 'query', 'host']) const namedGroupsRegex = /\(\?<([a-zA-Z][a-zA-Z0-9]*)>/g @@ -111,9 +117,9 @@ function checkHeader(route: Header): string[] { export type RouteType = 'rewrite' | 'redirect' | 'header' -function checkCustomRoutes( - routes: Redirect[] | Header[] | Rewrite[], - type: RouteType +export function checkCustomRoutes( + routes: Redirect[] | Header[] | Rewrite[] | Middleware[], + type: RouteType | 'middleware' ): void { if (!Array.isArray(routes)) { console.error( @@ -127,17 +133,20 @@ function checkCustomRoutes( let hadInvalidStatus = false let hadInvalidHas = false - const allowedKeys = new Set(['source', 'basePath', 'locale', 'has']) + const allowedKeys = new Set(['source', 'locale', 'has']) if (type === 'rewrite') { + allowedKeys.add('basePath') allowedKeys.add('destination') } if (type === 'redirect') { + allowedKeys.add('basePath') allowedKeys.add('statusCode') allowedKeys.add('permanent') allowedKeys.add('destination') } if (type === 'header') { + allowedKeys.add('basePath') allowedKeys.add('headers') } @@ -146,9 +155,11 @@ function checkCustomRoutes( console.error( `The route ${JSON.stringify( route - )} is not a valid object with \`source\` and \`${ - type === 'header' ? 'headers' : 'destination' - }\`` + )} is not a valid object with \`source\`${ + type !== 'middleware' + ? ` and \`${type === 'header' ? 'headers' : 'destination'}\`` + : '' + }` ) numInvalidRoutes++ continue @@ -175,7 +186,11 @@ function checkCustomRoutes( const invalidKeys = keys.filter((key) => !allowedKeys.has(key)) const invalidParts: string[] = [] - if (typeof route.basePath !== 'undefined' && route.basePath !== false) { + if ( + 'basePath' in route && + typeof route.basePath !== 'undefined' && + route.basePath !== false + ) { invalidParts.push('`basePath` must be undefined or false') } @@ -237,7 +252,7 @@ function checkCustomRoutes( if (type === 'header') { invalidParts.push(...checkHeader(route as Header)) - } else { + } else if (type !== 'middleware') { let _route = route as Rewrite | Redirect if (!_route.destination) { invalidParts.push('`destination` is missing') diff --git a/packages/next/server/base-server.ts b/packages/next/server/base-server.ts index 25bc78ad3f..c162054dc1 100644 --- a/packages/next/server/base-server.ts +++ b/packages/next/server/base-server.ts @@ -5,6 +5,7 @@ import type { DynamicRoutes, PageChecker, Route } from './router' import type { FontManifest } from './font-utils' import type { LoadComponentsReturnType } from './load-components' import type { RouteMatch } from '../shared/lib/router/utils/route-matcher' +import type { MiddlewareRouteMatch } from '../shared/lib/router/utils/middleware-route-matcher' import type { Params } from '../shared/lib/router/utils/route-matcher' import type { NextConfig, NextConfigComplete } from './config-shared' import type { NextParsedUrlQuery, NextUrlWithParsedQuery } from './request-meta' @@ -68,6 +69,7 @@ import { getLocaleRedirect } from '../shared/lib/i18n/get-locale-redirect' import { getHostname } from '../shared/lib/get-hostname' import { parseUrl as parseUrlUtil } from '../shared/lib/router/utils/parse-url' import { getNextPathnameInfo } from '../shared/lib/router/utils/get-next-pathname-info' +import { MiddlewareMatcher } from '../build/analysis/get-page-static-info' export type FindComponentsResult = { components: LoadComponentsReturnType @@ -80,6 +82,12 @@ export interface RoutingItem { re?: RegExp } +export interface MiddlewareRoutingItem { + page: string + match: MiddlewareRouteMatch + matchers?: MiddlewareMatcher[] +} + export interface Options { /** * Object containing the configuration next.config.js diff --git a/packages/next/server/dev/next-dev-server.ts b/packages/next/server/dev/next-dev-server.ts index 492ab6b93e..0452eea5b6 100644 --- a/packages/next/server/dev/next-dev-server.ts +++ b/packages/next/server/dev/next-dev-server.ts @@ -9,7 +9,8 @@ import type { ParsedUrlQuery } from 'querystring' import type { Server as HTTPServer } from 'http' import type { UrlWithParsedQuery } from 'url' import type { BaseNextRequest, BaseNextResponse } from '../base-http' -import type { RoutingItem } from '../base-server' +import type { MiddlewareRoutingItem, RoutingItem } from '../base-server' +import type { MiddlewareMatcher } from '../../build/analysis/get-page-static-info' import crypto from 'crypto' import fs from 'fs' @@ -34,6 +35,7 @@ import { } from '../../shared/lib/constants' import Server, { WrappedBuildError } from '../next-server' import { getRouteMatcher } from '../../shared/lib/router/utils/route-matcher' +import { getMiddlewareRouteMatcher } from '../../shared/lib/router/utils/middleware-route-matcher' import { normalizePagePath } from '../../shared/lib/page-path/normalize-page-path' import { absolutePathToPage } from '../../shared/lib/page-path/absolute-path-to-page' import Router from '../router' @@ -60,10 +62,7 @@ import { } from 'next/dist/compiled/@next/react-dev-overlay/dist/middleware' import * as Log from '../../build/output/log' import isError, { getProperError } from '../../lib/is-error' -import { - getMiddlewareRegex, - getRouteRegex, -} from '../../shared/lib/router/utils/route-regex' +import { getRouteRegex } from '../../shared/lib/router/utils/route-regex' import { getSortedRoutes, isDynamicRoute } from '../../shared/lib/router/utils' import { runDependingOnPageType } from '../../build/entries' import { NodeNextResponse, NodeNextRequest } from '../base-http/node' @@ -106,7 +105,7 @@ export default class DevServer extends Server { private pagesDir: string private appDir?: string private actualMiddlewareFile?: string - private middleware?: RoutingItem + private middleware?: MiddlewareRoutingItem private edgeFunctions?: RoutingItem[] private verifyingTypeScript?: boolean private usingTypeScript?: boolean @@ -305,11 +304,12 @@ export default class DevServer extends Server { let enabledTypeScript = this.usingTypeScript wp.on('aggregated', async () => { - let middlewareMatcher: RegExp | undefined + let middlewareMatchers: MiddlewareMatcher[] | undefined const routedPages: string[] = [] const knownFiles = wp.getTimeInfoEntries() const appPaths: Record = {} const edgeRoutesSet = new Set() + let envChange = false let tsconfigChange = false @@ -369,9 +369,9 @@ export default class DevServer extends Server { if (isMiddlewareFile(rootFile)) { this.actualMiddlewareFile = rootFile - middlewareMatcher = - staticInfo.middleware?.pathMatcher || new RegExp('.*') - edgeRoutesSet.add('/') + middlewareMatchers = staticInfo.middleware?.matchers || [ + { regexp: '.*' }, + ] continue } @@ -536,31 +536,29 @@ export default class DevServer extends Server { } this.appPathRoutes = appPaths - this.edgeFunctions = [] const edgeRoutes = Array.from(edgeRoutesSet) - getSortedRoutes(edgeRoutes).forEach((page) => { - let appPath = this.getOriginalAppPath(page) + this.edgeFunctions = getSortedRoutes(edgeRoutes).map((page) => { + const appPath = this.getOriginalAppPath(page) if (typeof appPath === 'string') { page = appPath } - const isRootMiddleware = page === '/' && !!middlewareMatcher - - const middlewareRegex = isRootMiddleware - ? { re: middlewareMatcher!, groups: {} } - : getMiddlewareRegex(page, { catchAll: false }) - const routeItem = { - match: getRouteMatcher(middlewareRegex), + const edgeRegex = getRouteRegex(page) + return { + match: getRouteMatcher(edgeRegex), page, - re: middlewareRegex.re, - } - if (isRootMiddleware) { - this.middleware = routeItem - } else { - this.edgeFunctions!.push(routeItem) + re: edgeRegex.re, } }) + this.middleware = middlewareMatchers + ? { + match: getMiddlewareRouteMatcher(middlewareMatchers), + page: '/', + matchers: middlewareMatchers, + } + : undefined + try { // we serve a separate manifest with all pages for the client in // dev mode so that we can match a page after a rewrite on the client @@ -828,6 +826,7 @@ export default class DevServer extends Server { response: BaseNextResponse parsedUrl: ParsedUrl parsed: UrlWithParsedQuery + middlewareList: MiddlewareRoutingItem[] }) { try { const result = await super.runMiddleware({ @@ -1158,17 +1157,7 @@ export default class DevServer extends Server { fn: async (_req, res) => { res.statusCode = 200 res.setHeader('Content-Type', 'application/json; charset=utf-8') - res - .body( - JSON.stringify( - this.middleware - ? { - location: this.middleware.re!.source, - } - : {} - ) - ) - .send() + res.body(JSON.stringify(this.getMiddleware()?.matchers ?? [])).send() return { finished: true, } diff --git a/packages/next/server/next-server.ts b/packages/next/server/next-server.ts index 28021d731f..dca746ea6b 100644 --- a/packages/next/server/next-server.ts +++ b/packages/next/server/next-server.ts @@ -22,6 +22,7 @@ import type { Params, RouteMatch, } from '../shared/lib/router/utils/route-matcher' +import type { MiddlewareRouteMatch } from '../shared/lib/router/utils/middleware-route-matcher' import type { NextConfig } from './config-shared' import type { DynamicRoutes, PageChecker } from './router' @@ -71,6 +72,7 @@ import BaseServer, { Options, FindComponentsResult, prepareServerlessUrl, + MiddlewareRoutingItem, RoutingItem, NoFallbackError, RequestContext, @@ -86,6 +88,7 @@ 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 { getMiddlewareRouteMatcher } from '../shared/lib/router/utils/middleware-route-matcher' import { loadEnvConfig } from '@next/env' import { getCustomRoute, stringifyQuery } from './server-route-utils' import { urlQueryToSearchParams } from '../shared/lib/router/utils/querystring' @@ -123,28 +126,55 @@ export interface NodeRequestHandler { const MiddlewareMatcherCache = new WeakMap< MiddlewareManifest['middleware'][string], + MiddlewareRouteMatch +>() + +const EdgeMatcherCache = new WeakMap< + MiddlewareManifest['functions'][string], RouteMatch >() function getMiddlewareMatcher( info: MiddlewareManifest['middleware'][string] -): RouteMatch { +): MiddlewareRouteMatch { const stored = MiddlewareMatcherCache.get(info) if (stored) { return stored } - if (typeof info.regexp !== 'string' || !info.regexp) { + if (!Array.isArray(info.matchers)) { throw new Error( - `Invariant: invalid regexp for middleware ${JSON.stringify(info)}` + `Invariant: invalid matchers for middleware ${JSON.stringify(info)}` ) } - const matcher = getRouteMatcher({ re: new RegExp(info.regexp), groups: {} }) + const matcher = getMiddlewareRouteMatcher(info.matchers) MiddlewareMatcherCache.set(info, matcher) return matcher } +function getEdgeMatcher( + info: MiddlewareManifest['functions'][string] +): RouteMatch { + const stored = EdgeMatcherCache.get(info) + if (stored) { + return stored + } + + if (!Array.isArray(info.matchers) || info.matchers.length !== 1) { + throw new Error( + `Invariant: invalid matchers for middleware ${JSON.stringify(info)}` + ) + } + + const matcher = getRouteMatcher({ + re: new RegExp(info.matchers[0].regexp), + groups: {}, + }) + EdgeMatcherCache.set(info, matcher) + return matcher +} + export default class NextNodeServer extends BaseServer { private imageResponseCache?: ResponseCache @@ -1491,7 +1521,7 @@ export default class NextNodeServer extends BaseServer { } /** Returns the middleware routing item if there is one. */ - protected getMiddleware(): RoutingItem | undefined { + protected getMiddleware(): MiddlewareRoutingItem | undefined { const manifest = this.getMiddlewareManifest() const middleware = manifest?.middleware?.['/'] if (!middleware) { @@ -1511,7 +1541,7 @@ export default class NextNodeServer extends BaseServer { } return Object.keys(manifest.functions).map((page) => ({ - match: getMiddlewareMatcher(manifest.functions[page]), + match: getEdgeMatcher(manifest.functions[page]), page, })) } @@ -1637,10 +1667,6 @@ export default class NextNodeServer extends BaseServer { } } - const allHeaders = new Headers() - let result: FetchEventResult | null = null - const method = (params.request.method || 'GET').toUpperCase() - const middleware = this.getMiddleware() if (!middleware) { return { finished: false } @@ -1649,50 +1675,52 @@ export default class NextNodeServer extends BaseServer { return { finished: false } } - if (middleware && middleware.match(normalizedPathname)) { - await this.ensureMiddleware() - const middlewareInfo = this.getEdgeFunctionInfo({ - page: middleware.page, - middleware: true, - }) + await this.ensureMiddleware() + const middlewareInfo = this.getEdgeFunctionInfo({ + page: middleware.page, + middleware: true, + }) - if (!middlewareInfo) { - throw new MiddlewareNotFoundError() - } + if (!middlewareInfo) { + throw new MiddlewareNotFoundError() + } - result = await run({ - distDir: this.distDir, - name: middlewareInfo.name, - paths: middlewareInfo.paths, - env: middlewareInfo.env, - edgeFunctionEntry: middlewareInfo, - request: { - headers: params.request.headers, - method, - nextConfig: { - basePath: this.nextConfig.basePath, - i18n: this.nextConfig.i18n, - trailingSlash: this.nextConfig.trailingSlash, - }, - url: url, - page: page, - body: getRequestMeta(params.request, '__NEXT_CLONABLE_BODY'), + const method = (params.request.method || 'GET').toUpperCase() + + const result = await run({ + distDir: this.distDir, + name: middlewareInfo.name, + paths: middlewareInfo.paths, + env: middlewareInfo.env, + edgeFunctionEntry: middlewareInfo, + request: { + headers: params.request.headers, + method, + nextConfig: { + basePath: this.nextConfig.basePath, + i18n: this.nextConfig.i18n, + trailingSlash: this.nextConfig.trailingSlash, }, - useCache: !this.nextConfig.experimental.runtime, - onWarning: params.onWarning, + url: url, + page: page, + body: getRequestMeta(params.request, '__NEXT_CLONABLE_BODY'), + }, + useCache: !this.nextConfig.experimental.runtime, + onWarning: params.onWarning, + }) + + const allHeaders = new Headers() + + for (let [key, value] of result.response.headers) { + if (key !== 'x-middleware-next') { + allHeaders.append(key, value) + } + } + + if (!this.renderOpts.dev) { + result.waitUntil.catch((error) => { + console.error(`Uncaught: middleware waitUntil errored`, error) }) - - for (let [key, value] of result.response.headers) { - if (key !== 'x-middleware-next') { - allHeaders.append(key, value) - } - } - - if (!this.renderOpts.dev) { - result.waitUntil.catch((error) => { - console.error(`Uncaught: middleware waitUntil errored`, error) - }) - } } if (!result) { @@ -1734,7 +1762,7 @@ export default class NextNodeServer extends BaseServer { const normalizedPathname = removeTrailingSlash( parsed.pathname || '' ) - if (!middleware.match(normalizedPathname)) { + if (!middleware.match(normalizedPathname, req, parsedUrl.query)) { return { finished: false } } diff --git a/packages/next/shared/lib/router/router.ts b/packages/next/shared/lib/router/router.ts index 2880d35a33..93d6b285e1 100644 --- a/packages/next/shared/lib/router/router.ts +++ b/packages/next/shared/lib/router/router.ts @@ -91,21 +91,27 @@ interface MiddlewareEffectParams { router: Router } -function matchesMiddleware( +export async function matchesMiddleware( options: MiddlewareEffectParams ): Promise { - return Promise.resolve(options.router.pageLoader.getMiddleware()).then( - (middleware) => { - const { pathname: asPathname } = parsePath(options.asPath) - const cleanedAs = hasBasePath(asPathname) - ? removeBasePath(asPathname) - : asPathname + const matchers = await Promise.resolve( + options.router.pageLoader.getMiddleware() + ) + if (!matchers) return false - const regex = middleware?.location - return ( - !!regex && new RegExp(regex).test(addLocale(cleanedAs, options.locale)) - ) - } + const { pathname: asPathname } = parsePath(options.asPath) + // remove basePath first since path prefix has to be in the order of `/${basePath}/${locale}` + const cleanedAs = hasBasePath(asPathname) + ? removeBasePath(asPathname) + : asPathname + const asWithBasePathAndLocale = addBasePath( + addLocale(cleanedAs, options.locale) + ) + + // Check only path match on client. Matching "has" should be done on server + // where we can access more info such as headers, HttpOnly cookie, etc. + return matchers.some((m) => + new RegExp(m.regexp).test(asWithBasePathAndLocale) ) } diff --git a/packages/next/shared/lib/router/utils/middleware-route-matcher.ts b/packages/next/shared/lib/router/utils/middleware-route-matcher.ts new file mode 100644 index 0000000000..87465e3d8a --- /dev/null +++ b/packages/next/shared/lib/router/utils/middleware-route-matcher.ts @@ -0,0 +1,40 @@ +import type { BaseNextRequest } from '../../../../server/base-http' +import type { MiddlewareMatcher } from '../../../../build/analysis/get-page-static-info' +import type { Params } from './route-matcher' +import { matchHas } from './prepare-destination' + +export interface MiddlewareRouteMatch { + ( + pathname: string | null | undefined, + request: BaseNextRequest, + query: Params + ): boolean +} + +export function getMiddlewareRouteMatcher( + matchers: MiddlewareMatcher[] +): MiddlewareRouteMatch { + return ( + pathname: string | null | undefined, + req: BaseNextRequest, + query: Params + ) => { + for (const matcher of matchers) { + const routeMatch = new RegExp(matcher.regexp).exec(pathname!) + if (!routeMatch) { + continue + } + + if (matcher.has) { + const hasParams = matchHas(req, matcher.has, query) + if (!hasParams) { + continue + } + } + + return true + } + + return false + } +} diff --git a/packages/next/shared/lib/router/utils/route-regex.ts b/packages/next/shared/lib/router/utils/route-regex.ts index 0f60695550..133ed1d994 100644 --- a/packages/next/shared/lib/router/utils/route-regex.ts +++ b/packages/next/shared/lib/router/utils/route-regex.ts @@ -144,36 +144,7 @@ export function getNamedRouteRegex(normalizedRoute: string) { } /** - * From a middleware normalized route this function generates a regular - * expression for it. Temporarly we are using this to generate Edge Function - * routes too. In such cases the route should not include a trailing catch-all. - * For these cases the option `catchAll` should be set to false. - */ -export function getMiddlewareRegex( - normalizedRoute: string, - options?: { - catchAll?: boolean - } -): RouteRegex { - const { parameterizedRoute, groups } = getParametrizedRoute(normalizedRoute) - const { catchAll = true } = options ?? {} - if (parameterizedRoute === '/') { - let catchAllRegex = catchAll ? '.*' : '' - return { - groups: {}, - re: new RegExp(`^/${catchAllRegex}$`), - } - } - - let catchAllGroupedRegex = catchAll ? '(?:(/.*)?)' : '' - return { - groups: groups, - re: new RegExp(`^${parameterizedRoute}${catchAllGroupedRegex}$`), - } -} - -/** - * A server version for getMiddlewareRegex that generates a named regexp. + * Generates a named regexp. * This is intended to be using for build time only. */ export function getNamedMiddlewareRegex( diff --git a/test/e2e/middleware-custom-matchers-basepath/app/middleware.js b/test/e2e/middleware-custom-matchers-basepath/app/middleware.js new file mode 100644 index 0000000000..0760a6e4ae --- /dev/null +++ b/test/e2e/middleware-custom-matchers-basepath/app/middleware.js @@ -0,0 +1,17 @@ +import { NextResponse } from 'next/server' + +export default function middleware(request) { + const nextUrl = request.nextUrl.clone() + nextUrl.pathname = '/' + const res = NextResponse.rewrite(nextUrl) + res.headers.set('X-From-Middleware', 'true') + return res +} + +export const config = { + matcher: [ + { + source: '/hello', + }, + ], +} diff --git a/test/e2e/middleware-custom-matchers-basepath/app/next.config.js b/test/e2e/middleware-custom-matchers-basepath/app/next.config.js new file mode 100644 index 0000000000..ee95502b60 --- /dev/null +++ b/test/e2e/middleware-custom-matchers-basepath/app/next.config.js @@ -0,0 +1,3 @@ +module.exports = { + basePath: '/docs', +} diff --git a/test/e2e/middleware-custom-matchers-basepath/app/pages/index.js b/test/e2e/middleware-custom-matchers-basepath/app/pages/index.js new file mode 100644 index 0000000000..accd5b17ad --- /dev/null +++ b/test/e2e/middleware-custom-matchers-basepath/app/pages/index.js @@ -0,0 +1,14 @@ +export default (props) => ( + <> +

home

+
{props.fromMiddleware}
+ +) + +export async function getServerSideProps({ res }) { + return { + props: { + fromMiddleware: res.getHeader('x-from-middleware') || null, + }, + } +} diff --git a/test/e2e/middleware-custom-matchers-basepath/app/pages/routes.js b/test/e2e/middleware-custom-matchers-basepath/app/pages/routes.js new file mode 100644 index 0000000000..4e8bb9d0cb --- /dev/null +++ b/test/e2e/middleware-custom-matchers-basepath/app/pages/routes.js @@ -0,0 +1,11 @@ +import Link from 'next/link' + +export default (props) => ( + +) diff --git a/test/e2e/middleware-custom-matchers-basepath/test/index.test.ts b/test/e2e/middleware-custom-matchers-basepath/test/index.test.ts new file mode 100644 index 0000000000..ff367b9787 --- /dev/null +++ b/test/e2e/middleware-custom-matchers-basepath/test/index.test.ts @@ -0,0 +1,56 @@ +/* eslint-env jest */ +/* eslint-disable jest/no-standalone-expect */ + +import { join } from 'path' +import webdriver from 'next-webdriver' +import { fetchViaHTTP } from 'next-test-utils' +import { createNext, FileRef } from 'e2e-utils' +import { NextInstance } from 'test/lib/next-modes/base' + +const itif = (condition: boolean) => (condition ? it : it.skip) + +const isModeDeploy = process.env.NEXT_TEST_MODE === 'deploy' + +describe('Middleware custom matchers basePath', () => { + let next: NextInstance + + beforeAll(async () => { + next = await createNext({ + files: new FileRef(join(__dirname, '../app')), + }) + }) + afterAll(() => next.destroy()) + + // FIXME + // See https://linear.app/vercel/issue/EC-170/middleware-rewrite-of-nextjs-with-basepath-does-not-work-on-vercel + itif(!isModeDeploy)('should match', async () => { + for (const path of [ + '/docs/hello', + `/docs/_next/data/${next.buildId}/hello.json`, + ]) { + const res = await fetchViaHTTP(next.url, path) + expect(res.status).toBe(200) + expect(res.headers.get('x-from-middleware')).toBeDefined() + } + }) + + it.each(['/hello', '/invalid/docs/hello'])( + 'should not match', + async (path) => { + const res = await fetchViaHTTP(next.url, path) + expect(res.status).toBe(404) + } + ) + + // FIXME: + // See https://linear.app/vercel/issue/EC-160/header-value-set-on-middleware-is-not-propagated-on-client-request-of + itif(!isModeDeploy)('should match has query on client routing', async () => { + const browser = await webdriver(next.url, '/docs/routes') + await browser.eval('window.__TEST_NO_RELOAD = true') + await browser.elementById('hello').click() + const fromMiddleware = await browser.elementById('from-middleware').text() + expect(fromMiddleware).toBe('true') + const noReload = await browser.eval('window.__TEST_NO_RELOAD') + expect(noReload).toBe(true) + }) +}) diff --git a/test/e2e/middleware-custom-matchers-i18n/app/middleware.js b/test/e2e/middleware-custom-matchers-i18n/app/middleware.js new file mode 100644 index 0000000000..bdd67e48df --- /dev/null +++ b/test/e2e/middleware-custom-matchers-i18n/app/middleware.js @@ -0,0 +1,21 @@ +import { NextResponse } from 'next/server' + +export default function middleware(request) { + const nextUrl = request.nextUrl.clone() + nextUrl.pathname = '/' + const res = NextResponse.rewrite(nextUrl) + res.headers.set('X-From-Middleware', 'true') + return res +} + +export const config = { + matcher: [ + { + source: '/hello', + }, + { + source: '/nl-NL/about', + locale: false, + }, + ], +} diff --git a/test/e2e/middleware-custom-matchers-i18n/app/next.config.js b/test/e2e/middleware-custom-matchers-i18n/app/next.config.js new file mode 100644 index 0000000000..97c6addb6f --- /dev/null +++ b/test/e2e/middleware-custom-matchers-i18n/app/next.config.js @@ -0,0 +1,6 @@ +module.exports = { + i18n: { + locales: ['en', 'nl-NL'], + defaultLocale: 'en', + }, +} diff --git a/test/e2e/middleware-custom-matchers-i18n/app/pages/index.js b/test/e2e/middleware-custom-matchers-i18n/app/pages/index.js new file mode 100644 index 0000000000..accd5b17ad --- /dev/null +++ b/test/e2e/middleware-custom-matchers-i18n/app/pages/index.js @@ -0,0 +1,14 @@ +export default (props) => ( + <> +

home

+
{props.fromMiddleware}
+ +) + +export async function getServerSideProps({ res }) { + return { + props: { + fromMiddleware: res.getHeader('x-from-middleware') || null, + }, + } +} diff --git a/test/e2e/middleware-custom-matchers-i18n/app/pages/routes.js b/test/e2e/middleware-custom-matchers-i18n/app/pages/routes.js new file mode 100644 index 0000000000..14f5eeb2af --- /dev/null +++ b/test/e2e/middleware-custom-matchers-i18n/app/pages/routes.js @@ -0,0 +1,26 @@ +import Link from 'next/link' + +export default (props) => ( + +) diff --git a/test/e2e/middleware-custom-matchers-i18n/test/index.test.ts b/test/e2e/middleware-custom-matchers-i18n/test/index.test.ts new file mode 100644 index 0000000000..274e81b7e1 --- /dev/null +++ b/test/e2e/middleware-custom-matchers-i18n/test/index.test.ts @@ -0,0 +1,55 @@ +/* eslint-env jest */ +/* eslint-disable jest/no-standalone-expect */ + +import { join } from 'path' +import webdriver from 'next-webdriver' +import { fetchViaHTTP } from 'next-test-utils' +import { createNext, FileRef } from 'e2e-utils' +import { NextInstance } from 'test/lib/next-modes/base' + +const itif = (condition: boolean) => (condition ? it : it.skip) + +const isModeDeploy = process.env.NEXT_TEST_MODE === 'deploy' + +describe('Middleware custom matchers i18n', () => { + let next: NextInstance + + beforeAll(async () => { + next = await createNext({ + files: new FileRef(join(__dirname, '../app')), + }) + }) + afterAll(() => next.destroy()) + + it.each(['/hello', '/en/hello', '/nl-NL/hello', '/nl-NL/about'])( + 'should match', + async (path) => { + const res = await fetchViaHTTP(next.url, path) + expect(res.status).toBe(200) + expect(res.headers.get('x-from-middleware')).toBeDefined() + } + ) + + it.each(['/invalid/hello', '/hello/invalid', '/about', '/en/about'])( + 'should not match', + async (path) => { + const res = await fetchViaHTTP(next.url, path) + expect(res.status).toBe(404) + } + ) + + // FIXME: + // See https://linear.app/vercel/issue/EC-160/header-value-set-on-middleware-is-not-propagated-on-client-request-of + itif(!isModeDeploy).each(['hello', 'en_hello', 'nl-NL_hello', 'nl-NL_about'])( + 'should match has query on client routing', + async (id) => { + const browser = await webdriver(next.url, '/routes') + await browser.eval('window.__TEST_NO_RELOAD = true') + await browser.elementById(id).click() + const fromMiddleware = await browser.elementById('from-middleware').text() + expect(fromMiddleware).toBe('true') + const noReload = await browser.eval('window.__TEST_NO_RELOAD') + expect(noReload).toBe(true) + } + ) +}) diff --git a/test/e2e/middleware-custom-matchers/app/middleware.js b/test/e2e/middleware-custom-matchers/app/middleware.js new file mode 100644 index 0000000000..fdea3f1f6f --- /dev/null +++ b/test/e2e/middleware-custom-matchers/app/middleware.js @@ -0,0 +1,61 @@ +import { NextResponse } from 'next/server' + +export default function middleware(request) { + const res = NextResponse.rewrite(new URL('/', request.url)) + res.headers.set('X-From-Middleware', 'true') + return res +} + +export const config = { + matcher: [ + { source: '/source-match' }, + { + source: '/has-match-1', + has: [ + { + type: 'header', + key: 'x-my-header', + value: '(?.*)', + }, + ], + }, + { + source: '/has-match-2', + has: [ + { + type: 'query', + key: 'my-query', + }, + ], + }, + { + source: '/has-match-3', + has: [ + { + type: 'cookie', + key: 'loggedIn', + value: '(?true)', + }, + ], + }, + { + source: '/has-match-4', + has: [ + { + type: 'host', + value: 'example.com', + }, + ], + }, + { + source: '/has-match-5', + has: [ + { + type: 'header', + key: 'hasParam', + value: 'with-params', + }, + ], + }, + ], +} diff --git a/test/e2e/middleware-custom-matchers/app/pages/index.js b/test/e2e/middleware-custom-matchers/app/pages/index.js new file mode 100644 index 0000000000..accd5b17ad --- /dev/null +++ b/test/e2e/middleware-custom-matchers/app/pages/index.js @@ -0,0 +1,14 @@ +export default (props) => ( + <> +

home

+
{props.fromMiddleware}
+ +) + +export async function getServerSideProps({ res }) { + return { + props: { + fromMiddleware: res.getHeader('x-from-middleware') || null, + }, + } +} diff --git a/test/e2e/middleware-custom-matchers/app/pages/routes.js b/test/e2e/middleware-custom-matchers/app/pages/routes.js new file mode 100644 index 0000000000..9cd510fe3d --- /dev/null +++ b/test/e2e/middleware-custom-matchers/app/pages/routes.js @@ -0,0 +1,16 @@ +import Link from 'next/link' + +export default (props) => ( + +) diff --git a/test/e2e/middleware-custom-matchers/test/index.test.ts b/test/e2e/middleware-custom-matchers/test/index.test.ts new file mode 100644 index 0000000000..0c33cf5e2f --- /dev/null +++ b/test/e2e/middleware-custom-matchers/test/index.test.ts @@ -0,0 +1,145 @@ +/* eslint-env jest */ +/* eslint-disable jest/no-standalone-expect */ +import { join } from 'path' +import webdriver from 'next-webdriver' +import { fetchViaHTTP } from 'next-test-utils' +import { createNext, FileRef } from 'e2e-utils' +import { NextInstance } from 'test/lib/next-modes/base' + +const itif = (condition: boolean) => (condition ? it : it.skip) + +const isModeDeploy = process.env.NEXT_TEST_MODE === 'deploy' + +describe('Middleware custom matchers', () => { + let next: NextInstance + + beforeAll(async () => { + next = await createNext({ + files: new FileRef(join(__dirname, '../app')), + }) + }) + afterAll(() => next.destroy()) + + const runTests = () => { + it('should match source path', async () => { + const res = await fetchViaHTTP(next.url, '/source-match') + expect(res.status).toBe(200) + expect(res.headers.get('x-from-middleware')).toBeDefined() + }) + + it('should match has header', async () => { + const res = await fetchViaHTTP(next.url, '/has-match-1', undefined, { + headers: { + 'x-my-header': 'hello world!!', + }, + }) + expect(res.status).toBe(200) + expect(res.headers.get('x-from-middleware')).toBeDefined() + + const res2 = await fetchViaHTTP(next.url, '/has-match-1') + expect(res2.status).toBe(404) + }) + + it('should match has query', async () => { + const res = await fetchViaHTTP(next.url, '/has-match-2', { + 'my-query': 'hellooo', + }) + expect(res.status).toBe(200) + expect(res.headers.get('x-from-middleware')).toBeDefined() + + const res2 = await fetchViaHTTP(next.url, '/has-match-2') + expect(res2.status).toBe(404) + }) + + it('should match has cookie', async () => { + const res = await fetchViaHTTP(next.url, '/has-match-3', undefined, { + headers: { + cookie: 'loggedIn=true', + }, + }) + expect(res.status).toBe(200) + expect(res.headers.get('x-from-middleware')).toBeDefined() + + const res2 = await fetchViaHTTP(next.url, '/has-match-3', undefined, { + headers: { + cookie: 'loggedIn=false', + }, + }) + expect(res2.status).toBe(404) + }) + + // Cannot modify host when testing with real deployment + itif(!isModeDeploy)('should match has host', async () => { + const res1 = await fetchViaHTTP(next.url, '/has-match-4') + expect(res1.status).toBe(404) + + const res = await fetchViaHTTP(next.url, '/has-match-4', undefined, { + headers: { + host: 'example.com', + }, + }) + + expect(res.status).toBe(200) + expect(res.headers.get('x-from-middleware')).toBeDefined() + + const res2 = await fetchViaHTTP(next.url, '/has-match-4', undefined, { + headers: { + host: 'example.org', + }, + }) + expect(res2.status).toBe(404) + }) + + it('should match has header value', async () => { + const res = await fetchViaHTTP(next.url, '/has-match-5', undefined, { + headers: { + hasParam: 'with-params', + }, + }) + expect(res.status).toBe(200) + expect(res.headers.get('x-from-middleware')).toBeDefined() + + const res2 = await fetchViaHTTP(next.url, '/has-match-5', undefined, { + headers: { + hasParam: 'without-params', + }, + }) + expect(res2.status).toBe(404) + }) + + // FIXME: Test fails on Vercel deployment for now. + // See https://linear.app/vercel/issue/EC-160/header-value-set-on-middleware-is-not-propagated-on-client-request-of + itif(!isModeDeploy)( + 'should match has query on client routing', + async () => { + const browser = await webdriver(next.url, '/routes') + await browser.eval('window.__TEST_NO_RELOAD = true') + await browser.elementById('has-match-2').click() + const fromMiddleware = await browser + .elementById('from-middleware') + .text() + expect(fromMiddleware).toBe('true') + const noReload = await browser.eval('window.__TEST_NO_RELOAD') + expect(noReload).toBe(true) + } + ) + + itif(!isModeDeploy)( + 'should match has cookie on client routing', + async () => { + const browser = await webdriver(next.url, '/routes') + await browser.addCookie({ name: 'loggedIn', value: 'true' }) + await browser.refresh() + await browser.eval('window.__TEST_NO_RELOAD = true') + await browser.elementById('has-match-3').click() + const fromMiddleware = await browser + .elementById('from-middleware') + .text() + expect(fromMiddleware).toBe('true') + const noReload = await browser.eval('window.__TEST_NO_RELOAD') + expect(noReload).toBe(true) + } + ) + } + runTests() +}) diff --git a/test/e2e/middleware-general/test/index.test.ts b/test/e2e/middleware-general/test/index.test.ts index 44adbdf0f1..a99d002b12 100644 --- a/test/e2e/middleware-general/test/index.test.ts +++ b/test/e2e/middleware-general/test/index.test.ts @@ -99,8 +99,8 @@ describe('Middleware Runtime', () => { next.url, `/_next/static/${next.buildId}/_devMiddlewareManifest.json` ) - const { location } = await res.json() - expect(location).toBe('.*') + const matchers = await res.json() + expect(matchers).toEqual([{ regexp: '.*' }]) }) } @@ -119,7 +119,7 @@ describe('Middleware Runtime', () => { files: ['server/edge-runtime-webpack.js', 'server/middleware.js'], name: 'middleware', page: '/', - regexp: '^/.*$', + matchers: [{ regexp: '^/.*$' }], wasm: [], assets: [], }, diff --git a/test/e2e/middleware-matcher/index.test.ts b/test/e2e/middleware-matcher/index.test.ts index 812e19c694..2049c560eb 100644 --- a/test/e2e/middleware-matcher/index.test.ts +++ b/test/e2e/middleware-matcher/index.test.ts @@ -94,20 +94,19 @@ describe('Middleware can set the matcher in its config', () => { expect(response.headers.get('X-From-Middleware')).toBe('true') }) - it('should load matches in client manifest correctly', async () => { + it('should load matches in client matchers correctly', async () => { const browser = await webdriver(next.url, '/') await check(async () => { - const manifest = await browser.eval( + const matchers = await browser.eval( (global as any).isNextDev - ? 'window.__DEV_MIDDLEWARE_MANIFEST' - : 'window.__MIDDLEWARE_MANIFEST' + ? 'window.__DEV_MIDDLEWARE_MATCHERS' + : 'window.__MIDDLEWARE_MATCHERS' ) - const { location } = manifest - return location && - location.includes('with-middleware') && - location.includes('another-middleware') + return matchers && + matchers.some((m) => m.regexp.includes('with-middleware')) && + matchers.some((m) => m.regexp.includes('another-middleware')) ? 'success' : 'failed' }, 'success') diff --git a/test/e2e/middleware-trailing-slash/test/index.test.ts b/test/e2e/middleware-trailing-slash/test/index.test.ts index 4277ec9ae8..97b85a525e 100644 --- a/test/e2e/middleware-trailing-slash/test/index.test.ts +++ b/test/e2e/middleware-trailing-slash/test/index.test.ts @@ -59,7 +59,7 @@ describe('Middleware Runtime trailing slash', () => { name: 'middleware', env: [], page: '/', - regexp: '^/.*$', + matchers: [{ regexp: '^/.*$' }], wasm: [], assets: [], }, diff --git a/test/e2e/switchable-runtime/index.test.ts b/test/e2e/switchable-runtime/index.test.ts index b7d1ab0efb..f21f94dba2 100644 --- a/test/e2e/switchable-runtime/index.test.ts +++ b/test/e2e/switchable-runtime/index.test.ts @@ -67,7 +67,7 @@ describe('Switchable runtime', () => { `/_next/static/${next.buildId}/_devMiddlewareManifest.json` ) const devMiddlewareManifest = await res.json() - expect(devMiddlewareManifest).toEqual({}) + expect(devMiddlewareManifest).toEqual([]) }) it('should sort edge SSR routes correctly', async () => { @@ -184,7 +184,7 @@ describe('Switchable runtime', () => { ], name: 'pages/api/hello', page: '/api/hello', - regexp: '^/api/hello$', + matchers: [{ regexp: '^/api/hello$' }], wasm: [], }, '/api/edge': { @@ -195,7 +195,7 @@ describe('Switchable runtime', () => { ], name: 'pages/api/edge', page: '/api/edge', - regexp: '^/api/edge$', + matchers: [{ regexp: '^/api/edge$' }], wasm: [], }, }, @@ -328,7 +328,7 @@ describe('Switchable runtime', () => { ], name: 'pages/api/hello', page: '/api/hello', - regexp: '^/api/hello$', + matchers: [{ regexp: '^/api/hello$' }], wasm: [], }, '/api/edge': { @@ -339,7 +339,7 @@ describe('Switchable runtime', () => { ], name: 'pages/api/edge', page: '/api/edge', - regexp: '^/api/edge$', + matchers: [{ regexp: '^/api/edge$' }], wasm: [], }, }, diff --git a/test/integration/custom-routes/test/index.test.js b/test/integration/custom-routes/test/index.test.js index cd5fc57f5d..885683cc40 100644 --- a/test/integration/custom-routes/test/index.test.js +++ b/test/integration/custom-routes/test/index.test.js @@ -986,7 +986,7 @@ const runTests = (isDev = false) => { host: '1', }) - const res2 = await fetchViaHTTP(appPort, '/has-rewrite-3') + const res2 = await fetchViaHTTP(appPort, '/has-rewrite-4') expect(res2.status).toBe(404) }) diff --git a/test/integration/invalid-custom-routes/test/index.test.js b/test/integration/invalid-custom-routes/test/index.test.js index ef42bc9262..67db498fc3 100644 --- a/test/integration/invalid-custom-routes/test/index.test.js +++ b/test/integration/invalid-custom-routes/test/index.test.js @@ -143,10 +143,6 @@ const runTests = () => { `\`destination\` is missing for route {"source":"/hello","permanent":false}` ) - expect(stderr).toContain( - `\`destination\` is missing for route {"source":"/hello","permanent":false}` - ) - expect(stderr).toContain( `\`source\` is not a string for route {"source":123,"destination":"/another","permanent":false}` ) @@ -163,14 +159,6 @@ const runTests = () => { `\`permanent\` is not set to \`true\` or \`false\` for route {"source":"/hello","destination":"/another","permanent":"yes"}` ) - expect(stderr).toContain( - `\`permanent\` is not set to \`true\` or \`false\` for route {"source":"/hello","destination":"/another","permanent":"yes"}` - ) - - expect(stderr).toContain( - `\`permanent\` is not set to \`true\` or \`false\` for route {"source":"/hello","destination":"/another","permanent":"yes"}` - ) - expect(stderr).toContain( `\`destination\` has unnamed params :0 for route {"source":"/hello/world/(.*)","destination":"/:0","permanent":true}` ) diff --git a/test/integration/invalid-middleware-matchers/pages/index.js b/test/integration/invalid-middleware-matchers/pages/index.js new file mode 100644 index 0000000000..0957a987fc --- /dev/null +++ b/test/integration/invalid-middleware-matchers/pages/index.js @@ -0,0 +1 @@ +export default () => 'hi' diff --git a/test/integration/invalid-middleware-matchers/test/index.test.js b/test/integration/invalid-middleware-matchers/test/index.test.js new file mode 100644 index 0000000000..0c918b0fa6 --- /dev/null +++ b/test/integration/invalid-middleware-matchers/test/index.test.js @@ -0,0 +1,169 @@ +/* eslint-env jest */ + +import fs from 'fs-extra' +import { join } from 'path' +import { fetchViaHTTP, findPort, launchApp, nextBuild } from 'next-test-utils' + +let appDir = join(__dirname, '..') +const middlewarePath = join(appDir, 'middleware.js') + +const writeMiddleware = async (matchers) => { + await fs.writeFile( + middlewarePath, + ` + import { NextResponse } from 'next/server' + + export default function middleware() { + return NextResponse.next() + } + + export const config = { + matcher: ${JSON.stringify(matchers)}, + } + ` + ) +} + +let getStderr + +const runTests = () => { + it('should error when source length is exceeded', async () => { + await writeMiddleware([{ source: `/${Array(4096).join('a')}` }]) + const stderr = await getStderr() + expect(stderr).toContain( + '`source` exceeds max built length of 4096 for route {"source":"/aaaaaaaaaaaaaaaaaa' + ) + }) + + it('should error during next build for invalid matchers', async () => { + await writeMiddleware([ + { + // missing source + }, + { + // invalid source + source: 123, + }, + // missing forward slash in source + 'hello', + { + // extra field + source: '/hello', + destination: '/not-allowed', + }, + + // invalid objects + null, + // invalid has items + { + source: '/hello', + has: [ + { + type: 'cookiee', + key: 'loggedIn', + }, + ], + }, + { + source: '/hello', + has: [ + { + type: 'headerr', + }, + { + type: 'queryr', + key: 'hello', + }, + ], + }, + { + source: '/hello', + basePath: false, + }, + { + source: '/hello', + locale: true, + }, + ]) + const stderr = await getStderr() + + expect(stderr).toContain(`\`source\` is missing for route {}`) + + expect(stderr).toContain( + `\`source\` is not a string for route {"source":123}` + ) + + expect(stderr).toContain( + `\`source\` does not start with / for route {"source":"hello"}` + ) + + expect(stderr).toContain( + `invalid field: destination for route {"source":"/hello","destination":"/not-allowed"}` + ) + + expect(stderr).toContain( + `The route null is not a valid object with \`source\`` + ) + + expect(stderr).toContain('Invalid `has` item:') + expect(stderr).toContain( + `invalid type "cookiee" for {"type":"cookiee","key":"loggedIn"}` + ) + expect(stderr).toContain( + `invalid \`has\` item found for route {"source":"/hello","has":[{"type":"cookiee","key":"loggedIn"}]}` + ) + + expect(stderr).toContain('Invalid `has` items:') + expect(stderr).toContain( + `invalid type "headerr", invalid key "undefined" for {"type":"headerr"}` + ) + expect(stderr).toContain( + `invalid type "queryr" for {"type":"queryr","key":"hello"}` + ) + expect(stderr).toContain( + `invalid \`has\` items found for route {"source":"/hello","has":[{"type":"headerr"},{"type":"queryr","key":"hello"}]}` + ) + expect(stderr).toContain(`Valid \`has\` object shape is {`) + expect(stderr).toContain( + `invalid field: basePath for route {"source":"/hello","basePath":false}` + ) + expect(stderr).toContain( + '`locale` must be undefined or false for route {"source":"/hello","locale":true}' + ) + }) +} + +describe('Errors on invalid custom middleware matchers', () => { + afterAll(() => fs.remove(middlewarePath)) + + describe('dev mode', () => { + beforeAll(() => { + getStderr = async () => { + let stderr = '' + const port = await findPort() + await launchApp(appDir, port, { + onStderr(msg) { + stderr += msg + }, + }) + await fetchViaHTTP(port, '/') + // suppress error + .catch(() => {}) + return stderr + } + }) + + runTests() + }) + + describe('production mode', () => { + beforeAll(() => { + getStderr = async () => { + const { stderr } = await nextBuild(appDir, [], { stderr: true }) + return stderr + } + }) + + runTests() + }) +})