feat(next): Support has match and locale option on middleware config (#39257)

## Feature

As the title, support `has` match, `local`  that works the same with the `rewrites` and `redirects` of next.config.js on middleware config. With this PR, you can write the config like the following:

```js
export const config = {
  matcher: [
    "/foo",
    { source: "/bar" },
    {
      source: "/baz",
      has: [
        {
          type: 'header',
          key: 'x-my-header',
          value: 'my-value',
        }
      ]
    },
    {
      source: "/en/asdf",
      locale: false,
     },
  ]
}
```

Also, fixes https://github.com/vercel/next.js/issues/39428

related https://github.com/vercel/edge-functions/issues/178, https://github.com/vercel/edge-functions/issues/179

- [x] Implements an existing feature request or RFC. Make sure the feature request has been accepted for implementation before opening a PR.
- [ ] Related issues linked using `fixes #number`
- [x] Integration tests added
- [ ] Documentation added
- [ ] Telemetry added. In case of a feature if it's used or not.
- [ ] Errors have helpful link attached, see `contributing.md`

Co-authored-by: JJ Kasper <jj@jjsweb.site>
This commit is contained in:
Naoyuki Kanezawa 2022-08-31 23:23:30 +07:00 committed by GitHub
parent 481950c34b
commit b522b94cce
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
38 changed files with 965 additions and 263 deletions

View file

@ -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
)
// check before we process the routes and after to ensure
// they are still valid
checkCustomRoutes(routes, 'middleware')
routes = routes.map((r) => {
let { source } = r
const isRoot = source === '/'
if (i18n?.locales && r.locale !== false) {
source = `/:nextInternalLocale([^/.]{1,})${isRoot ? '' : source}`
}
let matcher: string = matcherOrMatchers
if (!matcher.startsWith('/')) {
throw new Error('`matcher`: path matcher must start with /')
}
const isRoot = matcher === '/'
if (i18n?.locales) {
matcher = `/:nextInternalLocale([^/.]{1,})${isRoot ? '' : matcher}`
}
matcher = `/:nextData(_next/data/[^/]{1,})?${matcher}${
source = `/:nextData(_next/data/[^/]{1,})?${source}${
isRoot
? `(${nextConfig.i18n ? '|\\.json|' : ''}/?index|/?index\\.json)?`
: '(.json)?'
}`
if (nextConfig.basePath) {
matcher = `${nextConfig.basePath}${matcher}`
}
const parsedPage = tryToParsePath(matcher)
if (parsedPage.error) {
throw new Error(`Invalid path matcher: ${matcher}`)
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<MiddlewareConfig> = {}
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

View file

@ -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> = 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<MiddlewareConfig>
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<string, string>, 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,
}
}

View file

@ -849,7 +849,7 @@ export default async function build(
runWebpackSpan,
target,
appDir,
middlewareRegex: entrypoints.middlewareRegex,
middlewareMatchers: entrypoints.middlewareMatchers,
}
const configs = await runWebpackSpan

View file

@ -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<webpack.Configuration> {
const isClient = compilerType === COMPILER_NAMES.client
@ -1673,7 +1674,7 @@ export default async function getBaseWebpackConfig(
hasReactRoot,
isNodeServer,
isEdgeServer,
middlewareRegex,
middlewareMatchers,
hasServerComponents,
})
),

View file

@ -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 {

View file

@ -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}$`), '') || '/',
}

View file

@ -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,

View file

@ -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<string>
@ -30,7 +31,7 @@ export default class PageLoader {
private assetPrefix: string
private promisedSsgManifest: Promise<Set<string>>
private promisedDevPagesManifest?: Promise<string[]>
private promisedMiddlewareManifest?: Promise<{ location: string }>
private promisedMiddlewareMatchers?: Promise<MiddlewareMatcher[]>
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!
}
}
}

View file

@ -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<string, string[]>
__BUILD_MANIFEST_CB?: Function
__MIDDLEWARE_MANIFEST?: { location: string }
__MIDDLEWARE_MATCHERS?: MiddlewareMatcher[]
__MIDDLEWARE_MANIFEST_CB?: Function
}
}

View file

@ -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<string>(['source', 'basePath', 'locale', 'has'])
const allowedKeys = new Set<string>(['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')

View file

@ -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

View file

@ -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<string, string> = {}
const edgeRoutesSet = new Set<string>()
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,
}

View file

