11import "./server-globals.js" ;
22import type { NextI18nConfig } from "../config/next-config.js" ;
3+ import { normalizeHost } from "../config/request-context.js" ;
34import { normalizePathnameForRouteMatchStrict } from "../routing/utils.js" ;
45import path from "pathslash" ;
56import {
@@ -14,7 +15,11 @@ import {
1415 MIDDLEWARE_NEXT_HEADER ,
1516 MIDDLEWARE_REWRITE_HEADER ,
1617} from "./headers.js" ;
17- import { matchesMiddleware , type MatcherConfig } from "./middleware-matcher.js" ;
18+ import {
19+ matchesMiddleware ,
20+ type MatcherConfig ,
21+ type MiddlewareLocaleMatchContext ,
22+ } from "./middleware-matcher.js" ;
1823import { shouldKeepMiddlewareHeader } from "../utils/middleware-request-headers.js" ;
1924import { processMiddlewareHeaders } from "./request-pipeline.js" ;
2025import { badRequestResponse , internalServerErrorResponse } from "./http-error-responses.js" ;
@@ -25,6 +30,7 @@ import {
2530 removeTrailingSlash ,
2631 stripBasePath ,
2732} from "../utils/base-path.js" ;
33+ import { normalizeDefaultLocalePathname } from "./pages-i18n.js" ;
2834
2935export type MiddlewareModule = Record < string , unknown > ;
3036
@@ -320,15 +326,16 @@ export async function executeMiddleware(
320326 if ( normalizedPathname instanceof Response ) {
321327 return { continue : false , response : normalizedPathname } ;
322328 }
329+ const requestUrl = new URL ( options . request . url ) ;
330+ const requestPathname = requestUrl . pathname ;
323331
324332 // Default: derive in-basePath state from the request URL. The Pages
325333 // prod/deploy adapters pass the original URL — prefixed for in-basePath
326334 // requests, bare for out-of-basePath requests — so the URL itself is the
327335 // source of truth. Callers that pass pre-stripped URLs (dev server, App
328336 // Router) override this with an explicit `hadBasePath: true`.
329337 const hadBasePath =
330- options . hadBasePath ??
331- ( ! options . basePath || hasBasePath ( new URL ( options . request . url ) . pathname , options . basePath ) ) ;
338+ options . hadBasePath ?? ( ! options . basePath || hasBasePath ( requestPathname , options . basePath ) ) ;
332339
333340 // Matcher patterns use basePath-stripped paths (e.g. /about, not /root/about),
334341 // matching Next.js behavior where the matcher is evaluated against the path
@@ -341,15 +348,104 @@ export async function executeMiddleware(
341348 ? stripBasePath ( normalizedPathname , options . basePath )
342349 : normalizedPathname ;
343350 const matchPathname = basePathStrippedPathname ;
351+ // Next.js tests the normalized encoded pathname first, then retries after
352+ // decoding the full path once. Testing only a segment-decoded form lets
353+ // percent-encoded line terminators turn into characters that `.` cannot
354+ // match, while preserving encoded delimiters misses matchers that Next.js
355+ // evaluates against their decoded path structure.
356+ // https://github.com/vercel/next.js/blob/canary/packages/next/src/server/next-server.ts
357+ // Next.js removes the request pathname's terminal slash before evaluating
358+ // the compiled middleware matcher. The matcher compiler still appends its
359+ // own optional terminal delimiter, so a source without a slash matches both
360+ // request spellings while a source that includes a slash remains distinct.
361+ // https://github.com/vercel/next.js/blob/v16.2.6/packages/next/src/server/next-server.ts
362+ const encodedRequestPathname = removeTrailingSlash ( normalizePath ( requestPathname ) ) ;
363+ const matcher = middlewareMatcher ( options . module ) ;
364+ const prepareMatcherPathname = ( candidate : string ) : string | null => {
365+ if ( ! options . basePath ) return candidate ;
366+ if ( hasBasePath ( candidate , options . basePath ) ) {
367+ return stripBasePath ( candidate , options . basePath ) ;
368+ }
369+ if (
370+ candidate . length === options . basePath . length + 1 &&
371+ candidate . startsWith ( options . basePath ) &&
372+ ( candidate . endsWith ( "?" ) || candidate . endsWith ( "#" ) )
373+ ) {
374+ return "/" ;
375+ }
376+ // App Router and Pages dev may pass a URL that the adapter already
377+ // stripped after recording that it crossed the configured basePath.
378+ if ( options . hadBasePath === true ) return candidate ;
379+ // Next.js prefixes configured matchers with basePath at build time. Keep
380+ // default middleware eligible on absolute paths, but custom matchers must
381+ // not apply outside the basePath.
382+ return matcher === undefined ? candidate : null ;
383+ } ;
384+ const encodedMatchPathname = prepareMatcherPathname ( encodedRequestPathname ) ;
385+ let decodedMatchPathname = encodedMatchPathname ;
386+ try {
387+ if ( encodedMatchPathname !== null ) {
388+ decodedMatchPathname = decodeURIComponent ( encodedMatchPathname ) ;
389+ } else if ( ! options . i18nConfig ) {
390+ // Without i18n, Next.js can discover an encoded basePath on the decoded
391+ // matcher attempt. With i18n, default-locale insertion has already made
392+ // that path ineligible for the compiled basePath-prefixed matcher.
393+ decodedMatchPathname = prepareMatcherPathname ( decodeURIComponent ( encodedRequestPathname ) ) ;
394+ }
395+ } catch {
396+ // Match Next.js: malformed encoding is non-fatal for matcher eligibility.
397+ }
344398
345- if (
346- ! matchesMiddleware (
347- matchPathname ,
348- middlewareMatcher ( options . module ) ,
399+ let localeContext : MiddlewareLocaleMatchContext | undefined ;
400+ if ( options . i18nConfig && encodedMatchPathname !== null ) {
401+ const hostname = normalizeHost ( options . request . headers . get ( "host" ) , requestUrl . hostname ) ;
402+ const firstSegment = encodedMatchPathname . split ( "/" , 3 ) [ 1 ] ;
403+ const hasLiteralLocale =
404+ firstSegment !== undefined &&
405+ options . i18nConfig . locales . some (
406+ ( locale ) => locale . toLowerCase ( ) === firstSegment . toLowerCase ( ) ,
407+ ) ;
408+ if ( hasLiteralLocale ) {
409+ localeContext = { kind : "literal" } ;
410+ } else {
411+ const localeDefaultedPathname = normalizeDefaultLocalePathname (
412+ encodedMatchPathname ,
413+ options . i18nConfig ,
414+ { hostname } ,
415+ ) ;
416+ localeContext =
417+ localeDefaultedPathname === encodedMatchPathname
418+ ? { kind : "internal" }
419+ : {
420+ defaultLocale : normalizeDefaultLocalePathname ( "/" , options . i18nConfig , {
421+ hostname,
422+ } ) . slice ( 1 ) ,
423+ kind : "defaulted" ,
424+ } ;
425+ }
426+ }
427+ const encodedMatches =
428+ encodedMatchPathname !== null &&
429+ matchesMiddleware (
430+ encodedMatchPathname ,
431+ matcher ,
349432 options . request ,
350433 options . i18nConfig ,
351- )
352- ) {
434+ localeContext ,
435+ ) ;
436+ const decodedMatches =
437+ ! encodedMatches &&
438+ decodedMatchPathname !== null &&
439+ decodedMatchPathname !== encodedMatchPathname &&
440+ matchesMiddleware (
441+ decodedMatchPathname ,
442+ matcher ,
443+ options . request ,
444+ options . i18nConfig ,
445+ localeContext ,
446+ ) ;
447+
448+ if ( ! encodedMatches && ! decodedMatches ) {
353449 return { continue : true } ;
354450 }
355451
0 commit comments