@ -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,7 +1675,6 @@ export default class NextNodeServer extends BaseServer {
return { finished: false }
}
if (middleware && middleware.match(normalizedPathname)) {
await this.ensureMiddleware()
const middlewareInfo = this.getEdgeFunctionInfo({
page: middleware.page,
@ -1660,7 +1685,9 @@ export default class NextNodeServer extends BaseServer {
throw new MiddlewareNotFoundError()
}
result = await run({
const method = (params.request.method || 'GET').toUpperCase()
const result = await run({
distDir: this.distDir,
name: middlewareInfo.name,
paths: middlewareInfo.paths,
@ -1682,6 +1709,8 @@ export default class NextNodeServer extends BaseServer {
onWarning: params.onWarning,
})
const allHeaders = new Headers()
for (let [key, value] of result.response.headers) {
if (key !== 'x-middleware-next') {
allHeaders.append(key, value)
@ -1693,7 +1722,6 @@ export default class NextNodeServer extends BaseServer {
console.error(`Uncaught: middleware waitUntil errored`, error)
})
}
}
if (!result) {
this.render404(params.request, params.response, params.parsed)
@ -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 }
}

View file

@ -91,21 +91,27 @@ interface MiddlewareEffectParams<T extends FetchDataOutput> {
router: Router
}
function matchesMiddleware<T extends FetchDataOutput>(
export async function matchesMiddleware<T extends FetchDataOutput>(
options: MiddlewareEffectParams<T>
): Promise<boolean> {
return Promise.resolve(options.router.pageLoader.getMiddleware()).then(
(middleware) => {
const matchers = await Promise.resolve(
options.router.pageLoader.getMiddleware()
)
if (!matchers) return false
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 regex = middleware?.location
return (
!!regex && new RegExp(regex).test(addLocale(cleanedAs, options.locale))
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)
)
}

View file

@ -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
}
}

View file

@ -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(

View file

@ -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',
},
],
}

View file

@ -0,0 +1,3 @@
module.exports = {
basePath: '/docs',
}

View file

@ -0,0 +1,14 @@
export default (props) => (
<>
<h1>home</h1>
<div id="from-middleware">{props.fromMiddleware}</div>
</>
)
export async function getServerSideProps({ res }) {
return {
props: {
fromMiddleware: res.getHeader('x-from-middleware') || null,
},
}
}

View file

@ -0,0 +1,11 @@
import Link from 'next/link'
export default (props) => (
<ul>
<li>
<Link href="/hello">
<a id="hello">/hello</a>
</Link>
</li>
</ul>
)

View file

@ -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)
})
})

View file

@ -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,
},
],
}

View file

@ -0,0 +1,6 @@
module.exports = {
i18n: {
locales: ['en', 'nl-NL'],
defaultLocale: 'en',
},
}

View file

@ -0,0 +1,14 @@
export default (props) => (
<>
<h1>home</h1>
<div id="from-middleware">{props.fromMiddleware}</div>
</>
)
export async function getServerSideProps({ res }) {
return {
props: {
fromMiddleware: res.getHeader('x-from-middleware') || null,
},
}
}

View file

@ -0,0 +1,26 @@
import Link from 'next/link'
export default (props) => (
<ul>
<li>
<Link href="/hello">
<a id="hello">/hello</a>
</Link>
</li>
<li>
<Link href="/en/hello">
<a id="en_hello">/en/hello</a>
</Link>
</li>
<li>
<Link href="/nl-NL/hello">
<a id="nl-NL_hello">/nl-NL/hello</a>
</Link>
</li>
<li>
<Link href="/nl-NL/about">
<a id="nl-NL_about">/nl-NL/about</a>
</Link>
</li>
</ul>
)

View file

@ -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)
}
)
})

View file

@ -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: '(?<myHeader>.*)',
},
],
},
{
source: '/has-match-2',
has: [
{
type: 'query',
key: 'my-query',
},
],
},
{
source: '/has-match-3',
has: [
{
type: 'cookie',
key: 'loggedIn',
value: '(?<loggedIn>true)',
},
],
},
{
source: '/has-match-4',
has: [
{
type: 'host',
value: 'example.com',
},
],
},
{
source: '/has-match-5',
has: [
{
type: 'header',
key: 'hasParam',
value: 'with-params',
},
],
},
],
}

View file

@ -0,0 +1,14 @@
export default (props) => (
<>
<h1>home</h1>
<div id="from-middleware">{props.fromMiddleware}</div>
</>
)
export async function getServerSideProps({ res }) {
return {
props: {
fromMiddleware: res.getHeader('x-from-middleware') || null,
},
}
}

View file

@ -0,0 +1,16 @@
import Link from 'next/link'
export default (props) => (
<ul>
<li>
<Link href="/has-match-2?my-query=hellooo">
<a id="has-match-2">has-match-2</a>
</Link>
</li>
<li>
<Link href="/has-match-3">
<a id="has-match-3">has-match-3</a>
</Link>
</li>
</ul>
)

View file

@ -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()
})

View file

@ -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: [],
},

View file

@ -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')

View file

@ -59,7 +59,7 @@ describe('Middleware Runtime trailing slash', () => {
name: 'middleware',
env: [],
page: '/',
regexp: '^/.*$',
matchers: [{ regexp: '^/.*$' }],
wasm: [],
assets: [],
},

View file

@ -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: [],
},
},

View file

@ -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)
})

View file

@ -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}`
)

View file

@ -0,0 +1 @@
export default () => 'hi'

View file

@ -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()
})
})