From 6b9001934d39f35a5820bc2b407085fcdf583d5f Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Sat, 15 Aug 2026 13:07:22 +0200 Subject: [PATCH 1/3] Remove server route matcher stack (#94157) ### What? Remove the legacy server route matcher managers, route matcher providers, and server route matcher classes. Move route definition and params metadata into fsChecker output and request metadata instead. ### Why? The filesystem checker already owns the normalized route lookup path in production. Keeping a separate matcher manager stack duplicates route inventory, adds reload plumbing, and makes dev and production behavior harder to keep aligned. ### How? - Build pages and app route definitions in fsChecker from production manifests and from the dev route inventory. - Thread matched route definitions and params through resolve-routes and router-server request metadata into base-server and next-server. - Remove reloadMatchers propagation and delete the legacy matcher manager, provider, and matcher implementations and tests. - Preserve dev ensurePage behavior, including legacy custom-server render methods, and Pages API loading by using matched route definitions while loading compiled dev modules where needed. ### Verification - `pnpm test-start-webpack test/e2e/custom-routes/custom-routes.test.ts` - `pnpm test-start-turbo test/e2e/custom-routes/custom-routes.test.ts` - `pnpm test-dev-webpack test/e2e/custom-routes/custom-routes.test.ts` - `pnpm test-dev-turbo test/e2e/custom-routes/custom-routes.test.ts` - `pnpm test-dev-webpack test/e2e/on-request-error/dynamic-routes/dynamic-routes.test.ts` - `pnpm test-dev-turbo test/e2e/on-request-error/dynamic-routes/dynamic-routes.test.ts` - `pnpm test-start-webpack test/e2e/app-dir/app-routes/app-custom-routes.test.ts` - `pnpm test-start-webpack test/e2e/app-dir/edge-route-rewrite/edge-route-rewrite.test.ts` - `pnpm test-start-webpack test/e2e/middleware-rewrites/test/index.test.ts` - `pnpm test-start-webpack test/e2e/middleware-matcher/index.test.ts test/e2e/middleware-custom-matchers-basepath/test/index.test.ts test/e2e/app-dir/rewrites-redirects/rewrites-redirects.test.ts` - `pnpm test-start-webpack test/e2e/api-catch-all/api-catch-all.test.ts test/e2e/basepath-root-catch-all/basepath-root-catch-all.test.ts test/production/root-catchall-cache/root-catchall-cache.test.ts` - `pnpm test-start-webpack test/e2e/i18n-beforefiles-rewrite/i18n-beforefiles-rewrite.test.ts` - `pnpm test-start-webpack test/e2e/basepath/redirect-and-rewrite.test.ts` - `pnpm test-dev-webpack test/e2e/custom-server/custom-server.test.ts` - `pnpm test-start-webpack test/e2e/custom-server/custom-server.test.ts` - `pnpm --filter=next types` - `pnpm --filter=next build` - `git diff --check` --- packages/next/errors.json | 3 +- .../next/src/build/adapter/build-complete.ts | 38 +- packages/next/src/build/index.ts | 40 +- .../src/build/normalize-catchall-routes.ts | 102 +--- .../request-insights/trace-viewer.test.ts | 28 +- .../request-insights/trace-viewer.ts | 2 - packages/next/src/server/base-server.ts | 549 +++++++++++++----- .../src/server/dev/hot-reloader-turbopack.ts | 3 - .../next/src/server/dev/next-dev-server.ts | 112 +--- packages/next/src/server/lib/router-server.ts | 67 +++ .../lib/router-utils/build-data-route.test.ts | 44 +- .../lib/router-utils/build-data-route.ts | 17 + .../src/server/lib/router-utils/filesystem.ts | 309 +++++++++- .../router-utils/normalize-catchall-routes.ts | 111 ++++ .../server/lib/router-utils/resolve-routes.ts | 10 +- .../lib/router-utils/setup-dev-bundler.ts | 188 +++++- .../next/src/server/lib/router-utils/types.ts | 1 - .../next/src/server/lib/trace/constants.ts | 7 - packages/next/src/server/next-server.ts | 88 ++- .../built/app/app-page-normalizer.ts | 2 +- .../default-route-matcher-manager.test.ts | 320 ---------- .../default-route-matcher-manager.ts | 292 ---------- .../dev-route-matcher-manager.ts | 132 ----- .../route-matcher-manager.ts | 66 --- .../app-page-route-matcher-provider.test.ts | 106 ---- .../app-page-route-matcher-provider.ts | 60 -- .../app-route-route-matcher-provider.test.ts | 69 --- .../app-route-route-matcher-provider.ts | 47 -- ...ev-app-page-route-matcher-provider.test.ts | 110 ---- .../dev-app-page-route-matcher-provider.ts | 103 ---- ...v-app-route-route-matcher-provider.test.ts | 87 --- .../dev-app-route-route-matcher-provider.ts | 138 ----- ...v-pages-api-route-matcher-provider.test.ts | 89 --- .../dev-pages-api-route-matcher-provider.ts | 91 --- .../dev-pages-route-matcher-provider.test.ts | 89 --- .../dev/dev-pages-route-matcher-provider.ts | 91 --- .../dev/file-cache-route-matcher-provider.ts | 26 - .../file-reader/batched-file-reader.test.ts | 66 --- .../file-reader/batched-file-reader.ts | 125 ---- .../file-reader/default-file-reader.ts | 53 -- .../dev/helpers/file-reader/file-reader.ts | 8 - .../helpers/cached-route-matcher-provider.ts | 40 -- .../manifest-loaders/manifest-loader.ts | 5 - .../manifest-loaders/node-manifest-loader.ts | 21 - .../server-manifest-loader.ts | 9 - .../manifest-route-matcher-provider.ts | 17 - .../pages-api-route-matcher-provider.test.ts | 69 --- .../pages-api-route-matcher-provider.ts | 72 --- .../pages-route-matcher-provider.test.ts | 185 ------ .../pages-route-matcher-provider.ts | 82 --- .../route-matcher-provider.ts | 5 - .../route-matchers/app-page-route-matcher.ts | 8 - .../route-matchers/app-route-route-matcher.ts | 4 - .../route-matchers/locale-route-matcher.ts | 85 --- .../route-matchers/pages-api-route-matcher.ts | 7 - .../route-matchers/pages-route-matcher.ts | 7 - .../server/route-matchers/route-matcher.ts | 66 --- .../server/web/edge-route-module-wrapper.ts | 11 +- .../shared/lib/router/utils/app-paths.test.ts | 71 ++- .../src/shared/lib/router/utils/app-paths.ts | 39 +- .../build-error-logs/build-error-logs.test.ts | 2 +- .../app/api/route-preparation/route.ts | 3 - .../app/layout.tsx | 8 - .../app/page.tsx | 3 - .../next.config.js | 10 - ...request-insights-route-preparation.test.ts | 255 -------- .../adapter-dynamic-metadata.test.ts | 7 +- .../multiple-roots.test.ts | 2 +- .../catchall-parallel-routes-group.test.ts | 39 +- test/e2e/custom-server/custom-server.test.ts | 10 +- test/e2e/custom-server/server.js | 6 +- .../client-trace-metadata.test.ts | 7 +- .../adapter-config/adapter-config.test.ts | 5 + .../app/node-app/@dialog/page.tsx | 3 + .../app-dir/empty-resume/empty-resume.test.ts | 13 + .../pages/dynamic/[...path].js | 16 + .../test/index.test.ts | 41 ++ .../required-server-files-i18n.test.ts | 64 ++ 78 files changed, 1561 insertions(+), 3625 deletions(-) create mode 100644 packages/next/src/server/lib/router-utils/normalize-catchall-routes.ts delete mode 100644 packages/next/src/server/route-matcher-managers/default-route-matcher-manager.test.ts delete mode 100644 packages/next/src/server/route-matcher-managers/default-route-matcher-manager.ts delete mode 100644 packages/next/src/server/route-matcher-managers/dev-route-matcher-manager.ts delete mode 100644 packages/next/src/server/route-matcher-managers/route-matcher-manager.ts delete mode 100644 packages/next/src/server/route-matcher-providers/app-page-route-matcher-provider.test.ts delete mode 100644 packages/next/src/server/route-matcher-providers/app-page-route-matcher-provider.ts delete mode 100644 packages/next/src/server/route-matcher-providers/app-route-route-matcher-provider.test.ts delete mode 100644 packages/next/src/server/route-matcher-providers/app-route-route-matcher-provider.ts delete mode 100644 packages/next/src/server/route-matcher-providers/dev/dev-app-page-route-matcher-provider.test.ts delete mode 100644 packages/next/src/server/route-matcher-providers/dev/dev-app-page-route-matcher-provider.ts delete mode 100644 packages/next/src/server/route-matcher-providers/dev/dev-app-route-route-matcher-provider.test.ts delete mode 100644 packages/next/src/server/route-matcher-providers/dev/dev-app-route-route-matcher-provider.ts delete mode 100644 packages/next/src/server/route-matcher-providers/dev/dev-pages-api-route-matcher-provider.test.ts delete mode 100644 packages/next/src/server/route-matcher-providers/dev/dev-pages-api-route-matcher-provider.ts delete mode 100644 packages/next/src/server/route-matcher-providers/dev/dev-pages-route-matcher-provider.test.ts delete mode 100644 packages/next/src/server/route-matcher-providers/dev/dev-pages-route-matcher-provider.ts delete mode 100644 packages/next/src/server/route-matcher-providers/dev/file-cache-route-matcher-provider.ts delete mode 100644 packages/next/src/server/route-matcher-providers/dev/helpers/file-reader/batched-file-reader.test.ts delete mode 100644 packages/next/src/server/route-matcher-providers/dev/helpers/file-reader/batched-file-reader.ts delete mode 100644 packages/next/src/server/route-matcher-providers/dev/helpers/file-reader/default-file-reader.ts delete mode 100644 packages/next/src/server/route-matcher-providers/dev/helpers/file-reader/file-reader.ts delete mode 100644 packages/next/src/server/route-matcher-providers/helpers/cached-route-matcher-provider.ts delete mode 100644 packages/next/src/server/route-matcher-providers/helpers/manifest-loaders/manifest-loader.ts delete mode 100644 packages/next/src/server/route-matcher-providers/helpers/manifest-loaders/node-manifest-loader.ts delete mode 100644 packages/next/src/server/route-matcher-providers/helpers/manifest-loaders/server-manifest-loader.ts delete mode 100644 packages/next/src/server/route-matcher-providers/manifest-route-matcher-provider.ts delete mode 100644 packages/next/src/server/route-matcher-providers/pages-api-route-matcher-provider.test.ts delete mode 100644 packages/next/src/server/route-matcher-providers/pages-api-route-matcher-provider.ts delete mode 100644 packages/next/src/server/route-matcher-providers/pages-route-matcher-provider.test.ts delete mode 100644 packages/next/src/server/route-matcher-providers/pages-route-matcher-provider.ts delete mode 100644 packages/next/src/server/route-matcher-providers/route-matcher-provider.ts delete mode 100644 packages/next/src/server/route-matchers/app-page-route-matcher.ts delete mode 100644 packages/next/src/server/route-matchers/app-route-route-matcher.ts delete mode 100644 packages/next/src/server/route-matchers/locale-route-matcher.ts delete mode 100644 packages/next/src/server/route-matchers/pages-api-route-matcher.ts delete mode 100644 packages/next/src/server/route-matchers/pages-route-matcher.ts delete mode 100644 packages/next/src/server/route-matchers/route-matcher.ts delete mode 100644 test/development/app-dir/request-insights-route-preparation/app/api/route-preparation/route.ts delete mode 100644 test/development/app-dir/request-insights-route-preparation/app/layout.tsx delete mode 100644 test/development/app-dir/request-insights-route-preparation/app/page.tsx delete mode 100644 test/development/app-dir/request-insights-route-preparation/next.config.js delete mode 100644 test/development/app-dir/request-insights-route-preparation/request-insights-route-preparation.test.ts create mode 100644 test/production/adapter-config/app/node-app/@dialog/page.tsx create mode 100644 test/production/required-server-files-ssr-404/pages/dynamic/[...path].js diff --git a/packages/next/errors.json b/packages/next/errors.json index 9e45f7001e01..d19cb00827b6 100644 --- a/packages/next/errors.json +++ b/packages/next/errors.json @@ -1472,5 +1472,6 @@ "1471": "Cannot resolve a dynamic segment that has no param value: the response provides no rendered pathname to parse it from.", "1472": "Cannot convert a server response with no transport data and no base tree.", "1473": "Invariant: image cache entry \"%s\" is empty", - "1474": "Invariant: cannot write an empty buffer to the image cache" + "1474": "Invariant: cannot write an empty buffer to the image cache", + "1475": "Invariant: no direct app page entry found for %s" } diff --git a/packages/next/src/build/adapter/build-complete.ts b/packages/next/src/build/adapter/build-complete.ts index 4d6e4cda620d..5c7d6db9c12a 100644 --- a/packages/next/src/build/adapter/build-complete.ts +++ b/packages/next/src/build/adapter/build-complete.ts @@ -11,7 +11,10 @@ import { recursiveReadDir } from '../../lib/recursive-readdir' import { isDynamicRoute } from '../../shared/lib/router/utils' import type { Revalidate } from '../../server/lib/cache-control' import type { NextConfigComplete } from '../../server/config-shared' -import { normalizeAppPath } from '../../shared/lib/router/utils/app-paths' +import { + normalizeAppPath, + selectAppPageEntry, +} from '../../shared/lib/router/utils/app-paths' import { AdapterOutputType, type PHASE_TYPE } from '../../shared/lib/constants' import { normalizePagePath } from '../../shared/lib/page-path/normalize-page-path' import { @@ -194,6 +197,37 @@ type PrerenderClassification = htmlSize?: never } +// App paths sharing a pathname collapse into one Adapter output. Put the +// canonical entry first because later paths only merge assets into that output. +function orderAppPageKeysByEntry(appPageKeys: readonly string[]): string[] { + const appPathsByPathname = new Map() + + for (const page of appPageKeys) { + const pathname = normalizeAppPath(page) + const appPaths = appPathsByPathname.get(pathname) + + if (appPaths) { + appPaths.push(page) + } else { + appPathsByPathname.set(pathname, [page]) + } + } + + const orderedAppPageKeys: string[] = [] + for (const [pathname, appPaths] of appPathsByPathname) { + const entryPage = selectAppPageEntry(pathname, appPaths) + orderedAppPageKeys.push(entryPage) + + for (const appPath of appPaths) { + if (appPath !== entryPage) { + orderedAppPageKeys.push(appPath) + } + } + } + + return orderedAppPageKeys +} + export interface AdapterOutput { /** * `PAGES` represents all the React pages that are under `pages/`. @@ -1092,7 +1126,7 @@ export async function handleBuildComplete({ const appDistDir = path.join(distDir, 'server', 'app') if (appPageKeys) { - for (const page of appPageKeys) { + for (const page of orderAppPageKeysByEntry(appPageKeys)) { if (middlewareManifest.functions.hasOwnProperty(page)) { continue } diff --git a/packages/next/src/build/index.ts b/packages/next/src/build/index.ts index 17c3fc3af134..9b297077e69b 100644 --- a/packages/next/src/build/index.ts +++ b/packages/next/src/build/index.ts @@ -151,7 +151,11 @@ import { installBindings } from './swc/install-bindings' import { getNamedRouteRegex } from '../shared/lib/router/utils/route-regex' import { getFilesInDir } from '../lib/get-files-in-dir' import { eventSwcPlugins } from '../telemetry/events/swc-plugins' -import { normalizeAppPath } from '../shared/lib/router/utils/app-paths' +import { + compareAppPaths, + normalizeAppPath, + selectAppPageEntry, +} from '../shared/lib/router/utils/app-paths' import { ACTION_HEADER, type NEXT_ROUTER_PREFETCH_HEADER, @@ -2186,8 +2190,24 @@ export default async function build( emittedAppPageKeySet.has(appPageKey) ) + const appPathsByPathname = new Map() for (const key in appPathsManifest) { - appPathRoutes[key] = normalizeAppPath(key) + const pathname = normalizeAppPath(key) + const routeAppPaths = appPathsByPathname.get(pathname) + if (routeAppPaths) { + routeAppPaths.push(key) + } else { + appPathsByPathname.set(pathname, [key]) + } + } + + // Legacy deployment builders collapse this manifest by pathname using + // the final entry. Keep that entry aligned with selectAppPageEntry so + // the traced module and the module loaded at runtime cannot diverge. + for (const [pathname, routeAppPaths] of appPathsByPathname) { + for (const key of routeAppPaths.sort(compareAppPaths)) { + appPathRoutes[key] = pathname + } } await writeManifest( @@ -2373,6 +2393,7 @@ export default async function build( let originalAppPath: string | undefined if (pageType === 'app' && mappedAppPages) { + const originalAppPaths: string[] = [] for (const [originalPath, normalizedPath] of Object.entries( appPathRoutes )) { @@ -2380,14 +2401,17 @@ export default async function build( normalizedPath === page && mappedAppPages[originalPath] ) { - pagePath = mappedAppPages[originalPath].replace( - /^private-next-app-dir/, - '' - ) - originalAppPath = originalPath - break + originalAppPaths.push(originalPath) } } + + if (originalAppPaths.length > 0) { + originalAppPath = selectAppPageEntry(page, originalAppPaths) + pagePath = mappedAppPages[originalAppPath].replace( + /^private-next-app-dir/, + '' + ) + } } const pageFilePath = isAppBuiltinPage(pagePath) diff --git a/packages/next/src/build/normalize-catchall-routes.ts b/packages/next/src/build/normalize-catchall-routes.ts index bd5a7a93320f..23abaee3c525 100644 --- a/packages/next/src/build/normalize-catchall-routes.ts +++ b/packages/next/src/build/normalize-catchall-routes.ts @@ -1,101 +1 @@ -import { isInterceptionRouteAppPath } from '../shared/lib/router/utils/interception-routes' -import { AppPathnameNormalizer } from '../server/normalizers/built/app/app-pathname-normalizer' - -/** - * This function will transform the appPaths in order to support catch-all routes and parallel routes. - * It will traverse the appPaths, looking for catch-all routes and try to find parallel routes that could match - * the catch-all. If it finds a match, it will add the catch-all to the parallel route's list of possible routes. - * - * @param appPaths The appPaths to transform - */ -export function normalizeCatchAllRoutes( - appPaths: Record, - normalizer = new AppPathnameNormalizer() -) { - const catchAllRoutes = [ - ...new Set( - Object.values(appPaths) - .flat() - .filter(isCatchAllRoute) - // Sorting is important because we want to match the most specific path. - .sort((a, b) => b.split('/').length - a.split('/').length) - ), - ] - - // interception routes should only be matched by a single entrypoint - // we don't want to push a catch-all route to an interception route - // because it would mean the interception would be handled by the wrong page component - const filteredAppPaths = Object.keys(appPaths).filter( - (route) => !isInterceptionRouteAppPath(route) - ) - - for (const appPath of filteredAppPaths) { - for (const catchAllRoute of catchAllRoutes) { - const normalizedCatchAllRoute = normalizer.normalize(catchAllRoute) - const normalizedCatchAllRouteBasePath = normalizedCatchAllRoute.slice( - 0, - normalizedCatchAllRoute.search(catchAllRouteRegex) - ) - - if ( - // check if the appPath could match the catch-all - appPath.startsWith(normalizedCatchAllRouteBasePath) && - // check if there's not already a slot value that could match the catch-all - !appPaths[appPath].some((path) => hasMatchedSlots(path, catchAllRoute)) - ) { - // optional catch-all routes are not currently supported, but leaving this logic in place - // for when they are eventually supported. - if (isOptionalCatchAll(catchAllRoute)) { - // optional catch-all routes should match both the root segment and any segment after it - // for example, `/[[...slug]]` should match `/` and `/foo` and `/foo/bar` - appPaths[appPath].push(catchAllRoute) - } else if (isCatchAll(catchAllRoute)) { - // regular catch-all (single bracket) should only match segments after it - // for example, `/[...slug]` should match `/foo` and `/foo/bar` but not `/` - if (normalizedCatchAllRouteBasePath !== appPath) { - appPaths[appPath].push(catchAllRoute) - } - } - } - } - } -} - -function hasMatchedSlots(path1: string, path2: string): boolean { - const slots1 = path1.split('/').filter(isMatchableSlot) - const slots2 = path2.split('/').filter(isMatchableSlot) - - // if the catch-all route does not have the same number of slots as the app path, it can't match - if (slots1.length !== slots2.length) return false - - // compare the slots in both paths. For there to be a match, each slot must be the same - for (let i = 0; i < slots1.length; i++) { - if (slots1[i] !== slots2[i]) return false - } - - return true -} - -/** - * Returns true for slots that should be considered when checking for match compatibility. - * Excludes children slots because these are similar to having a segment-level `page` - * which would cause a slot length mismatch when comparing it to a catch-all route. - */ -function isMatchableSlot(segment: string): boolean { - return segment.startsWith('@') && segment !== '@children' -} - -const catchAllRouteRegex = /\[?\[\.\.\./ - -function isCatchAllRoute(pathname: string): boolean { - // Optional catch-all slots are not currently supported, and as such they are not considered when checking for match compatibility. - return !isOptionalCatchAll(pathname) && isCatchAll(pathname) -} - -function isOptionalCatchAll(pathname: string): boolean { - return pathname.includes('[[...') -} - -function isCatchAll(pathname: string): boolean { - return pathname.includes('[...') -} +export { normalizeCatchAllRoutes } from '../server/lib/router-utils/normalize-catchall-routes' diff --git a/packages/next/src/next-devtools/dev-overlay/components/request-insights/trace-viewer.test.ts b/packages/next/src/next-devtools/dev-overlay/components/request-insights/trace-viewer.test.ts index 845f3d2e5562..43beadf16e84 100644 --- a/packages/next/src/next-devtools/dev-overlay/components/request-insights/trace-viewer.test.ts +++ b/packages/next/src/next-devtools/dev-overlay/components/request-insights/trace-viewer.test.ts @@ -271,36 +271,16 @@ describe('request insights trace viewer', () => { durationMs: 5, attributes: { 'next.span_type': 'NextNodeServer.matchRoute' }, }, - { - name: 'prepare route', - spanId: 'ensure', - parentSpanId: 'match', - startTime: 107, - durationMs: 2, - attributes: { - 'next.span_type': 'DevRouteMatcherManager.ensureRoute', - }, - }, { name: 'compile route', spanId: 'compile-route', - parentSpanId: 'ensure', + parentSpanId: 'match', startTime: 107.1, durationMs: 1.5, attributes: { 'next.span_type': 'DevBundlerService.ensurePage', }, }, - { - name: 'reload route matchers', - spanId: 'reload-matchers', - parentSpanId: 'match', - startTime: 109, - durationMs: 1, - attributes: { - 'next.span_type': 'DevRouteMatcherManager.reloadMatchers', - }, - }, { name: 'render', spanId: 'base-render', @@ -402,9 +382,7 @@ describe('request insights trace viewer', () => { ).toEqual([ { label: 'GET', depth: 0 }, { label: 'match route', depth: 1 }, - { label: 'prepare route', depth: 2 }, - { label: 'compile route', depth: 3 }, - { label: 'reload route matchers', depth: 2 }, + { label: 'compile route', depth: 2 }, { label: 'render', depth: 2 }, { label: 'load components', depth: 3 }, { label: 'prepare app page response', depth: 3 }, @@ -419,9 +397,7 @@ describe('request insights trace viewer', () => { 'GET', 'prepare request', 'match route', - 'prepare route', 'compile route', - 'reload route matchers', 'render', 'resolve page components', 'load components', diff --git a/packages/next/src/next-devtools/dev-overlay/components/request-insights/trace-viewer.ts b/packages/next/src/next-devtools/dev-overlay/components/request-insights/trace-viewer.ts index 5b57de87dc8d..d8350819a9d6 100644 --- a/packages/next/src/next-devtools/dev-overlay/components/request-insights/trace-viewer.ts +++ b/packages/next/src/next-devtools/dev-overlay/components/request-insights/trace-viewer.ts @@ -30,8 +30,6 @@ const DEFAULT_VISIBLE_SPAN_TYPES = new Set([ 'BaseServer.handleRequest', 'Middleware.execute', 'NextNodeServer.matchRoute', - 'DevRouteMatcherManager.ensureRoute', - 'DevRouteMatcherManager.reloadMatchers', 'DevBundlerService.ensurePage', 'BaseServer.render', 'LoadComponents.loadComponents', diff --git a/packages/next/src/server/base-server.ts b/packages/next/src/server/base-server.ts index 613ca44468b1..60bb9fb11054 100644 --- a/packages/next/src/server/base-server.ts +++ b/packages/next/src/server/base-server.ts @@ -35,6 +35,11 @@ import type { import type { ClientReferenceManifest } from '../build/webpack/plugins/flight-manifest-plugin' import type { NextFontManifest } from '../build/webpack/plugins/next-font-manifest-plugin' import type { PagesAPIRouteMatch } from './route-matches/pages-api-route-match' +import type { RouteMatch } from './route-matches/route-match' +import type { RouteDefinition } from './route-definitions/route-definition' +import type { AppPageRouteDefinition } from './route-definitions/app-page-route-definition' +import type { AppRouteRouteDefinition } from './route-definitions/app-route-route-definition' +import type { LocaleRouteDefinition } from './route-definitions/locale-route-definition' import type { Server as HTTPServer, IncomingMessage, @@ -51,14 +56,12 @@ import { formatHostname } from './lib/format-hostname' import { isRSCRequestHeader } from './lib/is-rsc-request' import { isNonHtmlSecFetchDest } from './lib/is-non-html-sec-fetch-dest' import { - APP_PATHS_MANIFEST, NEXT_BUILTIN_DOCUMENT, - PAGES_MANIFEST, STATIC_STATUS_PAGES, UNDERSCORE_NOT_FOUND_ROUTE, UNDERSCORE_NOT_FOUND_ROUTE_ENTRY, } from '../shared/lib/constants' -import { isDynamicRoute } from '../shared/lib/router/utils' +import { getSortedRoutes, isDynamicRoute } from '../shared/lib/router/utils' import { execOnce } from '../shared/lib/utils' import { isBlockedPage } from './utils' import { getBotType, isBot } from '../shared/lib/router/utils/is-bot' @@ -68,6 +71,9 @@ import { removeTrailingSlash } from '../shared/lib/router/utils/remove-trailing- import { denormalizePagePath } from '../shared/lib/page-path/denormalize-page-path' import * as Log from '../build/output/log' import { getServerUtils } from './server-utils' +import { isAPIRoute } from '../lib/is-api-route' +import { isAppPageRoute } from '../lib/is-app-page-route' +import { isAppRouteRoute } from '../lib/is-app-route-route' import isError, { getProperError } from '../lib/is-error' import { addRequestMeta, @@ -76,7 +82,10 @@ import { setRequestMeta, } from './request-meta' import { removePathPrefix } from '../shared/lib/router/utils/remove-path-prefix' -import { normalizeAppPath } from '../shared/lib/router/utils/app-paths' +import { + normalizeAppPath, + selectAppPageEntry, +} from '../shared/lib/router/utils/app-paths' import { getHostname } from '../shared/lib/get-hostname' import { parseUrl, @@ -96,17 +105,10 @@ import { NEXT_HMR_REFRESH_HEADER, } from '../client/components/app-router-headers' import { nanoid } from 'next/dist/compiled/nanoid' -import type { - MatchOptions, - RouteMatcherManager, -} from './route-matcher-managers/route-matcher-manager' import { LocaleRouteNormalizer } from './normalizers/locale-route-normalizer' -import { DefaultRouteMatcherManager } from './route-matcher-managers/default-route-matcher-manager' -import { AppPageRouteMatcherProvider } from './route-matcher-providers/app-page-route-matcher-provider' -import { AppRouteRouteMatcherProvider } from './route-matcher-providers/app-route-route-matcher-provider' -import { PagesAPIRouteMatcherProvider } from './route-matcher-providers/pages-api-route-matcher-provider' -import { PagesRouteMatcherProvider } from './route-matcher-providers/pages-route-matcher-provider' -import { ServerManifestLoader } from './route-matcher-providers/helpers/manifest-loaders/server-manifest-loader' +import { isAppPageRouteDefinition } from './route-definitions/app-page-route-definition' +import { PagesNormalizers } from './normalizers/built/pages' +import { AppNormalizers } from './normalizers/built/app' import { getTracer, isBubbledError, @@ -116,7 +118,7 @@ import { import { BaseServerSpan } from './lib/trace/constants' import { runWithRequestInsightsIdentity } from './lib/trace/request-insights-identity' import { isRequestInsightsEnabled } from './lib/trace/span-store' -import { I18NProvider } from './lib/i18n-provider' +import { I18NProvider, type LocaleAnalysisResult } from './lib/i18n-provider' import { sendResponse } from './send-response' import { normalizeNextQueryParam } from './web/utils' import { @@ -128,6 +130,7 @@ import { import { normalizeLocalePath } from '../shared/lib/i18n/normalize-locale-path' import { matchNextDataPathname } from './lib/match-next-data-pathname' import getRouteFromAssetPath from '../shared/lib/router/utils/get-route-from-asset-path' +import { getRouteMatcher } from '../shared/lib/router/utils/route-matcher' import { RSCPathnameNormalizer } from './normalizers/request/rsc' import { stripFlightHeaders } from './app-render/strip-flight-headers' import { @@ -436,10 +439,10 @@ export default abstract class Server< forceReload: boolean }): void - // TODO-APP: (wyattjoh): Make protected again. Used for turbopack in route-resolver.ts right now. - public readonly matchers: RouteMatcherManager protected readonly i18nProvider?: I18NProvider protected readonly localeNormalizer?: LocaleRouteNormalizer + private readonly pagesNormalizers: PagesNormalizers + private readonly appNormalizers: AppNormalizers protected readonly normalizers: { readonly rsc: RSCPathnameNormalizer | undefined @@ -516,6 +519,8 @@ export default abstract class Server< /* turbopackIgnore: true */ this.dir, this.nextConfig.distDir ) + this.pagesNormalizers = new PagesNormalizers(this.distDir) + this.appNormalizers = new AppNormalizers(this.distDir) this.publicDir = this.getPublicDir() this.hasStaticDir = !minimalMode && this.getHasStaticDir() @@ -634,21 +639,9 @@ export default abstract class Server< this.appPathRoutes = this.getAppPathRoutes() this.interceptionRoutePatterns = this.getinterceptionRoutePatterns() - // Configure the routes. - this.matchers = this.getRouteMatchers() - - // Start route compilation. We don't wait for the routes to finish loading - // because we use the `waitTillReady` promise below in `handleRequest` to - // wait. Also we can't `await` in the constructor. - void this.matchers.reload() - this.setAssetPrefix(assetPrefix) } - protected reloadMatchers() { - return this.matchers.reload() - } - private handleRSCRequest: RouteHandler = ( req, _res, @@ -827,54 +820,6 @@ export default abstract class Server< ServerResponse > = () => false - protected getRouteMatchers(): RouteMatcherManager { - // Create a new manifest loader that get's the manifests from the server. - const manifestLoader = new ServerManifestLoader((name) => { - switch (name) { - case PAGES_MANIFEST: - return this.getPagesManifest() ?? null - case APP_PATHS_MANIFEST: - return this.getAppPathsManifest() ?? null - default: - return null - } - }) - - // Configure the matchers and handlers. - const matchers: RouteMatcherManager = new DefaultRouteMatcherManager() - - // Match pages under `pages/`. - matchers.push( - new PagesRouteMatcherProvider( - this.distDir, - manifestLoader, - this.i18nProvider - ) - ) - - // Match api routes under `pages/api/`. - matchers.push( - new PagesAPIRouteMatcherProvider( - this.distDir, - manifestLoader, - this.i18nProvider - ) - ) - - // If the app directory is enabled, then add the app matchers and handlers. - if (this.enabledDirectories.app) { - // Match app pages under `app/`. - matchers.push( - new AppPageRouteMatcherProvider(this.distDir, manifestLoader) - ) - matchers.push( - new AppRouteRouteMatcherProvider(this.distDir, manifestLoader) - ) - } - - return matchers - } - protected async instrumentationOnRequestError( ...args: Parameters ) { @@ -1029,9 +974,6 @@ export default abstract class Server< parsedUrl?: NextUrlWithParsedQuery ): Promise { try { - // Wait for the matchers to be ready. - await this.matchers.waitTillReady() - // ensure cookies set in middleware are merged and // not overridden by API routes/getServerSideProps patchSetHeaderWithCookieSupport( @@ -1231,21 +1173,31 @@ export default abstract class Server< hasValidParams: false, } - const match = await this.matchers.match(srcPathname, { - i18n: localeAnalysisResult, - }) - - if (!pageIsDynamic && match) { - // Update the source pathname to the matched page's pathname. - srcPathname = match.definition.pathname - - // The page is dynamic if the params are defined. We know at this - // stage that the matched path is not a static page if the params - // were parsed from the matched path header. - if (typeof match.params !== 'undefined') { - pageIsDynamic = true - paramsResult.params = match.params - paramsResult.hasValidParams = true + // A dynamic x-matched-path identifies the route pattern, not a + // concrete pathname. Match it by identity so a pattern such as + // `/[...path]` cannot be captured by the regex for `/[slug]`. + let routeDefinition: RouteDefinition | undefined + if (pageIsDynamic) { + routeDefinition = this.getRoutePatternDefinition( + srcPathname, + localeAnalysisResult + ) + } else { + const match = this.getRouteMatch(srcPathname, localeAnalysisResult) + if (match) { + routeDefinition = match.definition + + // Update the source pathname to the matched page's pathname. + srcPathname = match.definition.pathname + + // The page is dynamic if the params are defined. We know at this + // stage that the matched path is not a static page if the params + // were parsed from the matched path header. + if (typeof match.params !== 'undefined') { + pageIsDynamic = true + paramsResult.params = match.params + paramsResult.hasValidParams = true + } } } @@ -1466,6 +1418,50 @@ export default abstract class Server< } } + // A platform route match can capture the locale as a dynamic param. + // Analyze the interpolated pathname again to remove that prefix, but + // preserve the original locale when it was already stripped above. + let finalLocaleAnalysisResult = localeAnalysisResult + if (this.i18nProvider) { + const analyzedMatchedPath = this.i18nProvider.analyze(matchedPath, { + defaultLocale, + }) + const hasLocalePrefix = analyzedMatchedPath.pathname !== matchedPath + + if (hasLocalePrefix || !localeAnalysisResult) { + finalLocaleAnalysisResult = analyzedMatchedPath + } else { + finalLocaleAnalysisResult = { + ...localeAnalysisResult, + pathname: matchedPath, + } + } + + matchedPath = finalLocaleAnalysisResult.pathname + addRequestMeta( + req, + 'locale', + finalLocaleAnalysisResult.detectedLocale + ) + if (finalLocaleAnalysisResult.inferredFromDefault) { + addRequestMeta(req, 'localeInferredFromDefault', true) + } else { + removeRequestMeta(req, 'localeInferredFromDefault') + } + } + + // Match the resulting concrete pathname again so route precedence + // is preserved while the concrete params remain available to PPR. + const finalRouteMatch = this.getRouteMatch( + matchedPath, + finalLocaleAnalysisResult + ) + if (finalRouteMatch) { + addRequestMeta(req, 'match', finalRouteMatch) + } else { + removeRequestMeta(req, 'match') + } + if (pageIsDynamic || didRewrite) { utils.normalizeCdnUrl(req, [ ...rewriteParamKeys, @@ -1490,8 +1486,8 @@ export default abstract class Server< // App Router routes should not include rewrite query params as they // affect RSC payload. if ( - match?.definition.kind === RouteKind.PAGES || - match?.definition.kind === RouteKind.PAGES_API + routeDefinition?.kind === RouteKind.PAGES || + routeDefinition?.kind === RouteKind.PAGES_API ) { parsedUrl.query = rewrittenQueryParams } @@ -1727,6 +1723,269 @@ export default abstract class Server< return pathname } + private getPagesRouteDefinition( + page: string, + pathname: string, + kind: RouteKind.PAGES | RouteKind.PAGES_API, + locale?: string + ): LocaleRouteDefinition | undefined { + const filename = this.pagesManifest?.[page] + + if (!filename) return + + return { + kind, + pathname, + page, + bundlePath: this.pagesNormalizers.bundlePath.normalize(page), + filename: this.pagesNormalizers.filename.normalize(filename), + ...(this.i18nProvider + ? { + i18n: { + locale, + }, + } + : {}), + } + } + + private getAppPageRouteDefinition( + pathname: string + ): AppPageRouteDefinition | undefined { + const appPaths = this.appPathRoutes?.[pathname] + if (!appPaths) return + + const page = selectAppPageEntry(pathname, appPaths) + if (!isAppPageRoute(page)) return + + const filename = this.appPathsManifest?.[page] + if (!filename) return + + return { + kind: RouteKind.APP_PAGE, + pathname, + page, + bundlePath: this.appNormalizers.bundlePath.normalize(page), + filename: this.appNormalizers.filename.normalize(filename), + appPaths, + } + } + + private getAppRouteRouteDefinition( + page: string + ): AppRouteRouteDefinition | undefined { + const filename = this.appPathsManifest?.[page] + if (!filename) return + + const pathname = this.appNormalizers.pathname.normalize(page) + + return { + kind: RouteKind.APP_ROUTE, + pathname, + page, + bundlePath: this.appNormalizers.bundlePath.normalize(page), + filename: this.appNormalizers.filename.normalize(filename), + } + } + + private getRouteDefinitions(): RouteDefinition[] { + // Route definitions are rebuilt from the manifests instead of being held + // by route matcher providers. This keeps direct BaseServer entrypoints + // working when requests do not pass through router-server's fsChecker. + const definitions: RouteDefinition[] = [] + + if (this.enabledDirectories.pages) { + for (const page of Object.keys(this.pagesManifest || {})) { + const localeResult = this.i18nProvider?.analyze(page) + const pathname = localeResult?.pathname ?? page + + if (isBlockedPage(pathname)) continue + + const definition = this.getPagesRouteDefinition( + page, + pathname, + isAPIRoute(page) ? RouteKind.PAGES_API : RouteKind.PAGES, + localeResult?.detectedLocale + ) + + if (definition) { + definitions.push(definition) + } + } + } + + if (this.enabledDirectories.app) { + for (const pathname of Object.keys(this.appPathRoutes || {})) { + const definition = this.getAppPageRouteDefinition(pathname) + + if (definition) { + definitions.push(definition) + } + } + + for (const page of Object.keys(this.appPathsManifest || {})) { + if (!isAppRouteRoute(page)) continue + + const definition = this.getAppRouteRouteDefinition(page) + + if (definition) { + definitions.push(definition) + } + } + } + + return definitions + } + + private getSortedRouteDefinitions( + definitions: RouteDefinition[] + ): RouteDefinition[] { + const references = new Map() + const pathnames: string[] = [] + + for (const definition of definitions) { + const existing = references.get(definition.pathname) + if (existing) { + existing.push(definition) + } else { + references.set(definition.pathname, [definition]) + pathnames.push(definition.pathname) + } + } + + return getSortedRoutes(pathnames).flatMap( + (pathname) => references.get(pathname)! + ) + } + + private getRouteMatchPathname( + pathname: string, + definition: RouteDefinition, + localeAnalysisResult?: LocaleAnalysisResult + ): string | null { + const localeDefinition = definition as RouteDefinition & { + i18n?: { locale?: string } + } + + if (localeDefinition.i18n && localeAnalysisResult) { + if ( + localeDefinition.i18n.locale && + localeAnalysisResult.detectedLocale && + localeDefinition.i18n.locale !== localeAnalysisResult.detectedLocale + ) { + return null + } + + return localeAnalysisResult.pathname + } + + if (localeAnalysisResult?.inferredFromDefault) { + return localeAnalysisResult.pathname + } + + return pathname + } + + private testRouteDefinition( + pathname: string, + definition: RouteDefinition, + localeAnalysisResult?: LocaleAnalysisResult + ): RouteMatch | null { + const matchPathname = this.getRouteMatchPathname( + pathname, + definition, + localeAnalysisResult + ) + + if (!matchPathname) return null + + if (isDynamicRoute(definition.pathname)) { + const params = getRouteMatcher(getRouteRegex(definition.pathname))( + matchPathname + ) + + if (!params) return null + + return { + definition, + params, + } + } + + if (matchPathname === definition.pathname) { + return { + definition, + params: undefined, + } + } + + return null + } + + private getRoutePatternDefinition( + pathname: string, + localeAnalysisResult?: LocaleAnalysisResult + ): RouteDefinition | undefined { + for (const definition of this.getRouteDefinitions()) { + const matchPathname = this.getRouteMatchPathname( + pathname, + definition, + localeAnalysisResult + ) + + if (matchPathname === definition.pathname) { + return definition + } + } + } + + protected getRouteMatch( + pathname: string, + localeAnalysisResult?: LocaleAnalysisResult + ): RouteMatch | null { + const definitions = this.getRouteDefinitions() + const dynamicDefinitions: RouteDefinition[] = [] + + // Check exact routes first, then sort dynamic routes by specificity. The + // old matcher manager provided the same precedence before it was removed. + if (!isDynamicRoute(pathname)) { + for (const definition of definitions) { + if (isDynamicRoute(definition.pathname)) { + dynamicDefinitions.push(definition) + continue + } + + const match = this.testRouteDefinition( + pathname, + definition, + localeAnalysisResult + ) + + if (match) return match + } + } else { + for (const definition of definitions) { + if (isDynamicRoute(definition.pathname)) { + dynamicDefinitions.push(definition) + } + } + } + + for (const definition of this.getSortedRouteDefinitions( + dynamicDefinitions + )) { + const match = this.testRouteDefinition( + pathname, + definition, + localeAnalysisResult + ) + + if (match) return match + } + + return null + } + private normalizeAndAttachMetadata: RouteHandler< ServerRequest, ServerResponse @@ -1808,6 +2067,9 @@ export default abstract class Server< } appPathRoutes[normalizedPath].push(entry) }) + + // Preserve manifest order: legacy deployment builders package the final + // direct entry for a pathname as the function's loadable page module. return appPathRoutes } @@ -2643,13 +2905,22 @@ export default abstract class Server< ) { const { query, pathname } = ctx - const appPaths = this.getOriginalAppPaths(pathname) + const match = getRequestMeta(ctx.req, 'match') + const appPaths = + this.getOriginalAppPaths(pathname) ?? + (match && isAppPageRouteDefinition(match.definition) + ? match.definition.appPaths + : null) const isAppPath = Array.isArray(appPaths) let page = pathname if (isAppPath) { - // the last item in the array is the root page, if there are parallel routes - page = appPaths[appPaths.length - 1] + // Load the same direct entry selected by the build and deployment + // adapter. Expanded catch-all contributors are part of the route tree, + // but their modules are not necessarily traced into this function. + page = selectAppPageEntry(pathname, appPaths) + } else if (match?.definition.kind === RouteKind.APP_ROUTE) { + page = match.definition.page } const result = await this.findPageComponents({ @@ -2660,8 +2931,10 @@ export default abstract class Server< isAppPath, sriEnabled: !!this.nextConfig.experimental.sri?.algorithm, appPaths, - // Ensuring for loading page component routes is done via the matcher. - shouldEnsure: false, + // Normal routed requests are ensured by the route match. Legacy custom + // server render methods bypass that path, so ensure when no match exists. + shouldEnsure: !match, + url: pathname, }) if (result) { getTracer().setRootSpanAttribute('next.route', pathname) @@ -2721,54 +2994,46 @@ export default abstract class Server< } delete query[NEXT_RSC_UNION_QUERY] - const options: MatchOptions = { - i18n: this.i18nProvider?.fromRequest(req, pathname), - } - - const existingMatch = getRequestMeta(ctx.req, 'match') - - let fastPath = true - // when a specific invoke-output is meant to be matched - // ensure a prior dynamic route/page doesn't take priority + let existingMatch = getRequestMeta(ctx.req, 'match') const invokeOutput = getRequestMeta(ctx.req, 'invokeOutput') - if ( - (!this.minimalMode && - typeof invokeOutput === 'string' && - isDynamicRoute(invokeOutput || '') && - invokeOutput !== existingMatch?.definition.pathname) || - // Parallel routes are matched in `existingMatch` but since currently - // there can be multiple matches it's not guaranteed to be the right match - // therefor we need to opt-out of the fast path for parallel routes. - existingMatch?.definition.page.includes('/@') - ) { - fastPath = false - } - try { - for await (const match of fastPath && existingMatch - ? [existingMatch] - : this.matchers.matchAll(pathname, options)) { - if ( + if (!existingMatch) { + const localeAnalysisResult = this.i18nProvider?.fromRequest( + req, + pathname + ) + const routeMatch = this.getRouteMatch(pathname, localeAnalysisResult) + + if (routeMatch) { + existingMatch = routeMatch + addRequestMeta(req, 'match', routeMatch) + } + } + + if (existingMatch) { + const shouldSkipMatch = !this.minimalMode && typeof invokeOutput === 'string' && isDynamicRoute(invokeOutput || '') && - invokeOutput !== match.definition.pathname - ) { - continue - } + invokeOutput !== existingMatch.definition.pathname - const result = await this.renderPageComponent( - { - ...ctx, - pathname: match.definition.pathname, - renderOpts: { - ...ctx.renderOpts, - params: match.params, + if (!shouldSkipMatch) { + const result = await this.renderPageComponent( + { + ...ctx, + pathname: existingMatch.definition.pathname, + renderOpts: { + ...ctx.renderOpts, + params: existingMatch.params, + }, }, - }, - bubbleNoFallback - ) + bubbleNoFallback + ) + if (result !== false) return result + } + } else { + const result = await this.renderPageComponent(ctx, bubbleNoFallback) if (result !== false) return result } diff --git a/packages/next/src/server/dev/hot-reloader-turbopack.ts b/packages/next/src/server/dev/hot-reloader-turbopack.ts index faf1d06ec005..ae393a3fe587 100644 --- a/packages/next/src/server/dev/hot-reloader-turbopack.ts +++ b/packages/next/src/server/dev/hot-reloader-turbopack.ts @@ -1096,9 +1096,6 @@ export async function createHotReloaderTurbopack( }, }) - // Reload matchers when the files have been compiled - await propagateServerField(opts, 'reloadMatchers', undefined) - if (addedRoutes.length > 0 || removedRoutes.length > 0) { // When the list of routes changes a new manifest should be fetched for Pages Router. hotReloader.send({ diff --git a/packages/next/src/server/dev/next-dev-server.ts b/packages/next/src/server/dev/next-dev-server.ts index 949936c1335f..c3b2501c21a9 100644 --- a/packages/next/src/server/dev/next-dev-server.ts +++ b/packages/next/src/server/dev/next-dev-server.ts @@ -7,7 +7,6 @@ import type { ParsedUrlQuery } from 'querystring' import type { UrlWithParsedQuery } from 'url' import type { MiddlewareRoutingItem } from '../base-server' import type { RouteDefinition } from '../route-definitions/route-definition' -import type { RouteMatcherManager } from '../route-matcher-managers/route-matcher-manager' import { addRequestMeta, @@ -19,7 +18,6 @@ import type { DevBundlerService } from '../lib/dev-bundler-service' import type { IncrementalCache } from '../lib/incremental-cache' import type { UnwrapPromise } from '../../lib/coalesced-function' import type { NodeNextResponse, NodeNextRequest } from '../base-http/node' -import type { RouteEnsurer } from '../route-matcher-managers/dev-route-matcher-manager' import type { PagesManifest } from '../../build/webpack/plugins/pages-manifest-plugin' import * as React from 'react' @@ -62,14 +60,6 @@ import isError, { getProperError } from '../../lib/is-error' import { defaultConfig, type NextConfigComplete } from '../config-shared' import { isMiddlewareFile } from '../../build/utils' import { formatServerError } from '../../lib/format-server-error' -import { DevRouteMatcherManager } from '../route-matcher-managers/dev-route-matcher-manager' -import { DevPagesRouteMatcherProvider } from '../route-matcher-providers/dev/dev-pages-route-matcher-provider' -import { DevPagesAPIRouteMatcherProvider } from '../route-matcher-providers/dev/dev-pages-api-route-matcher-provider' -import { DevAppPageRouteMatcherProvider } from '../route-matcher-providers/dev/dev-app-page-route-matcher-provider' -import { DevAppRouteRouteMatcherProvider } from '../route-matcher-providers/dev/dev-app-route-route-matcher-provider' -import { NodeManifestLoader } from '../route-matcher-providers/helpers/manifest-loaders/node-manifest-loader' -import { BatchedFileReader } from '../route-matcher-providers/dev/helpers/file-reader/batched-file-reader' -import { DefaultFileReader } from '../route-matcher-providers/dev/helpers/file-reader/default-file-reader' import { LRUCache } from '../lib/lru-cache' import { getMiddlewareRouteMatcher } from '../../shared/lib/router/utils/middleware-route-matcher' import { createPromiseWithResolvers } from '../../shared/lib/promise-with-resolvers' @@ -108,6 +98,14 @@ const ReactDevOverlay: PagesDevOverlayBridgeType = (props) => { return React.createElement(PagesDevOverlayBridgeImpl, props) } +function requireManifest(id: string) { + try { + return require(id) + } catch { + return null + } +} + export interface Options extends ServerOptions { // Override type to make the full config available instead of only NextConfigRuntime conf: NextConfigComplete @@ -265,90 +263,6 @@ export default class DevServer extends Server { return this.bundlerService.getServerComponentsHmrRefreshHash() } - protected getRouteMatchers(): RouteMatcherManager { - const { pagesDir, appDir } = findPagesDir(this.dir) - - const ensurer: RouteEnsurer = { - ensure: async (match, pathname) => { - await this.ensurePage({ - definition: match.definition, - page: match.definition.page, - clientOnly: false, - url: pathname, - }) - }, - } - - const matchers = new DevRouteMatcherManager( - super.getRouteMatchers(), - ensurer, - this.dir - ) - const extensions = this.nextConfig.pageExtensions - const extensionsExpression = new RegExp(`\\.(?:${extensions.join('|')})$`) - - // If the pages directory is available, then configure those matchers. - if (pagesDir) { - const fileReader = new BatchedFileReader( - new DefaultFileReader({ - // Only allow files that have the correct extensions. - pathnameFilter: (pathname) => extensionsExpression.test(pathname), - }) - ) - - matchers.push( - new DevPagesRouteMatcherProvider( - pagesDir, - extensions, - fileReader, - this.localeNormalizer - ) - ) - matchers.push( - new DevPagesAPIRouteMatcherProvider( - pagesDir, - extensions, - fileReader, - this.localeNormalizer - ) - ) - } - - if (appDir) { - // We create a new file reader for the app directory because we don't want - // to include any folders or files starting with an underscore. This will - // prevent the reader from wasting time reading files that we know we - // don't care about. - const fileReader = new BatchedFileReader( - new DefaultFileReader({ - // Ignore any directory prefixed with an underscore. - ignorePartFilter: (part) => part.startsWith('_'), - }) - ) - - // TODO: Improve passing of "is running with Turbopack" - const isTurbopack = !!process.env.TURBOPACK - matchers.push( - new DevAppPageRouteMatcherProvider( - appDir, - extensions, - fileReader, - isTurbopack - ) - ) - matchers.push( - new DevAppRouteRouteMatcherProvider( - appDir, - extensions, - fileReader, - isTurbopack - ) - ) - } - - return matchers - } - protected getBuildId(): string { return 'development' } @@ -365,7 +279,6 @@ export default class DevServer extends Server { existingTelemetry || new Telemetry({ distDir: this.distDir }) await super.prepareImpl() - await this.matchers.reload() this.ready?.resolve() this.ready = undefined @@ -687,9 +600,7 @@ export default class DevServer extends Server { protected getPagesManifest(): PagesManifest | undefined { return ( - NodeManifestLoader.require( - pathJoin(this.serverDistDir, PAGES_MANIFEST) - ) ?? undefined + requireManifest(pathJoin(this.serverDistDir, PAGES_MANIFEST)) ?? undefined ) } @@ -697,9 +608,8 @@ export default class DevServer extends Server { if (!this.enabledDirectories.app) return undefined return ( - NodeManifestLoader.require( - pathJoin(this.serverDistDir, APP_PATHS_MANIFEST) - ) ?? undefined + requireManifest(pathJoin(this.serverDistDir, APP_PATHS_MANIFEST)) ?? + undefined ) } diff --git a/packages/next/src/server/lib/router-server.ts b/packages/next/src/server/lib/router-server.ts index 6b80d847face..45a6967134de 100644 --- a/packages/next/src/server/lib/router-server.ts +++ b/packages/next/src/server/lib/router-server.ts @@ -19,6 +19,7 @@ import { registerUnhandledRejectionListener, } from '../node-environment-extensions/process-error-handlers' import { DecodeError } from '../../shared/lib/utils' +import { deobfuscateText } from '../../shared/lib/magic-identifier' import { findPagesDir } from '../../lib/find-pages-dir' import { setupFsCheck } from './router-utils/filesystem' import { proxyRequest } from './router-utils/proxy-request' @@ -76,6 +77,42 @@ const debug = setupDebug('next:router-server:main') const isNextFont = (pathname: string | null) => pathname && /\/media\/[^/]+\.(woff|woff2|eot|ttf|otf)$/.test(pathname) +// ModuleBuildError can cross compiled module boundaries, so constructor +// identity is not reliable. Check its stable fields and string prefix instead. +function isModuleBuildError(error: unknown): boolean { + if (!error || typeof error !== 'object') { + return false + } + + const maybeError = error as { + code?: unknown + constructor?: { name?: unknown } + name?: unknown + } + const errorString = String(error) + + return ( + maybeError.name === 'ModuleBuildError' || + maybeError.code === 'ModuleBuildError' || + maybeError.constructor?.name === 'ModuleBuildError' || + errorString.startsWith('ModuleBuildError:') || + errorString.startsWith('Error [ModuleBuildError]:') + ) +} + +function getErrorMessage(error: unknown): string { + if ( + error && + typeof error === 'object' && + 'message' in error && + typeof error.message === 'string' + ) { + return deobfuscateText(error.message) + } + + return deobfuscateText(String(error)) +} + export type RenderServer = Pick< typeof import('./render-server'), | 'initialize' @@ -688,12 +725,38 @@ export async function initialize(opts: { if (matchedOutput) { invokedOutputs.add(matchedOutput.itemPath) + // fsChecker preserves compilation errors from its dev ensure step so + // the route remains matched. Log the compiler diagnostic, then render + // the matched route as a 500 instead of falling through to a 404. + if (matchedOutput.error && development) { + development.bundler.logErrorWithOriginalStack( + matchedOutput.error, + matchedOutput.type === 'appFile' ? 'app-dir' : undefined + ) + } + return await invokeRender( parsedUrl, parsedUrl.pathname || '/', handleIndex, { invokeOutput: matchedOutput.itemPath, + ...(matchedOutput.error + ? { + invokeStatus: 500, + invokeError: matchedOutput.error, + } + : undefined), + // fsChecker owns the route match for filesystem requests. Forward + // it so BaseServer does not need the removed matcher manager. + ...(matchedOutput.route + ? { + match: { + definition: matchedOutput.route, + params: matchedOutput.params, + }, + } + : undefined), } ) } @@ -791,6 +854,10 @@ export async function initialize(opts: { if (err instanceof DecodeError) { invokePath = '/400' invokeStatus = '400' + } else if (isModuleBuildError(err)) { + // Webpack compilation failures may bubble out of invokeRender. Log + // the readable diagnostic without printing the wrapper stack again. + Log.error(getErrorMessage(err)) } else { console.error(err) } diff --git a/packages/next/src/server/lib/router-utils/build-data-route.test.ts b/packages/next/src/server/lib/router-utils/build-data-route.test.ts index f63182d6ae66..723c7d1819a1 100644 --- a/packages/next/src/server/lib/router-utils/build-data-route.test.ts +++ b/packages/next/src/server/lib/router-utils/build-data-route.test.ts @@ -1,4 +1,7 @@ -import { buildDataRoute } from './build-data-route' +import { + addLocalePrefixToDataRouteRegex, + buildDataRoute, +} from './build-data-route' describe('buildDataRoute', () => { it('should build a dynamic data route', () => { @@ -27,3 +30,42 @@ describe('buildDataRoute', () => { `) }) }) + +describe('addLocalePrefixToDataRouteRegex', () => { + it('should add a non-capturing locale segment after the build id', () => { + const dataRouteRegex = addLocalePrefixToDataRouteRegex( + '^/_next/data/123/(.+?)\\.json$', + '123' + ) + const match = new RegExp(dataRouteRegex).exec( + '/_next/data/123/nl-NL/another.json' + ) + + expect(dataRouteRegex).toBe('^/_next/data/123/(?:[^/]+?)/(.+?)\\.json$') + expect(match?.[1]).toBe('another') + }) + + it('should support optional catch-all routes', () => { + const dataRouteRegex = addLocalePrefixToDataRouteRegex( + '^/_next/data/development(?:/(.+?))?\\.json$', + 'development' + ) + + expect( + new RegExp(dataRouteRegex).exec('/_next/data/development/nl-NL.json')?.[1] + ).toBeUndefined() + expect( + new RegExp(dataRouteRegex).exec( + '/_next/data/development/nl-NL/another.json' + )?.[1] + ).toBe('another') + }) + + it('should locate regex-escaped build ids', () => { + const route = buildDataRoute('/[...slug]', 'build.id') + + expect( + addLocalePrefixToDataRouteRegex(route.dataRouteRegex, 'build.id') + ).toBe('^/_next/data/build\\.id/(?:[^/]+?)/(.+?)\\.json$') + }) +}) diff --git a/packages/next/src/server/lib/router-utils/build-data-route.ts b/packages/next/src/server/lib/router-utils/build-data-route.ts index 307cb2fb08ab..4d843d722e42 100644 --- a/packages/next/src/server/lib/router-utils/build-data-route.ts +++ b/packages/next/src/server/lib/router-utils/build-data-route.ts @@ -42,3 +42,20 @@ export function buildDataRoute(page: string, buildId: string) { namedDataRouteRegex, } } + +export function addLocalePrefixToDataRouteRegex( + dataRouteRegex: string, + buildId: string +) { + // dataRouteRegex escapes static segments, including custom build IDs. Locate + // the escaped build ID so locale insertion also works for IDs such as "a.b". + const buildIdSegment = `/${escapeStringRegexp(buildId)}` + const buildIdIndex = dataRouteRegex.indexOf(buildIdSegment) + + if (buildIdIndex === -1) { + return dataRouteRegex + } + + const insertIndex = buildIdIndex + buildIdSegment.length + return `${dataRouteRegex.slice(0, insertIndex)}/(?:[^/]+?)${dataRouteRegex.slice(insertIndex)}` +} diff --git a/packages/next/src/server/lib/router-utils/filesystem.ts b/packages/next/src/server/lib/router-utils/filesystem.ts index c8f3820702e6..874ebae1e915 100644 --- a/packages/next/src/server/lib/router-utils/filesystem.ts +++ b/packages/next/src/server/lib/router-utils/filesystem.ts @@ -10,6 +10,8 @@ import type { UnwrapPromise } from '../../../lib/coalesced-function' import type { PatchMatcher } from '../../../shared/lib/router/utils/path-match' import type { MiddlewareRouteMatch } from '../../../shared/lib/router/utils/middleware-route-matcher' import type { __ApiPreviewProps } from '../../api-utils' +import type { Params } from '../../request/params' +import type { RouteDefinition } from '../../route-definitions/route-definition' import path from 'path' import fs from 'fs/promises' @@ -18,10 +20,16 @@ import setupDebug from 'next/dist/compiled/debug' import { LRUCache } from '../lru-cache' import loadCustomRoutes, { type Rewrite } from '../../../lib/load-custom-routes' import { modifyRouteRegex } from '../../../lib/redirect-status' +import { isAPIRoute } from '../../../lib/is-api-route' +import { isAppPageRoute } from '../../../lib/is-app-page-route' +import { isAppRouteRoute } from '../../../lib/is-app-route-route' import { FileType, fileExists } from '../../../lib/file-exists' import { recursiveReadDir } from '../../../lib/recursive-readdir' -import { isDynamicRoute } from '../../../shared/lib/router/utils' -import { escapeStringRegexp } from '../../../shared/lib/escape-regexp' +import { addLocalePrefixToDataRouteRegex } from './build-data-route' +import { + getSortedRoutes, + isDynamicRoute, +} from '../../../shared/lib/router/utils' import { getPathMatch } from '../../../shared/lib/router/utils/path-match' import { getNamedRouteRegex, @@ -32,8 +40,11 @@ import { pathHasPrefix } from '../../../shared/lib/router/utils/path-has-prefix' import { normalizeLocalePath } from '../../../shared/lib/i18n/normalize-locale-path' import { removePathPrefix } from '../../../shared/lib/router/utils/remove-path-prefix' import { getMiddlewareRouteMatcher } from '../../../shared/lib/router/utils/middleware-route-matcher' +import { PageNotFoundError } from '../../../shared/lib/utils' import { APP_PATH_ROUTES_MANIFEST, + APP_PATHS_MANIFEST, + BLOCKED_PAGES, BUILD_ID_FILE, FUNCTIONS_CONFIG_MANIFEST, MIDDLEWARE_MANIFEST, @@ -46,6 +57,12 @@ import { normalizeMetadataRoute } from '../../../lib/metadata/get-metadata-route import { RSCPathnameNormalizer } from '../../normalizers/request/rsc' import { encodeURIPath } from '../../../shared/lib/encode-uri-path' import { isMetadataRouteFile } from '../../../lib/metadata/is-metadata-route' +import { PagesNormalizers } from '../../normalizers/built/pages' +import { AppNormalizers } from '../../normalizers/built/app' +import { RouteKind } from '../../route-kind' +import { isAppPageRouteDefinition } from '../../route-definitions/app-page-route-definition' +import { selectAppPageEntry } from '../../../shared/lib/router/utils/app-paths' +import { normalizeCatchAllRoutes } from './normalize-catchall-routes' export type FsOutput = { type: @@ -61,6 +78,16 @@ export type FsOutput = { fsPath?: string itemsRoot?: string locale?: string + route?: RouteDefinition + params?: Params + requestPath?: string + error?: Error +} + +type FilesystemRouteDefinition = RouteDefinition & { + i18n?: { + locale?: string + } } const debug = setupDebug('next:router-server:filesystem') @@ -72,6 +99,41 @@ export type FilesystemDynamicRoute = ManifestRoute & { match: PatchMatcher } +const buildFilesystemDynamicRoute = (page: string): FilesystemDynamicRoute => { + const routeRegex = getNamedRouteRegex(page, { + prefixRouteKeys: true, + includePrefix: true, + includeSuffix: true, + }) + + return { + regex: routeRegex.re.toString(), + namedRegex: routeRegex.namedRegex, + routeKeys: routeRegex.routeKeys, + match: getRouteMatcher(routeRegex), + page, + } +} + +const sortDynamicRoutes = ( + routes: FilesystemDynamicRoute[] +): FilesystemDynamicRoute[] => { + const references = new Map() + const pages: string[] = [] + + for (const route of routes) { + const existing = references.get(route.page) + if (existing) { + existing.push(route) + } else { + references.set(route.page, [route]) + pages.push(route.page) + } + } + + return getSortedRoutes(pages).flatMap((page) => references.get(page)!) +} + export const buildCustomRoute = ( type: 'redirect' | 'header' | 'rewrite' | 'before_files_rewrite', item: T & { source: string }, @@ -170,6 +232,12 @@ export async function setupFsCheck(opts: { // /icon.png -> .../app/icon.png const staticMetadataFiles = new Map() let dynamicRoutes: FilesystemDynamicRoute[] = [] + // Page and app outputs need route metadata for compilation and rendering. + // Static assets remain plain filesystem matches. + const routeDefinitions = { + appFile: new Map(), + pageFile: new Map(), + } let middlewareMatcher: | ReturnType @@ -192,6 +260,41 @@ export async function setupFsCheck(opts: { let buildId = 'development' let previewProps: __ApiPreviewProps + const setRouteDefinition = ( + type: 'appFile' | 'pageFile', + pathname: string, + definition: FilesystemRouteDefinition + ) => { + const definitions = routeDefinitions[type].get(pathname) + if (definitions) { + definitions.push(definition) + } else { + routeDefinitions[type].set(pathname, [definition]) + } + } + + const getRouteDefinition = ( + type: 'appFile' | 'pageFile', + itemPath: string, + locale: string | undefined + ) => { + const definitions = routeDefinitions[type].get(itemPath) + if (!definitions?.length) return undefined + + if (type === 'pageFile') { + return ( + definitions.find((definition) => definition.i18n?.locale === locale) ?? + definitions.find((definition) => !definition.i18n?.locale) ?? + definitions[0] + ) + } + + return definitions[0] + } + + const pagesNormalizers = new PagesNormalizers(distDir) + const appNormalizers = new AppNormalizers(distDir) + if (!opts.dev) { const buildIdPath = path.join(opts.dir, opts.config.distDir, BUILD_ID_FILE) try { @@ -255,6 +358,11 @@ export async function setupFsCheck(opts: { FUNCTIONS_CONFIG_MANIFEST ) const pagesManifestPath = path.join(distDir, 'server', PAGES_MANIFEST) + const appPathsManifestPath = path.join( + distDir, + 'server', + APP_PATHS_MANIFEST + ) const appRoutesManifestPath = path.join(distDir, APP_PATH_ROUTES_MANIFEST) const routesManifest = JSON.parse( @@ -278,25 +386,98 @@ export async function setupFsCheck(opts: { const pagesManifest = JSON.parse( await fs.readFile(pagesManifestPath, 'utf8') ) + const appPathsManifest = JSON.parse( + await fs.readFile(appPathsManifestPath, 'utf8').catch(() => '{}') + ) const appRoutesManifest = JSON.parse( await fs.readFile(appRoutesManifestPath, 'utf8').catch(() => '{}') ) + const appDynamicRoutes: FilesystemDynamicRoute[] = [] + const appDynamicRoutePathnames = new Set() + const addAppDynamicRoute = (pathname: string) => { + if (!isDynamicRoute(pathname) || appDynamicRoutePathnames.has(pathname)) { + return + } + + appDynamicRoutePathnames.add(pathname) + appDynamicRoutes.push(buildFilesystemDynamicRoute(pathname)) + } for (const key of Object.keys(pagesManifest)) { + const localeResult = opts.config.i18n + ? normalizeLocalePath(key, opts.config.i18n.locales) + : { pathname: key, detectedLocale: undefined } + // ensure the non-locale version is in the set if (opts.config.i18n) { - pageFiles.add( - normalizeLocalePath(key, opts.config.i18n.locales).pathname - ) + pageFiles.add(localeResult.pathname) } else { pageFiles.add(key) } + + if (!isAPIRoute(key) && BLOCKED_PAGES.includes(localeResult.pathname)) { + continue + } + + setRouteDefinition('pageFile', localeResult.pathname, { + kind: isAPIRoute(key) ? RouteKind.PAGES_API : RouteKind.PAGES, + pathname: localeResult.pathname, + page: key, + bundlePath: pagesNormalizers.bundlePath.normalize(key), + filename: pagesNormalizers.filename.normalize(pagesManifest[key]), + ...(opts.config.i18n + ? { + i18n: { + locale: localeResult.detectedLocale, + }, + } + : undefined), + } as FilesystemRouteDefinition) } for (const key of Object.keys(appRoutesManifest)) { appFiles.add(appRoutesManifest[key]) } - const escapedBuildId = escapeStringRegexp(buildId) + const appPages = Object.keys(appPathsManifest).filter((page) => + isAppPageRoute(page) + ) + const allAppPaths: Record = {} + for (const page of appPages) { + const pathname = appNormalizers.pathname.normalize(page) + if (pathname in allAppPaths) allAppPaths[pathname].push(page) + else allAppPaths[pathname] = [page] + } + normalizeCatchAllRoutes(allAppPaths, appNormalizers.pathname) + for (const [pathname, appPaths] of Object.entries(allAppPaths)) { + // Keep manifest order aligned with the module packaged for this route. + const page = selectAppPageEntry(pathname, appPaths, (appPath) => + appNormalizers.pathname.normalize(appPath) + ) + setRouteDefinition('appFile', pathname, { + kind: RouteKind.APP_PAGE, + pathname, + page, + bundlePath: appNormalizers.bundlePath.normalize(page), + filename: appNormalizers.filename.normalize(appPathsManifest[page]), + appPaths, + } as FilesystemRouteDefinition) + addAppDynamicRoute(pathname) + } + + const appRouteHandlers = Object.keys(appPathsManifest).filter((page) => + isAppRouteRoute(page) + ) + for (const page of appRouteHandlers) { + const pathname = appNormalizers.pathname.normalize(page) + setRouteDefinition('appFile', pathname, { + kind: RouteKind.APP_ROUTE, + pathname, + page, + bundlePath: appNormalizers.bundlePath.normalize(page), + filename: appNormalizers.filename.normalize(appPathsManifest[page]), + } as FilesystemRouteDefinition) + addAppDynamicRoute(pathname) + } for (const route of routesManifest.dataRoutes) { if (isDynamicRoute(route.page)) { @@ -313,10 +494,7 @@ export async function setupFsCheck(opts: { // upstream builder that relies on this re: opts.config.i18n ? new RegExp( - route.dataRouteRegex.replace( - `/${escapedBuildId}/`, - `/${escapedBuildId}/(?[^/]+?)/` - ) + addLocalePrefixToDataRouteRegex(route.dataRouteRegex, buildId) ) : new RegExp(route.dataRouteRegex), groups: routeRegex.groups, @@ -326,6 +504,10 @@ export async function setupFsCheck(opts: { nextDataRoutes.add(route.page) } + const filesystemDynamicRoutes: FilesystemDynamicRoute[] = [ + ...appDynamicRoutes, + ] + for (const route of routesManifest.dynamicRoutes) { // If a route is marked as skipInternalRouting, it's not for the internal // router, and instead has been added to support external routers. @@ -333,12 +515,14 @@ export async function setupFsCheck(opts: { continue } - dynamicRoutes.push({ + filesystemDynamicRoutes.push({ ...route, - match: getRouteMatcher(getRouteRegex(route.page)), + ...buildFilesystemDynamicRoute(route.page), }) } + dynamicRoutes.push(...sortDynamicRoutes(filesystemDynamicRoutes)) + if (middlewareManifest.middleware?.['/']?.matchers) { middlewareMatcher = getMiddlewareRouteMatcher( middlewareManifest.middleware?.['/']?.matchers @@ -476,6 +660,20 @@ export async function setupFsCheck(opts: { staticMetadataFiles, dynamicRoutes, nextDataRoutes, + setRouteDefinitions( + type: 'appFile' | 'pageFile', + definitions: ReadonlyArray + ) { + routeDefinitions[type].clear() + for (const definition of definitions) { + setRouteDefinition( + type, + definition.pathname, + definition as FilesystemRouteDefinition + ) + } + }, + getRouteDefinition, exportPathMapRoutes: undefined as | undefined @@ -490,7 +688,10 @@ export async function setupFsCheck(opts: { ensureFn = fn }, - async getItem(itemPath: string): Promise { + async getItem( + itemPath: string, + requestPath?: string + ): Promise { const originalItemPath = itemPath const itemKey = originalItemPath const lruResult = getItemsLru?.get(itemKey) @@ -564,14 +765,14 @@ export async function setupFsCheck(opts: { let curItemPath = itemPath let curDecodedItemPath = decodedItemPath - const isDynamicOutput = type === 'pageFile' || type === 'appFile' + const isPageOrAppFile = type === 'pageFile' || type === 'appFile' if (i18n) { const localeResult = handleLocale( itemPath, // legacy behavior allows visiting static assets under // default locale but no other locale - isDynamicOutput + isPageOrAppFile ? undefined : [ i18n?.defaultLocale, @@ -637,6 +838,12 @@ export async function setupFsCheck(opts: { } catch {} } + // Only page and app outputs participate in route rendering. Public, + // static, image, and virtual outputs are served as filesystem assets. + const route = isPageOrAppFile + ? getRouteDefinition(type, curItemPath, locale) + : undefined + let matchedItem = items.has(curItemPath) // check decoded variant as well @@ -718,24 +925,42 @@ export async function setupFsCheck(opts: { continue } } - } else if (type === 'pageFile' || type === 'appFile') { - const isAppFile = type === 'appFile' + } else if (!isPageOrAppFile) { + continue + } + } - // Attempt to ensure the page/app file is compiled and ready - if (ensureFn) { - const ensureItemPath = isAppFile - ? normalizeMetadataRoute(curItemPath) - : curItemPath + let error: Error | undefined - try { - await ensureFn({ type, itemPath: ensureItemPath }) - } catch (error) { - // If ensure failed, skip this item and continue to the next one + if (opts.dev && isPageOrAppFile) { + if (!route) { + continue + } + + const isAppFile = type === 'appFile' + + // Attempt to ensure the page/app file is compiled and ready. + if (ensureFn) { + const ensureItemPath = isAppFile + ? normalizeMetadataRoute(curItemPath) + : curItemPath + + try { + await ensureFn({ + type, + itemPath: ensureItemPath, + route, + requestPath, + }) + } catch (err) { + // A disappeared route is not a match. Compilation errors still + // belong to this route and must render as a 500 downstream. + if (err instanceof PageNotFoundError) { continue } + + error = err instanceof Error ? err : new Error(String(err)) } - } else { - continue } } @@ -744,6 +969,31 @@ export async function setupFsCheck(opts: { continue } + if (isPageOrAppFile && !route && !matchedItem) { + continue + } + + let params: Params | undefined + if (route && isAppPageRouteDefinition(route)) { + // Parallel app routes can contribute multiple dynamic app paths + // to one pathname. Preserve the params from the matching path. + for (const appPath of route.appPaths) { + const routePathname = appNormalizers.pathname.normalize(appPath) + if (!isDynamicRoute(routePathname)) { + continue + } + + const routeParams = getRouteMatcher(getRouteRegex(routePathname))( + curItemPath + ) + + if (routeParams) { + params = routeParams + break + } + } + } + const itemResult = { type, fsPath, @@ -752,6 +1002,9 @@ export async function setupFsCheck(opts: { // itemPath is usually a slice of the request URL too; keep a // flat copy so the cached value doesn't retain the full URL. itemPath: flatKeyCopy(curItemPath), + route, + params, + error, } getItemsLru?.set(flatKeyCopy(itemKey), itemResult) diff --git a/packages/next/src/server/lib/router-utils/normalize-catchall-routes.ts b/packages/next/src/server/lib/router-utils/normalize-catchall-routes.ts new file mode 100644 index 000000000000..13a52a865e43 --- /dev/null +++ b/packages/next/src/server/lib/router-utils/normalize-catchall-routes.ts @@ -0,0 +1,111 @@ +import { normalizeAppPath } from '../../../shared/lib/router/utils/app-paths' +import { isInterceptionRouteAppPath } from '../../../shared/lib/router/utils/interception-routes' + +type AppPathNormalizer = { + normalize(pathname: string): string +} + +const defaultNormalizer: AppPathNormalizer = { + normalize(pathname: string): string { + return normalizeAppPath(pathname).replace(/%5F/g, '_') + }, +} + +/** + * This function will transform the appPaths in order to support catch-all routes and parallel routes. + * It will traverse the appPaths, looking for catch-all routes and try to find parallel routes that could match + * the catch-all. If it finds a match, it will add the catch-all to the parallel route's list of possible routes. + * + * @param appPaths The appPaths to transform + */ +export function normalizeCatchAllRoutes( + appPaths: Record, + normalizer: AppPathNormalizer = defaultNormalizer +) { + const catchAllRoutes = [ + ...new Set( + Object.values(appPaths) + .flat() + .filter(isCatchAllRoute) + // Sorting is important because we want to match the most specific path. + .sort((a, b) => b.split('/').length - a.split('/').length) + ), + ] + + // interception routes should only be matched by a single entrypoint + // we don't want to push a catch-all route to an interception route + // because it would mean the interception would be handled by the wrong page component + const filteredAppPaths = Object.keys(appPaths).filter( + (route) => !isInterceptionRouteAppPath(route) + ) + + for (const appPath of filteredAppPaths) { + for (const catchAllRoute of catchAllRoutes) { + const normalizedCatchAllRoute = normalizer.normalize(catchAllRoute) + const normalizedCatchAllRouteBasePath = normalizedCatchAllRoute.slice( + 0, + normalizedCatchAllRoute.search(catchAllRouteRegex) + ) + + if ( + // check if the appPath could match the catch-all + appPath.startsWith(normalizedCatchAllRouteBasePath) && + // check if there's not already a slot value that could match the catch-all + !appPaths[appPath].some((path) => hasMatchedSlots(path, catchAllRoute)) + ) { + // optional catch-all routes are not currently supported, but leaving this logic in place + // for when they are eventually supported. + if (isOptionalCatchAll(catchAllRoute)) { + // optional catch-all routes should match both the root segment and any segment after it + // for example, `/[[...slug]]` should match `/` and `/foo` and `/foo/bar` + appPaths[appPath].push(catchAllRoute) + } else if (isCatchAll(catchAllRoute)) { + // regular catch-all (single bracket) should only match segments after it + // for example, `/[...slug]` should match `/foo` and `/foo/bar` but not `/` + if (normalizedCatchAllRouteBasePath !== appPath) { + appPaths[appPath].push(catchAllRoute) + } + } + } + } + } +} + +function hasMatchedSlots(path1: string, path2: string): boolean { + const slots1 = path1.split('/').filter(isMatchableSlot) + const slots2 = path2.split('/').filter(isMatchableSlot) + + // if the catch-all route does not have the same number of slots as the app path, it can't match + if (slots1.length !== slots2.length) return false + + // compare the slots in both paths. For there to be a match, each slot must be the same + for (let i = 0; i < slots1.length; i++) { + if (slots1[i] !== slots2[i]) return false + } + + return true +} + +/** + * Returns true for slots that should be considered when checking for match compatibility. + * Excludes children slots because these are similar to having a segment-level `page` + * which would cause a slot length mismatch when comparing it to a catch-all route. + */ +function isMatchableSlot(segment: string): boolean { + return segment.startsWith('@') && segment !== '@children' +} + +const catchAllRouteRegex = /\[?\[\.\.\./ + +function isCatchAllRoute(pathname: string): boolean { + // Optional catch-all slots are not currently supported, and as such they are not considered when checking for match compatability. + return !isOptionalCatchAll(pathname) && isCatchAll(pathname) +} + +function isOptionalCatchAll(pathname: string): boolean { + return pathname.includes('[[...') +} + +function isCatchAll(pathname: string): boolean { + return pathname.includes('[...') +} diff --git a/packages/next/src/server/lib/router-utils/resolve-routes.ts b/packages/next/src/server/lib/router-utils/resolve-routes.ts index 52e612416162..6438eafaf1da 100644 --- a/packages/next/src/server/lib/router-utils/resolve-routes.ts +++ b/packages/next/src/server/lib/router-utils/resolve-routes.ts @@ -322,7 +322,8 @@ export function getResolveRoutes( if (params) { const pageOutput = await fsChecker.getItem( - addPathPrefix(route.page, config.basePath || '') + addPathPrefix(route.page, config.basePath || ''), + curPathname || undefined ) // i18n locales aren't matched for app dir @@ -339,6 +340,13 @@ export function getResolveRoutes( if (config.useFileSystemPublicRoutes || didRewrite) { return pageOutput + ? { + ...pageOutput, + // The dynamic-route scan matched the concrete request path; + // keep those params with the fsChecker route definition. + params, + } + : null } } } diff --git a/packages/next/src/server/lib/router-utils/setup-dev-bundler.ts b/packages/next/src/server/lib/router-utils/setup-dev-bundler.ts index a11b9cc71711..d01d9822efb4 100644 --- a/packages/next/src/server/lib/router-utils/setup-dev-bundler.ts +++ b/packages/next/src/server/lib/router-utils/setup-dev-bundler.ts @@ -6,6 +6,9 @@ import type { RoutesManifest } from '../../../build' import type { MiddlewareRouteMatch } from '../../../shared/lib/router/utils/middleware-route-matcher' import type { PropagateToWorkersField } from './types' import type { NextJsHotReloaderInterface } from '../../dev/hot-reloader-types' +import type { AppPageRouteDefinition } from '../../route-definitions/app-page-route-definition' +import type { AppRouteRouteDefinition } from '../../route-definitions/app-route-route-definition' +import type { LocaleRouteDefinition } from '../../route-definitions/locale-route-definition' import { createDefineEnv } from '../../../build/swc' import { installBindings } from '../../../build/swc/install-bindings' @@ -14,6 +17,7 @@ import path from 'path' import qs from 'querystring' import Watchpack from 'next/dist/compiled/watchpack' import findUp from 'next/dist/compiled/find-up' +import { cyan } from '../../../lib/picocolors' import { buildCustomRoute } from './filesystem' import * as Log from '../../../build/output/log' import { setGlobal } from '../../../trace/shared' @@ -26,11 +30,16 @@ import { } from '../../../telemetry/events' import { getSortedRoutes } from '../../../shared/lib/router/utils' import { sortByPageExts } from '../../../build/sort-by-page-exts' +import { normalizeCatchAllRoutes } from './normalize-catchall-routes' import { verifyAndRunTypeScript } from '../../../lib/verify-typescript-setup' import { verifyPartytownSetup } from '../../../lib/verify-partytown-setup' import { getNamedRouteRegex } from '../../../shared/lib/router/utils/route-regex' -import { buildDataRoute } from './build-data-route' +import { + addLocalePrefixToDataRouteRegex, + buildDataRoute, +} from './build-data-route' import { getRouteMatcher } from '../../../shared/lib/router/utils/route-matcher' +import { normalizePagePath } from '../../../shared/lib/page-path/normalize-page-path' import { normalizePathSep } from '../../../shared/lib/page-path/normalize-path-sep' import { createClientRouterFilter } from '../../../lib/create-client-router-filter' import { absolutePathToPage } from '../../../shared/lib/page-path/absolute-path-to-page' @@ -83,6 +92,9 @@ import { PROXY_FILENAME, } from '../../../lib/constants' import { parseUrl } from '../../../lib/url' +import { isAPIRoute } from '../../../lib/is-api-route' +import { isAppPageRoute } from '../../../lib/is-app-page-route' +import { isAppRouteRoute } from '../../../lib/is-app-route-route' import { createRouteTypesManifest, writeRouteTypesManifest, @@ -98,10 +110,12 @@ import { import { normalizeAppPath, compareAppPaths, + selectAppPageEntry, } from '../../../shared/lib/router/utils/app-paths' import { ensureLeadingSlash } from '../../../shared/lib/page-path/ensure-leading-slash' import { Lockfile, type DevServerInfo } from '../../../build/lockfile' import { deobfuscateText } from '../../../shared/lib/magic-identifier' +import { RouteKind } from '../../route-kind' export type SetupOpts = { renderServer: LazyRenderServerInstance @@ -339,14 +353,19 @@ async function startWatcher( } opts.fsChecker.ensureCallback(async function ensure(item) { - if (item.type === 'appFile' || item.type === 'pageFile') { - await hotReloader.ensurePage({ - clientOnly: false, - page: item.itemPath, - isApp: item.type === 'appFile', - definition: undefined, - }) - } + const definition = item.route + // FsOutput also includes static assets, which do not need compilation. + if (!definition) return + + // Static-info lookup needs the concrete grouped or parallel app path to + // discover segment configuration such as `runtime = 'edge'`. + await hotReloader.ensurePage({ + clientOnly: false, + page: definition.page, + isApp: item.type === 'appFile', + definition, + url: item.requestPath, + }) }) let resolved = false @@ -418,6 +437,7 @@ async function startWatcher( let previousClientRouterFilters: any let previousConflictingPagePaths: Set = new Set() let hadInitialScan = false + let previousDuplicatePagePaths: Set = new Set() const routeTypesFilePath = path.join(distDir, 'types', 'routes.d.ts') const validatorFilePath = path.join(distDir, 'types', 'validator.ts') @@ -434,9 +454,11 @@ async function startWatcher( const appPaths: Record = {} const pageNameSet = new Set() const conflictingAppPagePaths = new Set() + const duplicatePagePaths = new Set() const appPageFilePaths = new Map() + const appRouteFilePaths = new Map() const pagesPageFilePaths = new Map() - const appRouteHandlers: RouteInfo[] = [] + const appRouteHandlers: Array = [] const pageApiRoutes: RouteInfo[] = [] const pageRoutes: RouteInfo[] = [] const appRoutes: RouteInfo[] = [] @@ -532,12 +554,22 @@ async function startWatcher( continue } + const fileExists = fs.existsSync(fileName) if ( - meta?.accuracy === undefined || - !validFileMatcher.isPageFile(fileName) + !validFileMatcher.isPageFile(fileName) || + (meta?.accuracy === undefined && !fileExists) ) { continue } + if (fileExists) { + try { + if (!fs.statSync(fileName).isFile()) { + continue + } + } catch { + continue + } + } const isAppPath = Boolean( appDir && @@ -685,15 +717,15 @@ async function startWatcher( const originalPageName = pageName pageName = normalizeAppPath(pageName).replace(/%5F/g, '_') const appRoute = normalizePathSep(pageName) + const appPath = opts.turbo + ? originalPageName.replace(/%5F/g, '_') + : originalPageName if (!appPaths[pageName]) { appPaths[pageName] = [] } - appPaths[pageName].push( - opts.turbo - ? originalPageName.replace(/%5F/g, '_') - : originalPageName - ) + appPaths[pageName].push(appPath) + appRouteFilePaths.set(appPath, fileName) if (useFileSystemPublicRoutes) { if (appDir && isStaticMetadataFile(fileName.replace(appDir, ''))) { @@ -710,8 +742,8 @@ async function startWatcher( } const routeEntry = { route: appRoute, filePath: fileName } - if (validFileMatcher.isAppRouterRoute(fileName)) { - appRouteHandlers.push(routeEntry) + if (isAppRouteRoute(appPath)) { + appRouteHandlers.push({ ...routeEntry, page: appPath }) } else { appRoutes.push(routeEntry) } @@ -719,6 +751,31 @@ async function startWatcher( if (routedPages.includes(pageName)) continue } else { // Pages router + const existingPageFilePath = pagesPageFilePaths.get(pageName) + if (pagesDir && existingPageFilePath) { + duplicatePagePaths.add(pageName) + + if (!previousDuplicatePagePaths.has(pageName)) { + const existingPagePath = normalizePathSep( + path.join( + 'pages', + path.relative(pagesDir, existingPageFilePath) + ) + ) + const duplicatePagePath = normalizePathSep( + path.join('pages', path.relative(pagesDir, fileName)) + ) + + Log.warn( + `Duplicate page detected. ${cyan( + existingPagePath + )} and ${cyan(duplicatePagePath)} both resolve to ${cyan( + pageName + )}.` + ) + } + } + if (useFileSystemPublicRoutes) { pageFiles.add(pageName) opts.fsChecker.nextDataRoutes.add(pageName) @@ -726,7 +783,7 @@ async function startWatcher( const route = normalizePathSep(pageName) const routeEntry = { route, filePath: fileName } - if (pageName.startsWith('/api/')) { + if (isAPIRoute(pageName)) { pageApiRoutes.push(routeEntry) } else { pageRoutes.push(routeEntry) @@ -775,11 +832,11 @@ async function startWatcher( hotReloader.setHmrServerError(new Error(errorMessage)) } else if (numConflicting === 0) { hotReloader.clearHmrServerError() - await propagateServerField(opts, 'reloadMatchers', undefined) } } previousConflictingPagePaths = conflictingAppPagePaths + previousDuplicatePagePaths = duplicatePagePaths let clientRouterFilters: any if (nextConfig.experimental.clientRouterFilter) { @@ -969,6 +1026,24 @@ async function startWatcher( nestedMiddleware = [] } + // appPaths intentionally contains both pages and route handlers. The + // removed matcher providers classified those entries independently, so + // isolate pages before catch-all normalization and definition creation. + const appPagePaths: Record = {} + for (const [route, routeAppPaths] of Object.entries(appPaths)) { + const pageAppPaths = routeAppPaths.filter(isAppPageRoute) + if (pageAppPaths.length > 0) { + appPagePaths[route] = pageAppPaths + } + } + + normalizeCatchAllRoutes(appPagePaths) + for (const pageAppPaths of Object.values(appPagePaths)) { + pageAppPaths.sort(compareAppPaths) + } + + normalizeCatchAllRoutes(appPaths) + // Make sure to sort parallel routes to make the result deterministic. serverFields.appPathRoutes = Object.fromEntries( Object.entries(appPaths).map(([k, v]) => [k, v.sort(compareAppPaths)]) @@ -979,6 +1054,63 @@ async function startWatcher( serverFields.appPathRoutes ) + // fsChecker replaces the removed dev matcher providers. Refresh its + // definitions on every watcher pass so newly added routes can be ensured + // and rendered without waiting for a separate matcher reload. + const pageRouteDefinitions = [ + ...pageRoutes.map( + ({ route, filePath }) => + ({ + kind: RouteKind.PAGES, + pathname: route, + page: route, + bundlePath: path.posix.join('pages', normalizePagePath(route)), + filename: filePath, + ...(opts.nextConfig.i18n ? { i18n: {} } : undefined), + }) satisfies LocaleRouteDefinition + ), + ...pageApiRoutes.map( + ({ route, filePath }) => + ({ + kind: RouteKind.PAGES_API, + pathname: route, + page: route, + bundlePath: path.posix.join('pages', normalizePagePath(route)), + filename: filePath, + ...(opts.nextConfig.i18n ? { i18n: {} } : undefined), + }) satisfies LocaleRouteDefinition + ), + ] satisfies Array< + LocaleRouteDefinition + > + + const appRouteDefinitions = [ + ...Object.entries(appPagePaths).map(([route, routeAppPaths]) => { + const page = selectAppPageEntry(route, routeAppPaths) + const filePath = appRouteFilePaths.get(page)! + return { + kind: RouteKind.APP_PAGE, + pathname: route, + page, + bundlePath: path.posix.join('app', normalizePagePath(page)), + filename: filePath, + appPaths: routeAppPaths, + } satisfies AppPageRouteDefinition + }), + ...appRouteHandlers.map(({ route, page, filePath }) => { + return { + kind: RouteKind.APP_ROUTE, + pathname: route, + page, + bundlePath: path.posix.join('app', normalizePagePath(page)), + filename: filePath, + } satisfies AppRouteRouteDefinition + }), + ] satisfies Array + + opts.fsChecker.setRouteDefinitions('pageFile', pageRouteDefinitions) + opts.fsChecker.setRouteDefinitions('appFile', appRouteDefinitions) + // TODO: pass this to fsChecker/next-dev-server? serverFields.middleware = middlewareMatchers ? { @@ -1052,6 +1184,8 @@ async function startWatcher( (page): FilesystemDynamicRoute => { const regex = getNamedRouteRegex(page, { prefixRouteKeys: true, + includePrefix: true, + includeSuffix: true, }) return { regex: regex.re.toString(), @@ -1080,9 +1214,9 @@ async function startWatcher( // upstream builder that relies on this re: opts.nextConfig.i18n ? new RegExp( - route.dataRouteRegex.replace( - `/development/`, - `/development/(?[^/]+?)/` + addLocalePrefixToDataRouteRegex( + route.dataRouteRegex, + 'development' ) ) : new RegExp(route.dataRouteRegex), @@ -1095,12 +1229,6 @@ async function startWatcher( // For Turbopack ADDED_PAGE and REMOVED_PAGE are implemented in hot-reloader-turbopack.ts // in order to avoid a race condition where ADDED_PAGE and REMOVED_PAGE are sent before Turbopack picked up the file change. if (!opts.turbo) { - // Reload the matchers. The filesystem would have been written to, - // and the matchers need to re-scan it to update the router. - // Reloading the matchers should happen before `ADDED_PAGE` or `REMOVED_PAGE` is sent over the websocket - // otherwise it sends the event too early. - await propagateServerField(opts, 'reloadMatchers', undefined) - const sortedRoutesChanged = prevSortedRoutes.length !== sortedRoutes.length || prevSortedRoutes.some((route, idx) => route !== sortedRoutes[idx]) diff --git a/packages/next/src/server/lib/router-utils/types.ts b/packages/next/src/server/lib/router-utils/types.ts index b58ef9e5452f..40bdcdf354e8 100644 --- a/packages/next/src/server/lib/router-utils/types.ts +++ b/packages/next/src/server/lib/router-utils/types.ts @@ -1,7 +1,6 @@ export type PropagateToWorkersField = | 'actualMiddlewareFile' | 'actualInstrumentationHookFile' - | 'reloadMatchers' | 'loadEnvConfig' | 'appPathRoutes' | 'middleware' diff --git a/packages/next/src/server/lib/trace/constants.ts b/packages/next/src/server/lib/trace/constants.ts index 7b384b319423..5b5d8b6099df 100644 --- a/packages/next/src/server/lib/trace/constants.ts +++ b/packages/next/src/server/lib/trace/constants.ts @@ -100,11 +100,6 @@ enum AppRenderSpan { instantInsightsRunValidation = 'AppRender.instantInsights.runValidation', } -enum DevRouteMatcherManagerSpan { - ensureRoute = 'DevRouteMatcherManager.ensureRoute', - reloadMatchers = 'DevRouteMatcherManager.reloadMatchers', -} - enum DevBundlerServiceSpan { ensurePage = 'DevBundlerService.ensurePage', } @@ -144,7 +139,6 @@ type SpanTypes = | `${RenderSpan}` | `${RouterSpan}` | `${AppRenderSpan}` - | `${DevRouteMatcherManagerSpan}` | `${DevBundlerServiceSpan}` | `${NodeSpan}` | `${AppRouteRouteHandlersSpan}` @@ -194,7 +188,6 @@ export { RenderSpan, RouterSpan, AppRenderSpan, - DevRouteMatcherManagerSpan, DevBundlerServiceSpan, NodeSpan, AppRouteRouteHandlersSpan, diff --git a/packages/next/src/server/next-server.ts b/packages/next/src/server/next-server.ts index 302f6fd7d7ba..f86cf4dabfc8 100644 --- a/packages/next/src/server/next-server.ts +++ b/packages/next/src/server/next-server.ts @@ -33,6 +33,7 @@ import type { PagesModule } from './route-modules/pages/module.compiled' import fs from 'fs' import { join, relative } from 'path' +import { format as formatUrl } from 'url' import { getRouteMatcher } from '../shared/lib/router/utils/route-matcher' import { addRequestMeta, getRequestMeta, setRequestMeta } from './request-meta' import { @@ -70,6 +71,7 @@ import BaseServer from './base-server' import { getMaybePagePath, getPagePath } from './require' import { denormalizePagePath } from '../shared/lib/page-path/denormalize-page-path' import { normalizePagePath } from '../shared/lib/page-path/normalize-page-path' +import { selectAppPageEntry } from '../shared/lib/router/utils/app-paths' import { loadComponents } from './load-components' import type { LoadComponentsReturnType } from './load-components' import isError, { getProperError } from '../lib/is-error' @@ -95,13 +97,12 @@ import { setHttpClientAndAgentOptions } from './setup-http-agent-env' import { isPagesAPIRouteMatch } from './route-matches/pages-api-route-match' import type { PagesAPIRouteMatch } from './route-matches/pages-api-route-match' -import type { MatchOptions } from './route-matcher-managers/route-matcher-manager' import { BubbledError, getTracer } from './lib/trace/tracer' import { NextNodeServerSpan } from './lib/trace/constants' import { nodeFs } from './lib/node-fs-methods' import { getRouteRegex } from '../shared/lib/router/utils/route-regex' import { pipeToNodeResponse } from './pipe-readable' -import { createRequestResponseMocks } from './lib/mock-request' +import { createRequestResponseMocks, MockedResponse } from './lib/mock-request' import { NEXT_RSC_UNION_QUERY } from '../client/components/app-router-headers' import { signalFromNodeResponse } from './web/spec-extension/adapters/next-request' import { loadManifest } from './load-manifest.external' @@ -589,7 +590,12 @@ export default class NextNodeServer extends BaseServer< req.url = `${parsedInitUrl.pathname}${parsedInitUrl.search || ''}` const loader = new NodeModuleLoader() - const module = (await loader.load(match.definition.filename)) as { + // Dev definitions retain source filenames for watcher bookkeeping. API + // execution still needs to load the compiled server bundle. + const modulePath = this.isDev + ? join(this.distDir, 'server', `${match.definition.bundlePath}.js`) + : match.definition.filename + const module = (await loader.load(modulePath)) as { handler: ( req: IncomingMessage, res: ServerResponse, @@ -801,7 +807,7 @@ export default class NextNodeServer extends BaseServer< let page = ctx.pathname if (isAppPath) { // When it's an array, we need to pass all parallel routes to the loader. - page = appPaths[0] + page = selectAppPageEntry(ctx.pathname, appPaths) } for (const edgeFunctionsPage of edgeFunctionsPages) { @@ -1125,12 +1131,25 @@ export default class NextNodeServer extends BaseServer< // next.js core assumes page path without trailing slash pathname = removeTrailingSlash(pathname) - const options: MatchOptions = { - i18n: this.i18nProvider?.fromRequest(req, pathname), + let match = getRequestMeta(req, 'match') + + // router-server normally attaches the fsChecker match. Direct internal + // requests, such as on-demand revalidation, bypass router-server and need + // to resolve the route from the manifests here. + if (!match) { + const localeAnalysisResult = this.i18nProvider?.analyze(pathname, { + defaultLocale: getRequestMeta(req, 'defaultLocale'), + }) + + const routeMatch = this.getRouteMatch(pathname, localeAnalysisResult) + if (routeMatch) { + match = routeMatch + } } - const match = await this.matchers.match(pathname, options) - // If we don't have a match, try to render it anyways. + // The matcher manager previously fell through to render for unknown + // paths. Preserve that behavior for direct render-server requests that do + // not pass through fsChecker. if (!match) { await this.render(req, res, pathname, query, parsedUrl, true) @@ -1368,12 +1387,59 @@ export default class NextNodeServer extends BaseServer< pathname: string, query?: ParsedUrlQuery ): Promise { - return super.renderToHTML( - this.normalizeReq(req), - this.normalizeRes(res), + const normalizedRes = this.normalizeRes(res) + const normalizedReq = this.normalizeReq(req) + normalizedReq.url = formatUrl({ + pathname, + query, + }) + + if (this.dev) { + await this.ensurePage({ + page: pathname, + clientOnly: false, + url: normalizedReq.url, + }) + } + + // renderToHTML returns the body to legacy custom servers. Route modules + // write to the response, so capture their output instead of sending it. + const mockedRes = new MockedResponse({ + headers: normalizedRes.getHeaders(), + statusCode: normalizedRes.statusCode, + socket: normalizedRes.originalResponse.socket, + }) + + const result = await super.renderToHTML( + normalizedReq, + this.normalizeRes(mockedRes), pathname, query ) + + if (result === null && mockedRes.isSent) { + await mockedRes.hasStreamed + } + + const mockedHeaders = mockedRes.getHeaders() + for (const key in mockedHeaders) { + const value = mockedHeaders[key] + if (value !== undefined) { + normalizedRes.setHeader( + key, + Array.isArray(value) ? value.map(String) : String(value) + ) + } + } + normalizedRes.statusCode = mockedRes.statusCode + + if (result !== null) { + return result + } + if (mockedRes.buffers.length > 0) { + return Buffer.concat(mockedRes.buffers).toString('utf8') + } + return null } protected async renderErrorToResponseImpl( diff --git a/packages/next/src/server/normalizers/built/app/app-page-normalizer.ts b/packages/next/src/server/normalizers/built/app/app-page-normalizer.ts index 637950e917c7..a16d900ce2a0 100644 --- a/packages/next/src/server/normalizers/built/app/app-page-normalizer.ts +++ b/packages/next/src/server/normalizers/built/app/app-page-normalizer.ts @@ -21,7 +21,7 @@ export class DevAppPageNormalizer extends Normalizers { const normalizer = new DevAppPageNormalizerInternal(appDir, extensions) super( // %5F to _ replacement should only happen with Turbopack. - // TODO: enable when page matcher `/_` check is moved: https://github.com/vercel/next.js/blob/8eda00bf5999e43e8f0211bd72c981d5ce292e8b/packages/next/src/server/route-matcher-providers/dev/dev-app-route-route-matcher-provider.ts#L48 + // TODO: enable when the dev app file classifier's `/_` check is moved. // isTurbopack // ? [ // // The page should have the `%5F` characters replaced with `_` characters. diff --git a/packages/next/src/server/route-matcher-managers/default-route-matcher-manager.test.ts b/packages/next/src/server/route-matcher-managers/default-route-matcher-manager.test.ts deleted file mode 100644 index 6c48d759834b..000000000000 --- a/packages/next/src/server/route-matcher-managers/default-route-matcher-manager.test.ts +++ /dev/null @@ -1,320 +0,0 @@ -import type { AppPageRouteDefinition } from '../route-definitions/app-page-route-definition' -import type { LocaleRouteDefinition } from '../route-definitions/locale-route-definition' -import type { PagesRouteDefinition } from '../route-definitions/pages-route-definition' -import { RouteKind } from '../route-kind' -import type { RouteMatcherProvider } from '../route-matcher-providers/route-matcher-provider' -import { LocaleRouteMatcher } from '../route-matchers/locale-route-matcher' -import { RouteMatcher } from '../route-matchers/route-matcher' -import { DefaultRouteMatcherManager } from './default-route-matcher-manager' -import type { MatchOptions } from './route-matcher-manager' - -describe('DefaultRouteMatcherManager', () => { - it('will throw an error when used before it has been reloaded', async () => { - const manager = new DefaultRouteMatcherManager() - await expect(manager.match('/some/not/real/path', {})).resolves.toEqual( - null - ) - manager.push({ matchers: jest.fn(async () => []) }) - await expect(manager.match('/some/not/real/path', {})).rejects.toThrow() - await manager.reload() - await expect(manager.match('/some/not/real/path', {})).resolves.toEqual( - null - ) - }) - - it('will not error and not match when no matchers are provided', async () => { - const manager = new DefaultRouteMatcherManager() - await manager.reload() - await expect(manager.match('/some/not/real/path', {})).resolves.toEqual( - null - ) - }) - - it.each<{ - pathname: string - options: MatchOptions - definition: LocaleRouteDefinition - }>([ - { - pathname: '/nl-NL/some/path', - options: { - i18n: { - detectedLocale: 'nl-NL', - pathname: '/some/path', - inferredFromDefault: false, - }, - }, - definition: { - kind: RouteKind.PAGES, - filename: '', - bundlePath: '', - page: '', - pathname: '/some/path', - i18n: { - locale: 'nl-NL', - }, - }, - }, - { - pathname: '/en-US/some/path', - options: { - i18n: { - detectedLocale: 'en-US', - pathname: '/some/path', - inferredFromDefault: false, - }, - }, - definition: { - kind: RouteKind.PAGES, - filename: '', - bundlePath: '', - page: '', - pathname: '/some/path', - i18n: { - locale: 'en-US', - }, - }, - }, - { - pathname: '/some/path', - options: { - i18n: { - pathname: '/some/path', - inferredFromDefault: false, - }, - }, - definition: { - kind: RouteKind.PAGES, - filename: '', - bundlePath: '', - page: '', - pathname: '/some/path', - i18n: { - locale: 'en-US', - }, - }, - }, - ])( - 'can handle locale aware matchers for $pathname and locale $options.i18n.detectedLocale', - async ({ pathname, options, definition }) => { - const manager = new DefaultRouteMatcherManager() - - const matcher = new LocaleRouteMatcher(definition) - const provider: RouteMatcherProvider = { - matchers: jest.fn(async () => [matcher]), - } - manager.push(provider) - await manager.reload() - - const match = await manager.match(pathname, options) - expect(match?.definition).toBe(definition) - } - ) - - it('calls the locale route matcher when one is provided', async () => { - const manager = new DefaultRouteMatcherManager() - const definition: PagesRouteDefinition = { - kind: RouteKind.PAGES, - filename: '', - bundlePath: '', - page: '', - pathname: '/some/path', - i18n: { - locale: 'en-US', - }, - } - const matcher = new LocaleRouteMatcher(definition) - const provider: RouteMatcherProvider = { - matchers: jest.fn(async () => [matcher]), - } - manager.push(provider) - await manager.reload() - - const options: MatchOptions = { - i18n: { - detectedLocale: undefined, - pathname: '/some/path', - inferredFromDefault: false, - }, - } - const match = await manager.match('/en-US/some/path', options) - expect(match?.definition).toBe(definition) - }) - - it('will match a route that is not locale aware when it was inferred from the default locale', async () => { - const manager = new DefaultRouteMatcherManager() - const definition: AppPageRouteDefinition = { - kind: RouteKind.APP_PAGE, - filename: '', - bundlePath: '', - page: '', - pathname: '/some/path', - appPaths: [], - } - const matcher = new RouteMatcher(definition) - const provider: RouteMatcherProvider = { - matchers: jest.fn(async () => [matcher]), - } - manager.push(provider) - await manager.reload() - - const options: MatchOptions = { - i18n: { - detectedLocale: 'en-US', - pathname: '/some/path', - inferredFromDefault: true, - }, - } - const match = await manager.match('/en-US/some/path', options) - expect(match?.definition).toBe(definition) - }) -}) - -// TODO: port tests -/* eslint-disable jest/no-commented-out-tests */ - -// describe('DefaultRouteMatcherManager', () => { -// describe('static routes', () => { -// it.each([ -// ['/some/static/route', '/some/static/route.js'], -// ['/some/other/static/route', '/some/other/static/route.js'], -// ])('will match %s to %s', async (pathname, filename) => { -// const matchers = new DefaultRouteMatcherManager() - -// matchers.push({ -// routes: async () => [ -// { -// kind: RouteKind.APP_ROUTE, -// pathname: '/some/other/static/route', -// filename: '/some/other/static/route.js', -// bundlePath: '', -// page: '', -// }, -// { -// kind: RouteKind.APP_ROUTE, -// pathname: '/some/static/route', -// filename: '/some/static/route.js', -// bundlePath: '', -// page: '', -// }, -// ], -// }) - -// await matchers.compile() - -// expect(await matchers.match(pathname)).toEqual({ -// kind: RouteKind.APP_ROUTE, -// pathname, -// filename, -// bundlePath: '', -// page: '', -// }) -// }) -// }) - -// describe('async generator', () => { -// it('will match', async () => { -// const matchers = new DefaultRouteMatcherManager() - -// matchers.push({ -// routes: async () => [ -// { -// kind: RouteKind.APP_ROUTE, -// pathname: '/account/[[...slug]]', -// filename: '/account/[[...slug]].js', -// bundlePath: '', -// page: '', -// }, -// { -// kind: RouteKind.APP_ROUTE, -// pathname: '/blog/[[...slug]]', -// filename: '/blog/[[...slug]].js', -// bundlePath: '', -// page: '', -// }, -// { -// kind: RouteKind.APP_ROUTE, -// pathname: '/[[...optional]]', -// filename: '/[[...optional]].js', -// bundlePath: '', -// page: '', -// }, -// ], -// }) - -// await matchers.compile() - -// const matches: string[] = [] - -// for await (const match of matchers.each('/blog/some-other-path')) { -// matches.push(match.definition.filename) -// } - -// expect(matches).toHaveLength(2) -// expect(matches[0]).toEqual('/blog/[[...slug]].js') -// expect(matches[1]).toEqual('/[[...optional]].js') -// }) -// }) - -// describe('dynamic routes', () => { -// it.each([ -// { -// pathname: '/users/123', -// route: { -// pathname: '/users/[id]', -// filename: '/users/[id].js', -// params: { id: '123' }, -// }, -// }, -// { -// pathname: '/account/123', -// route: { -// pathname: '/[...paths]', -// filename: '/[...paths].js', -// params: { paths: ['account', '123'] }, -// }, -// }, -// { -// pathname: '/dashboard/users/123', -// route: { -// pathname: '/[...paths]', -// filename: '/[...paths].js', -// params: { paths: ['dashboard', 'users', '123'] }, -// }, -// }, -// ])( -// "will match '$pathname' to '$route.filename'", -// async ({ pathname, route }) => { -// const matchers = new DefaultRouteMatcherManager() - -// matchers.push({ -// routes: async () => [ -// { -// kind: RouteKind.APP_ROUTE, -// pathname: '/[...paths]', -// filename: '/[...paths].js', -// bundlePath: '', -// page: '', -// }, -// { -// kind: RouteKind.APP_ROUTE, -// pathname: '/users/[id]', -// filename: '/users/[id].js', -// bundlePath: '', -// page: '', -// }, -// ], -// }) - -// await matchers.compile() - -// expect(await matchers.match(pathname)).toEqual({ -// kind: RouteKind.APP_ROUTE, -// bundlePath: '', -// page: '', -// ...route, -// }) -// } -// ) -// }) -// }) diff --git a/packages/next/src/server/route-matcher-managers/default-route-matcher-manager.ts b/packages/next/src/server/route-matcher-managers/default-route-matcher-manager.ts deleted file mode 100644 index 2daeb4795794..000000000000 --- a/packages/next/src/server/route-matcher-managers/default-route-matcher-manager.ts +++ /dev/null @@ -1,292 +0,0 @@ -import { isDynamicRoute } from '../../shared/lib/router/utils' -import type { RouteKind } from '../route-kind' -import type { RouteMatch } from '../route-matches/route-match' -import type { RouteDefinition } from '../route-definitions/route-definition' -import type { RouteMatcherProvider } from '../route-matcher-providers/route-matcher-provider' -import type { RouteMatcher } from '../route-matchers/route-matcher' -import type { MatchOptions, RouteMatcherManager } from './route-matcher-manager' -import { getSortedRoutes } from '../../shared/lib/router/utils' -import { LocaleRouteMatcher } from '../route-matchers/locale-route-matcher' -import { ensureLeadingSlash } from '../../shared/lib/page-path/ensure-leading-slash' -import { createPromiseWithResolvers } from '../../shared/lib/promise-with-resolvers' - -interface RouteMatchers { - static: ReadonlyArray - dynamic: ReadonlyArray - duplicates: Record> -} - -export class DefaultRouteMatcherManager implements RouteMatcherManager { - private readonly providers: Array = [] - protected readonly matchers: RouteMatchers = { - static: [], - dynamic: [], - duplicates: {}, - } - private lastCompilationID = this.compilationID - - /** - * When this value changes, it indicates that a change has been introduced - * that requires recompilation. - */ - private get compilationID() { - return this.providers.length - } - - private waitTillReadyPromise?: Promise - public async waitTillReady(): Promise { - if (this.waitTillReadyPromise) { - await this.waitTillReadyPromise - delete this.waitTillReadyPromise - } - } - - private previousMatchers: ReadonlyArray = [] - public async reload() { - const { promise, resolve, reject } = createPromiseWithResolvers() - this.waitTillReadyPromise = promise - - // Grab the compilation ID for this run, we'll verify it at the end to - // ensure that if any routes were added before reloading is finished that - // we error out. - const compilationID = this.compilationID - - try { - // Collect all the matchers from each provider. - const matchers: Array = [] - - // Get all the providers matchers. - const providersMatchers: ReadonlyArray> = - await Promise.all(this.providers.map((provider) => provider.matchers())) - - // Use this to detect duplicate pathnames. - const all = new Map() - const duplicates: Record = {} - for (const providerMatchers of providersMatchers) { - for (const matcher of providerMatchers) { - // Reset duplicated matches when reloading from pages conflicting state. - if (matcher.duplicated) delete matcher.duplicated - // Test to see if the matcher being added is a duplicate. - const duplicate = all.get(matcher.definition.pathname) - if (duplicate) { - // This looks a little weird, but essentially if the pathname - // already exists in the duplicates map, then we got that array - // reference. Otherwise, we create a new array with the original - // duplicate first. Then we push the new matcher into the duplicate - // array, and reset it to the duplicates object (which may be a - // no-op if the pathname already existed in the duplicates object). - // Then we set the array of duplicates on both the original - // duplicate object and the new one, so we can keep them in sync. - // If a new duplicate is found, and it matches an existing pathname, - // the retrieval of the `other` will actually return the array - // reference used by all other duplicates. This is why ReadonlyArray - // is so important! Array's are always references! - const others = duplicates[matcher.definition.pathname] ?? [ - duplicate, - ] - others.push(matcher) - duplicates[matcher.definition.pathname] = others - - // Add duplicated details to each route. - duplicate.duplicated = others - matcher.duplicated = others - - // TODO: see if we should error for duplicates in production? - } - - matchers.push(matcher) - - // Add the matcher's pathname to the set. - all.set(matcher.definition.pathname, matcher) - } - } - - // Update the duplicate matchers. This is used in the development manager - // to warn about duplicates. - this.matchers.duplicates = duplicates - - // If the cache is the same as what we just parsed, we can exit now. We - // can tell by using the `===` which compares object identity, which for - // the manifest matchers, will return the same matcher each time. - if ( - this.previousMatchers.length === matchers.length && - this.previousMatchers.every( - (cachedMatcher, index) => cachedMatcher === matchers[index] - ) - ) { - return - } - this.previousMatchers = matchers - - // For matchers that are for static routes, filter them now. - this.matchers.static = matchers.filter((matcher) => !matcher.isDynamic) - - // For matchers that are for dynamic routes, filter them and sort them now. - const dynamic = matchers.filter((matcher) => matcher.isDynamic) - - // As `getSortedRoutes` only takes an array of strings, we need to create - // a map of the pathnames (used for sorting) and the matchers. When we - // have locales, there may be multiple matches for the same pathname. To - // handle this, we keep a map of all the indexes (in `reference`) and - // merge them in later. - - const reference = new Map() - const pathnames = new Array() - for (let index = 0; index < dynamic.length; index++) { - // Grab the pathname from the definition. - const pathname = dynamic[index].definition.pathname - - // Grab the index in the dynamic array, push it into the reference. - const indexes = reference.get(pathname) ?? [] - indexes.push(index) - - // If this is the first one set it. If it isn't, we don't need to - // because pushing above on the array will mutate the array already - // stored there because array's are always a reference! - if (indexes.length === 1) reference.set(pathname, indexes) - // Otherwise, continue, we've already added this pathname before. - else continue - - pathnames.push(pathname) - } - - // Sort the array of pathnames. - const sorted = getSortedRoutes(pathnames) - - // For each of the sorted pathnames, iterate over them, grabbing the list - // of indexes and merging them back into the new `sortedDynamicMatchers` - // array. The order of the same matching pathname doesn't matter because - // they will have other matching characteristics (like the locale) that - // is considered. - const sortedDynamicMatchers: Array = [] - for (const pathname of sorted) { - const indexes = reference.get(pathname) - if (!Array.isArray(indexes)) { - throw new Error('Invariant: expected to find identity in indexes map') - } - - const dynamicMatches = indexes.map((index) => dynamic[index]) - - sortedDynamicMatchers.push(...dynamicMatches) - } - - this.matchers.dynamic = sortedDynamicMatchers - - // This means that there was a new matcher pushed while we were waiting - if (this.compilationID !== compilationID) { - throw new Error( - 'Invariant: expected compilation to finish before new matchers were added, possible missing await' - ) - } - } catch (err) { - reject(err) - } finally { - // The compilation ID matched, so mark the complication as finished. - this.lastCompilationID = compilationID - resolve() - } - } - - public push(provider: RouteMatcherProvider): void { - this.providers.push(provider) - } - - public async test(pathname: string, options: MatchOptions): Promise { - // See if there's a match for the pathname... - const match = await this.match(pathname, options) - - // This default implementation only needs to check to see if there _was_ a - // match. The development matcher actually changes it's behavior by not - // recompiling the routes. - return match !== null - } - - public async match( - pathname: string, - options: MatchOptions - ): Promise> | null> { - // "Iterate" over the match options. Once we found a single match, exit with - // it, otherwise return null below. If no match is found, the inner block - // won't be called. - for await (const match of this.matchAll(pathname, options)) { - return match - } - - return null - } - - /** - * This is a point for other managers to override to inject other checking - * behavior like duplicate route checking on a per-request basis. - * - * @param pathname the pathname to validate against - * @param matcher the matcher to validate/test with - * @returns the match if found - */ - protected validate( - pathname: string, - matcher: RouteMatcher, - options: MatchOptions - ): RouteMatch | null { - if (matcher instanceof LocaleRouteMatcher) { - return matcher.match(pathname, options) - } - - // If the locale was inferred from the default locale, then it will have - // already added a locale to the pathname. We need to remove it before - // matching because this matcher is not locale aware. - if (options.i18n?.inferredFromDefault) { - return matcher.match(options.i18n.pathname) - } - - return matcher.match(pathname) - } - - public async *matchAll( - pathname: string, - options: MatchOptions - ): AsyncGenerator>, null, undefined> { - // Guard against the matcher manager from being run before it needs to be - // recompiled. This was preferred to re-running the compilation here because - // it should be re-ran only when it changes. If a match is attempted before - // this is done, it indicates that there is a case where a provider is added - // before it was recompiled (an error). We also don't want to affect request - // times. - if (this.lastCompilationID !== this.compilationID) { - throw new Error( - 'Invariant: expected routes to have been loaded before match' - ) - } - - // Ensure that path matching is done with a leading slash. - pathname = ensureLeadingSlash(pathname) - - // If this pathname doesn't look like a dynamic route, and this pathname is - // listed in the normalized list of routes, then return it. This ensures - // that when a route like `/user/[id]` is encountered, it doesn't just match - // with the list of normalized routes. - if (!isDynamicRoute(pathname)) { - for (const matcher of this.matchers.static) { - const match = this.validate(pathname, matcher, options) - if (!match) continue - - yield match - } - } - - // If we should skip handling dynamic routes, exit now. - if (options?.skipDynamic) return null - - // Loop over the dynamic matchers, yielding each match. - for (const matcher of this.matchers.dynamic) { - const match = this.validate(pathname, matcher, options) - if (!match) continue - - yield match - } - - // We tried direct matching against the pathname and against all the dynamic - // paths, so there was no match. - return null - } -} diff --git a/packages/next/src/server/route-matcher-managers/dev-route-matcher-manager.ts b/packages/next/src/server/route-matcher-managers/dev-route-matcher-manager.ts deleted file mode 100644 index 551537735ede..000000000000 --- a/packages/next/src/server/route-matcher-managers/dev-route-matcher-manager.ts +++ /dev/null @@ -1,132 +0,0 @@ -import { RouteKind } from '../route-kind' -import type { RouteMatch } from '../route-matches/route-match' -import type { RouteDefinition } from '../route-definitions/route-definition' -import { DefaultRouteMatcherManager } from './default-route-matcher-manager' -import type { MatchOptions, RouteMatcherManager } from './route-matcher-manager' -import path from '../../shared/lib/isomorphic/path' -import * as Log from '../../build/output/log' -import { cyan } from '../../lib/picocolors' -import type { RouteMatcher } from '../route-matchers/route-matcher' -import { DevRouteMatcherManagerSpan } from '../lib/trace/constants' -import { getTracer } from '../lib/trace/tracer' - -export interface RouteEnsurer { - ensure(match: RouteMatch, pathname: string): Promise -} - -export class DevRouteMatcherManager extends DefaultRouteMatcherManager { - constructor( - private readonly production: RouteMatcherManager, - private readonly ensurer: RouteEnsurer, - private readonly dir: string - ) { - super() - } - - public async test(pathname: string, options: MatchOptions): Promise { - // Try to find a match within the developer routes. - const match = await super.match(pathname, options) - - // Return if the match wasn't null. Unlike the implementation of `match` - // which uses `matchAll` here, this does not call `ensure` on the match - // found via the development matches. - return match !== null - } - - protected validate( - pathname: string, - matcher: RouteMatcher, - options: MatchOptions - ): RouteMatch | null { - const match = super.validate(pathname, matcher, options) - - // If a match was found, check to see if there were any conflicting app or - // pages files. - // TODO: maybe expand this to _any_ duplicated routes instead? - if ( - match && - matcher.duplicated && - matcher.duplicated.some( - (duplicate) => - duplicate.definition.kind === RouteKind.APP_PAGE || - duplicate.definition.kind === RouteKind.APP_ROUTE - ) && - matcher.duplicated.some( - (duplicate) => - duplicate.definition.kind === RouteKind.PAGES || - duplicate.definition.kind === RouteKind.PAGES_API - ) - ) { - return null - } - - return match - } - - public async *matchAll( - pathname: string, - options: MatchOptions - ): AsyncGenerator>, null, undefined> { - // Iterate over the development matches to see if one of them match the - // request path. - for await (const developmentMatch of super.matchAll(pathname, options)) { - // We're here, which means that we haven't seen this match yet, so we - // should try to ensure it and recompile the production matcher. - await getTracer().trace( - DevRouteMatcherManagerSpan.ensureRoute, - { - spanName: 'prepare route', - }, - () => this.ensurer.ensure(developmentMatch, pathname) - ) - await getTracer().trace( - DevRouteMatcherManagerSpan.reloadMatchers, - { - spanName: 'reload route matchers', - }, - () => this.production.reload() - ) - - // Iterate over the production matches again, this time we should be able - // to match it against the production matcher unless there's an error. - for await (const productionMatch of this.production.matchAll( - pathname, - options - )) { - yield productionMatch - } - } - - // We tried direct matching against the pathname and against all the dynamic - // paths, so there was no match. - return null - } - - public async reload(): Promise { - // Compile the production routes again. - await this.production.reload() - - // Compile the development routes. - await super.reload() - - // Check for and warn of any duplicates. - for (const [pathname, matchers] of Object.entries( - this.matchers.duplicates - )) { - // We only want to warn about matchers resolving to the same path if their - // identities are different. - const identity = matchers[0].identity - if (matchers.slice(1).some((matcher) => matcher.identity !== identity)) { - continue - } - - Log.warn( - `Duplicate page detected. ${matchers - .map((matcher) => - cyan(path.relative(this.dir, matcher.definition.filename)) - ) - .join(' and ')} resolve to ${cyan(pathname)}` - ) - } - } -} diff --git a/packages/next/src/server/route-matcher-managers/route-matcher-manager.ts b/packages/next/src/server/route-matcher-managers/route-matcher-manager.ts deleted file mode 100644 index 0d44b9178157..000000000000 --- a/packages/next/src/server/route-matcher-managers/route-matcher-manager.ts +++ /dev/null @@ -1,66 +0,0 @@ -import type { RouteMatch } from '../route-matches/route-match' -import type { RouteMatcherProvider } from '../route-matcher-providers/route-matcher-provider' -import type { LocaleAnalysisResult } from '../lib/i18n-provider' - -export type MatchOptions = { - skipDynamic?: boolean - - /** - * If defined, this indicates to the matcher that the request should be - * treated as locale-aware. If this is undefined, it means that this - * application was not configured for additional locales. - */ - i18n?: LocaleAnalysisResult | undefined -} - -export interface RouteMatcherManager { - /** - * Returns a promise that resolves when the matcher manager has finished - * reloading. - */ - waitTillReady(): Promise - - /** - * Pushes in a new matcher for this manager to manage. After all the - * providers have been pushed, the manager must be reloaded. - * - * @param provider the provider for this manager to also manage - */ - push(provider: RouteMatcherProvider): void - - /** - * Reloads the matchers from the providers. This should be done after all the - * providers have been added or the underlying providers should be refreshed. - */ - reload(): Promise - - /** - * Tests the underlying matchers to find a match. It does not return the - * match. - * - * @param pathname the pathname to test for matches - * @param options the options for the testing - */ - test(pathname: string, options: MatchOptions): Promise - - /** - * Returns the first match for a given request. - * - * @param pathname the pathname to match against - * @param options the options for the matching - */ - match(pathname: string, options: MatchOptions): Promise - - /** - * Returns a generator for each match for a given request. This should be - * consumed in a `for await (...)` loop, when finished, breaking or returning - * from the loop will terminate the matching operation. - * - * @param pathname the pathname to match against - * @param options the options for the matching - */ - matchAll( - pathname: string, - options: MatchOptions - ): AsyncGenerator -} diff --git a/packages/next/src/server/route-matcher-providers/app-page-route-matcher-provider.test.ts b/packages/next/src/server/route-matcher-providers/app-page-route-matcher-provider.test.ts deleted file mode 100644 index dcdbfa9489bb..000000000000 --- a/packages/next/src/server/route-matcher-providers/app-page-route-matcher-provider.test.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { SERVER_DIRECTORY } from '../../shared/lib/constants' -import type { AppPageRouteDefinition } from '../route-definitions/app-page-route-definition' -import { RouteKind } from '../route-kind' -import { AppPageRouteMatcherProvider } from './app-page-route-matcher-provider' -import type { ManifestLoader } from './helpers/manifest-loaders/manifest-loader' - -describe('AppPageRouteMatcherProvider', () => { - it('returns no routes with an empty manifest', async () => { - const loader: ManifestLoader = { load: jest.fn(() => ({})) } - const matcher = new AppPageRouteMatcherProvider('', loader) - await expect(matcher.matchers()).resolves.toEqual([]) - }) - - describe('manifest matching', () => { - it.each<{ - manifest: Record - route: AppPageRouteDefinition - }>([ - { - manifest: { - '/page': 'app/page.js', - }, - route: { - kind: RouteKind.APP_PAGE, - pathname: '/', - filename: `/${SERVER_DIRECTORY}/app/page.js`, - page: '/page', - bundlePath: 'app/page', - appPaths: ['/page'], - }, - }, - { - manifest: { - '/(marketing)/about/page': 'app/(marketing)/about/page.js', - }, - route: { - kind: RouteKind.APP_PAGE, - pathname: '/about', - filename: `/${SERVER_DIRECTORY}/app/(marketing)/about/page.js`, - page: '/(marketing)/about/page', - bundlePath: 'app/(marketing)/about/page', - appPaths: ['/(marketing)/about/page'], - }, - }, - { - manifest: { - '/dashboard/users/[id]/page': 'app/dashboard/users/[id]/page.js', - }, - route: { - kind: RouteKind.APP_PAGE, - pathname: '/dashboard/users/[id]', - filename: `/${SERVER_DIRECTORY}/app/dashboard/users/[id]/page.js`, - page: '/dashboard/users/[id]/page', - bundlePath: 'app/dashboard/users/[id]/page', - appPaths: ['/dashboard/users/[id]/page'], - }, - }, - { - manifest: { '/dashboard/users/page': 'app/dashboard/users/page.js' }, - route: { - kind: RouteKind.APP_PAGE, - pathname: '/dashboard/users', - filename: `/${SERVER_DIRECTORY}/app/dashboard/users/page.js`, - page: '/dashboard/users/page', - bundlePath: 'app/dashboard/users/page', - appPaths: ['/dashboard/users/page'], - }, - }, - { - manifest: { - '/dashboard/users/page': 'app/dashboard/users/page.js', - '/(marketing)/dashboard/users/page': - 'app/(marketing)/dashboard/users/page.js', - }, - route: { - kind: RouteKind.APP_PAGE, - pathname: '/dashboard/users', - filename: `/${SERVER_DIRECTORY}/app/dashboard/users/page.js`, - page: '/dashboard/users/page', - bundlePath: 'app/dashboard/users/page', - appPaths: [ - '/dashboard/users/page', - '/(marketing)/dashboard/users/page', - ], - }, - }, - ])( - 'returns the correct routes for $route.pathname', - async ({ manifest, route }) => { - const loader: ManifestLoader = { - load: jest.fn(() => ({ - '/users/[id]/route': 'app/users/[id]/route.js', - '/users/route': 'app/users/route.js', - ...manifest, - })), - } - const matcher = new AppPageRouteMatcherProvider('', loader) - const matchers = await matcher.matchers() - - expect(loader.load).toHaveBeenCalled() - expect(matchers).toHaveLength(1) - expect(matchers[0].definition).toEqual(route) - } - ) - }) -}) diff --git a/packages/next/src/server/route-matcher-providers/app-page-route-matcher-provider.ts b/packages/next/src/server/route-matcher-providers/app-page-route-matcher-provider.ts deleted file mode 100644 index 2186c6ff1ff5..000000000000 --- a/packages/next/src/server/route-matcher-providers/app-page-route-matcher-provider.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { isAppPageRoute } from '../../lib/is-app-page-route' - -import { APP_PATHS_MANIFEST } from '../../shared/lib/constants' -import { AppNormalizers } from '../normalizers/built/app' -import { RouteKind } from '../route-kind' -import { AppPageRouteMatcher } from '../route-matchers/app-page-route-matcher' -import type { - Manifest, - ManifestLoader, -} from './helpers/manifest-loaders/manifest-loader' -import { ManifestRouteMatcherProvider } from './manifest-route-matcher-provider' - -export class AppPageRouteMatcherProvider extends ManifestRouteMatcherProvider { - private readonly normalizers: AppNormalizers - - constructor(distDir: string, manifestLoader: ManifestLoader) { - super(APP_PATHS_MANIFEST, manifestLoader) - - this.normalizers = new AppNormalizers(distDir) - } - - protected async transform( - manifest: Manifest - ): Promise> { - // This matcher only matches app pages. - const pages = Object.keys(manifest).filter((page) => isAppPageRoute(page)) - - // Collect all the app paths for each page. This could include any parallel - // routes. - const allAppPaths: Record = {} - for (const page of pages) { - const pathname = this.normalizers.pathname.normalize(page) - if (pathname in allAppPaths) allAppPaths[pathname].push(page) - else allAppPaths[pathname] = [page] - } - - // Format the routes. - const matchers: Array = [] - for (const [pathname, appPaths] of Object.entries(allAppPaths)) { - // TODO-APP: (wyattjoh) this is a hack right now, should be more deterministic - const page = appPaths[0] - - const filename = this.normalizers.filename.normalize(manifest[page]) - const bundlePath = this.normalizers.bundlePath.normalize(page) - - matchers.push( - new AppPageRouteMatcher({ - kind: RouteKind.APP_PAGE, - pathname, - page, - bundlePath, - filename, - appPaths, - }) - ) - } - - return matchers - } -} diff --git a/packages/next/src/server/route-matcher-providers/app-route-route-matcher-provider.test.ts b/packages/next/src/server/route-matcher-providers/app-route-route-matcher-provider.test.ts deleted file mode 100644 index 354eb71befb7..000000000000 --- a/packages/next/src/server/route-matcher-providers/app-route-route-matcher-provider.test.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { SERVER_DIRECTORY } from '../../shared/lib/constants' -import type { AppRouteRouteDefinition } from '../route-definitions/app-route-route-definition' -import { RouteKind } from '../route-kind' -import { AppRouteRouteMatcherProvider } from './app-route-route-matcher-provider' -import type { ManifestLoader } from './helpers/manifest-loaders/manifest-loader' - -describe('AppRouteRouteMatcherProvider', () => { - it('returns no routes with an empty manifest', async () => { - const loader: ManifestLoader = { load: jest.fn(() => ({})) } - const provider = new AppRouteRouteMatcherProvider('', loader) - expect(await provider.matchers()).toEqual([]) - }) - - describe('manifest matching', () => { - it.each<{ - manifest: Record - route: AppRouteRouteDefinition - }>([ - { - manifest: { - '/route': 'app/route.js', - }, - route: { - kind: RouteKind.APP_ROUTE, - pathname: '/', - filename: `/${SERVER_DIRECTORY}/app/route.js`, - page: '/route', - bundlePath: 'app/route', - }, - }, - { - manifest: { '/users/[id]/route': 'app/users/[id]/route.js' }, - route: { - kind: RouteKind.APP_ROUTE, - pathname: '/users/[id]', - filename: `/${SERVER_DIRECTORY}/app/users/[id]/route.js`, - page: '/users/[id]/route', - bundlePath: 'app/users/[id]/route', - }, - }, - { - manifest: { '/users/route': 'app/users/route.js' }, - route: { - kind: RouteKind.APP_ROUTE, - pathname: '/users', - filename: `/${SERVER_DIRECTORY}/app/users/route.js`, - page: '/users/route', - bundlePath: 'app/users/route', - }, - }, - ])( - 'returns the correct routes for $route.pathname', - async ({ manifest, route }) => { - const loader: ManifestLoader = { - load: jest.fn(() => ({ - '/dashboard/users/[id]/page': 'app/dashboard/users/[id]/page.js', - '/dashboard/users/page': 'app/dashboard/users/page.js', - ...manifest, - })), - } - const provider = new AppRouteRouteMatcherProvider('', loader) - const matchers = await provider.matchers() - - expect(matchers).toHaveLength(1) - expect(matchers[0].definition).toEqual(route) - } - ) - }) -}) diff --git a/packages/next/src/server/route-matcher-providers/app-route-route-matcher-provider.ts b/packages/next/src/server/route-matcher-providers/app-route-route-matcher-provider.ts deleted file mode 100644 index 23d18c586bd2..000000000000 --- a/packages/next/src/server/route-matcher-providers/app-route-route-matcher-provider.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { isAppRouteRoute } from '../../lib/is-app-route-route' -import { APP_PATHS_MANIFEST } from '../../shared/lib/constants' -import { RouteKind } from '../route-kind' -import { AppRouteRouteMatcher } from '../route-matchers/app-route-route-matcher' -import type { - Manifest, - ManifestLoader, -} from './helpers/manifest-loaders/manifest-loader' -import { ManifestRouteMatcherProvider } from './manifest-route-matcher-provider' -import { AppNormalizers } from '../normalizers/built/app' - -export class AppRouteRouteMatcherProvider extends ManifestRouteMatcherProvider { - private readonly normalizers: AppNormalizers - - constructor(distDir: string, manifestLoader: ManifestLoader) { - super(APP_PATHS_MANIFEST, manifestLoader) - - this.normalizers = new AppNormalizers(distDir) - } - - protected async transform( - manifest: Manifest - ): Promise> { - // This matcher only matches app routes. - const pages = Object.keys(manifest).filter((page) => isAppRouteRoute(page)) - - // Format the routes. - const matchers: Array = [] - for (const page of pages) { - const filename = this.normalizers.filename.normalize(manifest[page]) - const pathname = this.normalizers.pathname.normalize(page) - const bundlePath = this.normalizers.bundlePath.normalize(page) - - matchers.push( - new AppRouteRouteMatcher({ - kind: RouteKind.APP_ROUTE, - pathname, - page, - bundlePath, - filename, - }) - ) - } - - return matchers - } -} diff --git a/packages/next/src/server/route-matcher-providers/dev/dev-app-page-route-matcher-provider.test.ts b/packages/next/src/server/route-matcher-providers/dev/dev-app-page-route-matcher-provider.test.ts deleted file mode 100644 index 0ebacf28e819..000000000000 --- a/packages/next/src/server/route-matcher-providers/dev/dev-app-page-route-matcher-provider.test.ts +++ /dev/null @@ -1,110 +0,0 @@ -import type { AppPageRouteDefinition } from '../../route-definitions/app-page-route-definition' -import { RouteKind } from '../../route-kind' -import { DevAppPageRouteMatcherProvider } from './dev-app-page-route-matcher-provider' -import type { FileReader } from './helpers/file-reader/file-reader' - -describe.each(['webpack', 'turbopack'])( - 'DevAppPageRouteMatcher %s', - (bundler) => { - const isTurbopack = bundler === 'turbopack' - const dir = '' - const extensions = ['ts', 'tsx', 'js', 'jsx'] - - it('returns no routes with an empty filesystem', async () => { - const reader: FileReader = { read: jest.fn(() => []) } - const provider = new DevAppPageRouteMatcherProvider( - dir, - extensions, - reader, - isTurbopack - ) - const matchers = await provider.matchers() - expect(matchers).toHaveLength(0) - expect(reader.read).toHaveBeenCalledWith(dir) - }) - - describe('filename matching', () => { - it.each<{ - files: ReadonlyArray - route: AppPageRouteDefinition - }>([ - { - files: [`${dir}/(marketing)/about/page.ts`], - route: { - kind: RouteKind.APP_PAGE, - pathname: '/about', - filename: `${dir}/(marketing)/about/page.ts`, - page: '/(marketing)/about/page', - bundlePath: 'app/(marketing)/about/page', - appPaths: ['/(marketing)/about/page'], - }, - }, - { - files: [`${dir}/(marketing)/about/page.ts`], - route: { - kind: RouteKind.APP_PAGE, - pathname: '/about', - filename: `${dir}/(marketing)/about/page.ts`, - page: '/(marketing)/about/page', - bundlePath: 'app/(marketing)/about/page', - appPaths: ['/(marketing)/about/page'], - }, - }, - { - files: [`${dir}/some/other/page.ts`], - route: { - kind: RouteKind.APP_PAGE, - pathname: '/some/other', - filename: `${dir}/some/other/page.ts`, - page: '/some/other/page', - bundlePath: 'app/some/other/page', - appPaths: ['/some/other/page'], - }, - }, - { - files: [`${dir}/page.ts`], - route: { - kind: RouteKind.APP_PAGE, - pathname: '/', - filename: `${dir}/page.ts`, - page: '/page', - bundlePath: 'app/page', - appPaths: ['/page'], - }, - }, - { - files: [`${dir}/%5Fnotignored/page.ts`], - route: { - kind: RouteKind.APP_PAGE, - pathname: '/_notignored', - filename: `${dir}/%5Fnotignored/page.ts`, - page: `/${isTurbopack ? '_' : '%5F'}notignored/page`, - bundlePath: `app/${isTurbopack ? '_' : '%5F'}notignored/page`, - appPaths: [`/${isTurbopack ? '_' : '%5F'}notignored/page`], - }, - }, - ])( - "matches the '$route.page' route specified with the provided files", - async ({ files, route }) => { - const reader: FileReader = { - read: jest.fn(() => [ - ...extensions.map((ext) => `${dir}/some/route.${ext}`), - ...extensions.map((ext) => `${dir}/api/other.${ext}`), - ...files, - ]), - } - const provider = new DevAppPageRouteMatcherProvider( - dir, - extensions, - reader, - isTurbopack - ) - const matchers = await provider.matchers() - expect(matchers).toHaveLength(1) - expect(reader.read).toHaveBeenCalledWith(dir) - expect(matchers[0].definition).toEqual(route) - } - ) - }) - } -) diff --git a/packages/next/src/server/route-matcher-providers/dev/dev-app-page-route-matcher-provider.ts b/packages/next/src/server/route-matcher-providers/dev/dev-app-page-route-matcher-provider.ts deleted file mode 100644 index ebe01928530e..000000000000 --- a/packages/next/src/server/route-matcher-providers/dev/dev-app-page-route-matcher-provider.ts +++ /dev/null @@ -1,103 +0,0 @@ -import type { FileReader } from './helpers/file-reader/file-reader' -import { AppPageRouteMatcher } from '../../route-matchers/app-page-route-matcher' -import { RouteKind } from '../../route-kind' -import { FileCacheRouteMatcherProvider } from './file-cache-route-matcher-provider' - -import { DevAppNormalizers } from '../../normalizers/built/app' -import { normalizeCatchAllRoutes } from '../../../build/normalize-catchall-routes' -import { compareAppPaths } from '../../../shared/lib/router/utils/app-paths' - -export class DevAppPageRouteMatcherProvider extends FileCacheRouteMatcherProvider { - private readonly expression: RegExp - private readonly normalizers: DevAppNormalizers - private readonly isTurbopack: boolean - - constructor( - appDir: string, - extensions: ReadonlyArray, - reader: FileReader, - isTurbopack: boolean - ) { - super(appDir, reader) - - this.normalizers = new DevAppNormalizers(appDir, extensions, isTurbopack) - - // Match any page file that ends with `/page.${extension}` or `/default.${extension}` under the app - // directory. - this.expression = new RegExp( - `[/\\\\](page|default)\\.(?:${extensions.join('|')})$` - ) - this.isTurbopack = isTurbopack - } - - protected async transform( - files: ReadonlyArray - ): Promise> { - // Collect all the app paths for each page. This could include any parallel - // routes. - const cache = new Map< - string, - { page: string; pathname: string; bundlePath: string } - >() - const routeFilenames = new Array() - let appPaths: Record = {} - for (const filename of files) { - // If the file isn't a match for this matcher, then skip it. - if (!this.expression.test(filename)) continue - - let page = this.normalizers.page.normalize(filename) - - // Validate that this is not an ignored page. - if (page.includes('/_')) continue - - // Turbopack uses the correct page name with the underscore normalized. - // TODO: Move implementation to packages/next/src/server/normalizers/built/app/app-page-normalizer.ts. - // The `includes('/_')` check above needs to be moved for that to work as otherwise `%5Fsegmentname` - // will result in `_segmentname` which hits that includes check and be skipped. - if (this.isTurbopack) { - page = page.replace(/%5F/g, '_') - } - - // This is a valid file that we want to create a matcher for. - routeFilenames.push(filename) - - const pathname = this.normalizers.pathname.normalize(filename) - const bundlePath = this.normalizers.bundlePath.normalize(filename) - - // Save the normalization results. - cache.set(filename, { page, pathname, bundlePath }) - - if (pathname in appPaths) appPaths[pathname].push(page) - else appPaths[pathname] = [page] - } - - normalizeCatchAllRoutes(appPaths) - - // Make sure to sort parallel routes to make the result deterministic. - appPaths = Object.fromEntries( - Object.entries(appPaths).map(([k, v]) => [k, v.sort(compareAppPaths)]) - ) - - const matchers: Array = [] - for (const filename of routeFilenames) { - // Grab the cached values (and the appPaths). - const cached = cache.get(filename) - if (!cached) { - throw new Error('Invariant: expected filename to exist in cache') - } - const { pathname, page, bundlePath } = cached - - matchers.push( - new AppPageRouteMatcher({ - kind: RouteKind.APP_PAGE, - pathname, - page, - bundlePath, - filename, - appPaths: appPaths[pathname], - }) - ) - } - return matchers - } -} diff --git a/packages/next/src/server/route-matcher-providers/dev/dev-app-route-route-matcher-provider.test.ts b/packages/next/src/server/route-matcher-providers/dev/dev-app-route-route-matcher-provider.test.ts deleted file mode 100644 index bc787b87054d..000000000000 --- a/packages/next/src/server/route-matcher-providers/dev/dev-app-route-route-matcher-provider.test.ts +++ /dev/null @@ -1,87 +0,0 @@ -import type { AppRouteRouteDefinition } from '../../route-definitions/app-route-route-definition' -import { RouteKind } from '../../route-kind' -import { DevAppRouteRouteMatcherProvider } from './dev-app-route-route-matcher-provider' -import type { FileReader } from './helpers/file-reader/file-reader' - -describe.each(['webpack', 'turbopack'])( - 'DevAppRouteRouteMatcher %s', - (bundler) => { - const isTurbopack = bundler === 'turbopack' - const dir = '' - const extensions = ['ts', 'tsx', 'js', 'jsx'] - - it('returns no routes with an empty filesystem', async () => { - const reader: FileReader = { read: jest.fn(() => []) } - const matcher = new DevAppRouteRouteMatcherProvider( - dir, - extensions, - reader, - isTurbopack - ) - const matchers = await matcher.matchers() - expect(matchers).toHaveLength(0) - expect(reader.read).toHaveBeenCalledWith(dir) - }) - - describe('filename matching', () => { - it.each<{ - files: ReadonlyArray - route: AppRouteRouteDefinition - }>([ - { - files: [`${dir}/some/other/route.ts`], - route: { - kind: RouteKind.APP_ROUTE, - pathname: '/some/other', - filename: `${dir}/some/other/route.ts`, - page: '/some/other/route', - bundlePath: 'app/some/other/route', - }, - }, - { - files: [`${dir}/route.ts`], - route: { - kind: RouteKind.APP_ROUTE, - pathname: '/', - filename: `${dir}/route.ts`, - page: '/route', - bundlePath: 'app/route', - }, - }, - { - files: [`${dir}/%5Fnotignored/route.ts`], - route: { - kind: RouteKind.APP_ROUTE, - pathname: '/_notignored', - filename: `${dir}/%5Fnotignored/route.ts`, - page: `/${isTurbopack ? '_' : '%5F'}notignored/route`, - bundlePath: `app/${isTurbopack ? '_' : '%5F'}notignored/route`, - }, - }, - ])( - "matches the '$route.page' route specified with the provided files", - async ({ files, route }) => { - console.log({ files }) - - const reader: FileReader = { - read: jest.fn(() => [ - ...extensions.map((ext) => `${dir}/some/page.${ext}`), - ...extensions.map((ext) => `${dir}/api/other.${ext}`), - ...files, - ]), - } - const matcher = new DevAppRouteRouteMatcherProvider( - dir, - extensions, - reader, - isTurbopack - ) - const matchers = await matcher.matchers() - expect(matchers).toHaveLength(1) - expect(reader.read).toHaveBeenCalledWith(dir) - expect(matchers[0].definition).toEqual(route) - } - ) - }) - } -) diff --git a/packages/next/src/server/route-matcher-providers/dev/dev-app-route-route-matcher-provider.ts b/packages/next/src/server/route-matcher-providers/dev/dev-app-route-route-matcher-provider.ts deleted file mode 100644 index cfa8b1d00367..000000000000 --- a/packages/next/src/server/route-matcher-providers/dev/dev-app-route-route-matcher-provider.ts +++ /dev/null @@ -1,138 +0,0 @@ -import type { FileReader } from './helpers/file-reader/file-reader' -import type { Normalizer } from '../../normalizers/normalizer' -import { AppRouteRouteMatcher } from '../../route-matchers/app-route-route-matcher' -import { RouteKind } from '../../route-kind' -import { FileCacheRouteMatcherProvider } from './file-cache-route-matcher-provider' -import { isAppRouteRoute } from '../../../lib/is-app-route-route' -import { DevAppNormalizers } from '../../normalizers/built/app' -import { - isMetadataRouteFile, - isStaticMetadataRoute, - isStaticMetadataFile, -} from '../../../lib/metadata/is-metadata-route' -import { normalizeMetadataPageToRoute } from '../../../lib/metadata/get-metadata-route' -import path from '../../../shared/lib/isomorphic/path' - -export class DevAppRouteRouteMatcherProvider extends FileCacheRouteMatcherProvider { - private readonly normalizers: { - page: Normalizer - pathname: Normalizer - bundlePath: Normalizer - } - private readonly appDir: string - private readonly isTurbopack: boolean - - constructor( - appDir: string, - extensions: ReadonlyArray, - reader: FileReader, - isTurbopack: boolean - ) { - super(appDir, reader) - - this.appDir = appDir - this.isTurbopack = isTurbopack - this.normalizers = new DevAppNormalizers(appDir, extensions, isTurbopack) - } - - protected async transform( - files: ReadonlyArray - ): Promise> { - const matchers: Array = [] - for (const filename of files) { - // Skip static metadata files as they are served from filesystem. - if (isStaticMetadataFile(filename.replace(this.appDir, ''))) { - continue - } - - let page = this.normalizers.page.normalize(filename) - - // If the file isn't a match for this matcher, then skip it. - if (!isAppRouteRoute(page)) continue - - // Validate that this is not an ignored page. - if (page.includes('/_')) continue - - // Turbopack uses the correct page name with the underscore normalized. - // TODO: Move implementation to packages/next/src/server/normalizers/built/app/app-page-normalizer.ts. - // The `includes('/_')` check above needs to be moved for that to work as otherwise `%5Fsegmentname` - // will result in `_segmentname` which hits that includes check and be skipped. - if (this.isTurbopack) { - page = page.replace(/%5F/g, '_') - } - - const pathname = this.normalizers.pathname.normalize(filename) - const bundlePath = this.normalizers.bundlePath.normalize(filename) - const ext = path.extname(filename).slice(1) - const isEntryMetadataRouteFile = isMetadataRouteFile( - filename.replace(this.appDir, ''), - [ext], - true - ) - - if (isEntryMetadataRouteFile && !isStaticMetadataRoute(page)) { - // Matching dynamic metadata routes. - // Add 2 possibilities for both single and multiple routes: - { - // single: - // /sitemap.ts -> /sitemap.xml/route - // /icon.ts -> /icon/route - // We'll map the filename before normalization: - // sitemap.ts -> sitemap.xml/route.ts - // icon.ts -> icon/route.ts - const metadataPage = normalizeMetadataPageToRoute(page, false) - const metadataPathname = normalizeMetadataPageToRoute(pathname, false) - const metadataBundlePath = normalizeMetadataPageToRoute( - bundlePath, - false - ) - - const matcher = new AppRouteRouteMatcher({ - kind: RouteKind.APP_ROUTE, - page: metadataPage, - pathname: metadataPathname, - bundlePath: metadataBundlePath, - filename, - }) - matchers.push(matcher) - } - { - // multiple: - // /sitemap.ts -> /sitemap/[__metadata_id__]/route - // /icon.ts -> /icon/[__metadata_id__]/route - // We'll map the filename before normalization: - // sitemap.ts -> sitemap.xml/[__metadata_id__].ts - // icon.ts -> icon/[__metadata_id__].ts - const metadataPage = normalizeMetadataPageToRoute(page, true) - const metadataPathname = normalizeMetadataPageToRoute(pathname, true) - const metadataBundlePath = normalizeMetadataPageToRoute( - bundlePath, - true - ) - - const matcher = new AppRouteRouteMatcher({ - kind: RouteKind.APP_ROUTE, - page: metadataPage, - pathname: metadataPathname, - bundlePath: metadataBundlePath, - filename, - }) - matchers.push(matcher) - } - } else { - // Normal app routes. - matchers.push( - new AppRouteRouteMatcher({ - kind: RouteKind.APP_ROUTE, - page, - pathname, - bundlePath, - filename, - }) - ) - } - } - - return matchers - } -} diff --git a/packages/next/src/server/route-matcher-providers/dev/dev-pages-api-route-matcher-provider.test.ts b/packages/next/src/server/route-matcher-providers/dev/dev-pages-api-route-matcher-provider.test.ts deleted file mode 100644 index 515519e7aaf4..000000000000 --- a/packages/next/src/server/route-matcher-providers/dev/dev-pages-api-route-matcher-provider.test.ts +++ /dev/null @@ -1,89 +0,0 @@ -import path from 'path' -import type { PagesAPIRouteDefinition } from '../../route-definitions/pages-api-route-definition' -import { RouteKind } from '../../route-kind' -import { DevPagesAPIRouteMatcherProvider } from './dev-pages-api-route-matcher-provider' -import type { FileReader } from './helpers/file-reader/file-reader' - -const normalizeSlashes = (p: string) => p.replace(/\//g, path.sep) - -describe('DevPagesAPIRouteMatcherProvider', () => { - const dir = '' - const extensions = ['ts', 'tsx', 'js', 'jsx'] - - it('returns no routes with an empty filesystem', async () => { - const reader: FileReader = { read: jest.fn(() => []) } - const matcher = new DevPagesAPIRouteMatcherProvider(dir, extensions, reader) - const matchers = await matcher.matchers() - expect(matchers).toHaveLength(0) - expect(reader.read).toHaveBeenCalledWith(dir) - }) - - describe('filename matching', () => { - it.each<{ - files: ReadonlyArray - route: PagesAPIRouteDefinition - }>([ - { - files: [normalizeSlashes(`${dir}/api/other/route.ts`)], - route: { - kind: RouteKind.PAGES_API, - pathname: '/api/other/route', - filename: normalizeSlashes(`${dir}/api/other/route.ts`), - page: '/api/other/route', - bundlePath: 'pages/api/other/route', - }, - }, - { - files: [normalizeSlashes(`${dir}/api/other/index.ts`)], - route: { - kind: RouteKind.PAGES_API, - pathname: '/api/other', - filename: normalizeSlashes(`${dir}/api/other/index.ts`), - page: '/api/other', - bundlePath: 'pages/api/other', - }, - }, - { - files: [normalizeSlashes(`${dir}/api.ts`)], - route: { - kind: RouteKind.PAGES_API, - pathname: '/api', - filename: normalizeSlashes(`${dir}/api.ts`), - page: '/api', - bundlePath: 'pages/api', - }, - }, - { - files: [normalizeSlashes(`${dir}/api/index.ts`)], - route: { - kind: RouteKind.PAGES_API, - pathname: '/api', - filename: normalizeSlashes(`${dir}/api/index.ts`), - page: '/api', - bundlePath: 'pages/api', - }, - }, - ])( - "matches the '$route.page' route specified with the provided files", - async ({ files, route }) => { - const reader: FileReader = { - read: jest.fn(() => [ - ...extensions.map((ext) => `${dir}/some/other/page.${ext}`), - ...extensions.map((ext) => `${dir}/some/other/route.${ext}`), - `${dir}/some/api/route.ts`, - ...files, - ]), - } - const matcher = new DevPagesAPIRouteMatcherProvider( - dir, - extensions, - reader - ) - const matchers = await matcher.matchers() - expect(matchers).toHaveLength(1) - expect(reader.read).toHaveBeenCalledWith(dir) - expect(matchers[0].definition).toEqual(route) - } - ) - }) -}) diff --git a/packages/next/src/server/route-matcher-providers/dev/dev-pages-api-route-matcher-provider.ts b/packages/next/src/server/route-matcher-providers/dev/dev-pages-api-route-matcher-provider.ts deleted file mode 100644 index 38c805a4d2bd..000000000000 --- a/packages/next/src/server/route-matcher-providers/dev/dev-pages-api-route-matcher-provider.ts +++ /dev/null @@ -1,91 +0,0 @@ -import type { FileReader } from './helpers/file-reader/file-reader' -import { - PagesAPILocaleRouteMatcher, - PagesAPIRouteMatcher, -} from '../../route-matchers/pages-api-route-matcher' -import { RouteKind } from '../../route-kind' -import path from 'path' -import type { LocaleRouteNormalizer } from '../../normalizers/locale-route-normalizer' -import { FileCacheRouteMatcherProvider } from './file-cache-route-matcher-provider' -import { DevPagesNormalizers } from '../../normalizers/built/pages' - -export class DevPagesAPIRouteMatcherProvider extends FileCacheRouteMatcherProvider { - private readonly expression: RegExp - private readonly normalizers: DevPagesNormalizers - - constructor( - private readonly pagesDir: string, - private readonly extensions: ReadonlyArray, - reader: FileReader, - private readonly localeNormalizer?: LocaleRouteNormalizer - ) { - super(pagesDir, reader) - - // Match any route file that ends with `/${filename}.${extension}` under the - // pages directory. - this.expression = new RegExp(`\\.(?:${extensions.join('|')})$`) - - this.normalizers = new DevPagesNormalizers(pagesDir, extensions) - } - - private test(filename: string): boolean { - // If the file does not end in the correct extension it's not a match. - if (!this.expression.test(filename)) return false - - // Pages API routes must exist in the pages directory with the `/api/` - // prefix. The pathnames being tested here though are the full filenames, - // so we need to include the pages directory. - - // TODO: could path separator normalization be needed here? - if (filename.startsWith(path.join(this.pagesDir, '/api/'))) return true - - for (const extension of this.extensions) { - // We can also match if we have `pages/api.${extension}`, so check to - // see if it's a match. - if (filename === path.join(this.pagesDir, `api.${extension}`)) { - return true - } - } - - return false - } - - protected async transform( - files: ReadonlyArray - ): Promise> { - const matchers: Array = [] - for (const filename of files) { - // If the file isn't a match for this matcher, then skip it. - if (!this.test(filename)) continue - - const pathname = this.normalizers.pathname.normalize(filename) - const page = this.normalizers.page.normalize(filename) - const bundlePath = this.normalizers.bundlePath.normalize(filename) - - if (this.localeNormalizer) { - matchers.push( - new PagesAPILocaleRouteMatcher({ - kind: RouteKind.PAGES_API, - pathname, - page, - bundlePath, - filename, - i18n: {}, - }) - ) - } else { - matchers.push( - new PagesAPIRouteMatcher({ - kind: RouteKind.PAGES_API, - pathname, - page, - bundlePath, - filename, - }) - ) - } - } - - return matchers - } -} diff --git a/packages/next/src/server/route-matcher-providers/dev/dev-pages-route-matcher-provider.test.ts b/packages/next/src/server/route-matcher-providers/dev/dev-pages-route-matcher-provider.test.ts deleted file mode 100644 index 35c20c3dfaf4..000000000000 --- a/packages/next/src/server/route-matcher-providers/dev/dev-pages-route-matcher-provider.test.ts +++ /dev/null @@ -1,89 +0,0 @@ -import path from 'path' -import type { PagesRouteDefinition } from '../../route-definitions/pages-route-definition' -import { RouteKind } from '../../route-kind' -import { DevPagesRouteMatcherProvider } from './dev-pages-route-matcher-provider' -import type { FileReader } from './helpers/file-reader/file-reader' - -const normalizeSlashes = (p: string) => p.replace(/\//g, path.sep) - -describe('DevPagesRouteMatcherProvider', () => { - const dir = '' - const extensions = ['ts', 'tsx', 'js', 'jsx'] - - it('returns no routes with an empty filesystem', async () => { - const reader: FileReader = { read: jest.fn(() => []) } - const matcher = new DevPagesRouteMatcherProvider(dir, extensions, reader) - const matchers = await matcher.matchers() - expect(matchers).toHaveLength(0) - expect(reader.read).toHaveBeenCalledWith(dir) - }) - - describe('filename matching', () => { - it.each<{ - files: ReadonlyArray - route: PagesRouteDefinition - }>([ - { - files: [normalizeSlashes(`${dir}/index.ts`)], - route: { - kind: RouteKind.PAGES, - pathname: '/', - filename: normalizeSlashes(`${dir}/index.ts`), - page: '/', - bundlePath: 'pages/index', - }, - }, - { - files: [normalizeSlashes(`${dir}/some/api/route.ts`)], - route: { - kind: RouteKind.PAGES, - pathname: '/some/api/route', - filename: normalizeSlashes(`${dir}/some/api/route.ts`), - page: '/some/api/route', - bundlePath: 'pages/some/api/route', - }, - }, - { - files: [normalizeSlashes(`${dir}/some/other/route/index.ts`)], - route: { - kind: RouteKind.PAGES, - pathname: '/some/other/route', - filename: normalizeSlashes(`${dir}/some/other/route/index.ts`), - page: '/some/other/route', - bundlePath: 'pages/some/other/route', - }, - }, - { - files: [normalizeSlashes(`${dir}/some/other/route/index/route.ts`)], - route: { - kind: RouteKind.PAGES, - pathname: '/some/other/route/index/route', - filename: normalizeSlashes(`${dir}/some/other/route/index/route.ts`), - page: '/some/other/route/index/route', - bundlePath: 'pages/some/other/route/index/route', - }, - }, - ])( - "matches the '$route.page' route specified with the provided files", - async ({ files, route }) => { - const reader: FileReader = { - read: jest.fn(() => [ - ...extensions.map((ext) => - normalizeSlashes(`${dir}/api/other/page.${ext}`) - ), - ...files, - ]), - } - const matcher = new DevPagesRouteMatcherProvider( - dir, - extensions, - reader - ) - const matchers = await matcher.matchers() - expect(matchers).toHaveLength(1) - expect(reader.read).toHaveBeenCalledWith(dir) - expect(matchers[0].definition).toEqual(route) - } - ) - }) -}) diff --git a/packages/next/src/server/route-matcher-providers/dev/dev-pages-route-matcher-provider.ts b/packages/next/src/server/route-matcher-providers/dev/dev-pages-route-matcher-provider.ts deleted file mode 100644 index fef702de3d2b..000000000000 --- a/packages/next/src/server/route-matcher-providers/dev/dev-pages-route-matcher-provider.ts +++ /dev/null @@ -1,91 +0,0 @@ -import type { FileReader } from './helpers/file-reader/file-reader' -import { - PagesRouteMatcher, - PagesLocaleRouteMatcher, -} from '../../route-matchers/pages-route-matcher' -import { RouteKind } from '../../route-kind' -import path from 'path' -import type { LocaleRouteNormalizer } from '../../normalizers/locale-route-normalizer' -import { FileCacheRouteMatcherProvider } from './file-cache-route-matcher-provider' -import { DevPagesNormalizers } from '../../normalizers/built/pages' - -export class DevPagesRouteMatcherProvider extends FileCacheRouteMatcherProvider { - private readonly expression: RegExp - private readonly normalizers: DevPagesNormalizers - - constructor( - private readonly pagesDir: string, - private readonly extensions: ReadonlyArray, - reader: FileReader, - private readonly localeNormalizer?: LocaleRouteNormalizer - ) { - super(pagesDir, reader) - - // Match any route file that ends with `/${filename}.${extension}` under the - // pages directory. - this.expression = new RegExp(`\\.(?:${extensions.join('|')})$`) - - this.normalizers = new DevPagesNormalizers(pagesDir, extensions) - } - - private test(filename: string): boolean { - // If the file does not end in the correct extension it's not a match. - if (!this.expression.test(filename)) return false - - // Pages routes must exist in the pages directory without the `/api/` - // prefix. The pathnames being tested here though are the full filenames, - // so we need to include the pages directory. - - // TODO: could path separator normalization be needed here? - if (filename.startsWith(path.join(this.pagesDir, '/api/'))) return false - - for (const extension of this.extensions) { - // We can also match if we have `pages/api.${extension}`, so check to - // see if it's a match. - if (filename === path.join(this.pagesDir, `api.${extension}`)) { - return false - } - } - - return true - } - - protected async transform( - files: ReadonlyArray - ): Promise> { - const matchers: Array = [] - for (const filename of files) { - // If the file isn't a match for this matcher, then skip it. - if (!this.test(filename)) continue - - const pathname = this.normalizers.pathname.normalize(filename) - const page = this.normalizers.page.normalize(filename) - const bundlePath = this.normalizers.bundlePath.normalize(filename) - - if (this.localeNormalizer) { - matchers.push( - new PagesLocaleRouteMatcher({ - kind: RouteKind.PAGES, - pathname, - page, - bundlePath, - filename, - i18n: {}, - }) - ) - } else { - matchers.push( - new PagesRouteMatcher({ - kind: RouteKind.PAGES, - pathname, - page, - bundlePath, - filename, - }) - ) - } - } - - return matchers - } -} diff --git a/packages/next/src/server/route-matcher-providers/dev/file-cache-route-matcher-provider.ts b/packages/next/src/server/route-matcher-providers/dev/file-cache-route-matcher-provider.ts deleted file mode 100644 index 8f8424d8bdde..000000000000 --- a/packages/next/src/server/route-matcher-providers/dev/file-cache-route-matcher-provider.ts +++ /dev/null @@ -1,26 +0,0 @@ -import type { RouteMatcher } from '../../route-matchers/route-matcher' -import { CachedRouteMatcherProvider } from '../helpers/cached-route-matcher-provider' -import type { FileReader } from './helpers/file-reader/file-reader' - -/** - * This will memoize the matchers when the file contents are the same. - */ -export abstract class FileCacheRouteMatcherProvider< - M extends RouteMatcher = RouteMatcher, -> extends CachedRouteMatcherProvider> { - constructor(dir: string, reader: FileReader) { - super({ - load: async () => reader.read(dir), - compare: (left, right) => { - if (left.length !== right.length) return false - - // Assuming the file traversal order is deterministic... - for (let i = 0; i < left.length; i++) { - if (left[i] !== right[i]) return false - } - - return true - }, - }) - } -} diff --git a/packages/next/src/server/route-matcher-providers/dev/helpers/file-reader/batched-file-reader.test.ts b/packages/next/src/server/route-matcher-providers/dev/helpers/file-reader/batched-file-reader.test.ts deleted file mode 100644 index 7529e819e8c1..000000000000 --- a/packages/next/src/server/route-matcher-providers/dev/helpers/file-reader/batched-file-reader.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { BatchedFileReader } from './batched-file-reader' -import type { FileReader } from './file-reader' - -describe('CachedFileReader', () => { - it('will only scan the filesystem a minimal amount of times', async () => { - const pages = ['1', '2', '3'] - const app = ['4', '5', '6'] - - const reader: FileReader = { - read: jest.fn(async (directory: string) => { - switch (directory) { - case '/pages': - return pages - case '/app': - return app - default: - throw new Error('unexpected') - } - }), - } - const cached = new BatchedFileReader(reader) - - const results = await Promise.all([ - cached.read('/pages'), - cached.read('/pages'), - cached.read('/app'), - cached.read('/app'), - ]) - - expect(reader.read).toHaveBeenCalledTimes(2) - expect(results).toHaveLength(4) - expect(results[0]).toBe(pages) - expect(results[1]).toBe(pages) - expect(results[2]).toBe(app) - expect(results[3]).toBe(app) - }) - - it('will send an error back only to the correct reader', async () => { - const resolved: string[] = [] - const reader: FileReader = { - read: jest.fn(async (directory: string) => { - switch (directory) { - case 'reject': - throw new Error('rejected') - case 'resolve': - return resolved - default: - throw new Error('should not occur') - } - }), - } - const cached = new BatchedFileReader(reader) - - await Promise.all( - ['reject', 'resolve', 'reject', 'resolve'].map(async (directory) => { - if (directory === 'reject') { - await expect(cached.read(directory)).rejects.toThrow('rejected') - } else { - await expect(cached.read(directory)).resolves.toEqual(resolved) - } - }) - ) - - expect(reader.read).toHaveBeenCalledTimes(2) - }) -}) diff --git a/packages/next/src/server/route-matcher-providers/dev/helpers/file-reader/batched-file-reader.ts b/packages/next/src/server/route-matcher-providers/dev/helpers/file-reader/batched-file-reader.ts deleted file mode 100644 index 3b7b5f6e0a7a..000000000000 --- a/packages/next/src/server/route-matcher-providers/dev/helpers/file-reader/batched-file-reader.ts +++ /dev/null @@ -1,125 +0,0 @@ -import type { FileReader } from './file-reader' - -interface FileReaderBatch { - completed: boolean - directories: Array - callbacks: Array<{ - resolve: (value: ReadonlyArray) => void - reject: (err: any) => void - }> -} - -/** - * CachedFileReader will deduplicate requests made to the same folder structure - * to scan for files. - */ -export class BatchedFileReader implements FileReader { - private batch?: FileReaderBatch - - constructor(private readonly reader: FileReader) {} - - // This allows us to schedule the batches after all the promises associated - // with loading files. - private schedulePromise?: Promise - private schedule(callback: Function) { - if (!this.schedulePromise) { - this.schedulePromise = Promise.resolve() - } - this.schedulePromise.then(() => { - process.nextTick(callback) - }) - } - - private getOrCreateBatch(): FileReaderBatch { - // If there is an existing batch and it's not completed, then reuse it. - if (this.batch && !this.batch.completed) { - return this.batch - } - - const batch: FileReaderBatch = { - completed: false, - directories: [], - callbacks: [], - } - - this.batch = batch - - this.schedule(async () => { - batch.completed = true - if (batch.directories.length === 0) return - - // Collect all the results for each of the directories. If any error - // occurs, send the results back to the loaders. - let values: ReadonlyArray | Error> - try { - values = await this.load(batch.directories) - } catch (err) { - // Reject all the callbacks. - for (const { reject } of batch.callbacks) { - reject(err) - } - return - } - - // Loop over all the callbacks and send them their results. - for (let i = 0; i < batch.callbacks.length; i++) { - const value = values[i] - if (value instanceof Error) { - batch.callbacks[i].reject(value) - } else { - batch.callbacks[i].resolve(value) - } - } - }) - - return batch - } - - private async load( - directories: ReadonlyArray - ): Promise | Error>> { - // Make a unique array of directories. This is what lets us de-duplicate - // loads for the same directory. - const unique = [...new Set(directories)] - - const results = await Promise.all( - unique.map(async (directory) => { - let files: ReadonlyArray | undefined - let error: Error | undefined - try { - files = await this.reader.read(directory) - } catch (err) { - if (err instanceof Error) error = err - } - - return { directory, files, error } - }) - ) - - return directories.map((directory) => { - const found = results.find((result) => result.directory === directory) - if (!found) return [] - - if (found.files) return found.files - if (found.error) return found.error - - return [] - }) - } - - public async read(dir: string): Promise> { - // Get or create a new file reading batch. - const batch = this.getOrCreateBatch() - - // Push this directory into the batch to resolve. - batch.directories.push(dir) - - // Push the promise handles into the batch (under the same index) so it can - // be resolved later when it's scheduled. - const promise = new Promise>((resolve, reject) => { - batch.callbacks.push({ resolve, reject }) - }) - - return promise - } -} diff --git a/packages/next/src/server/route-matcher-providers/dev/helpers/file-reader/default-file-reader.ts b/packages/next/src/server/route-matcher-providers/dev/helpers/file-reader/default-file-reader.ts deleted file mode 100644 index 7154918bff50..000000000000 --- a/packages/next/src/server/route-matcher-providers/dev/helpers/file-reader/default-file-reader.ts +++ /dev/null @@ -1,53 +0,0 @@ -import type { FileReader } from './file-reader' -import type { RecursiveReadDirOptions } from '../../../../../lib/recursive-readdir' -import { recursiveReadDir } from '../../../../../lib/recursive-readdir' - -export type DefaultFileReaderOptions = Pick< - RecursiveReadDirOptions, - 'pathnameFilter' | 'ignorePartFilter' -> - -/** - * Reads all the files in the directory and its subdirectories following any - * symbolic links. - */ -export class DefaultFileReader implements FileReader { - /** - * Filter to ignore files with absolute pathnames. If undefined, no files are - * ignored. - */ - private readonly options: Readonly - - /** - * Creates a new file reader. - * - * @param pathnameFilter filter to ignore files with absolute pathnames, false to ignore - * @param ignoreFilter filter to ignore files and directories with absolute pathnames, false to ignore - * @param ignorePartFilter filter to ignore files and directories with the pathname part, false to ignore - */ - constructor(options: Readonly) { - this.options = options - } - - /** - * Reads all the files in the directory and its subdirectories following any - * symbolic links. - * - * @param dir the directory to read - * @returns a promise that resolves to the list of files - */ - public async read(dir: string): Promise> { - return recursiveReadDir(dir, { - pathnameFilter: this.options.pathnameFilter, - ignorePartFilter: this.options.ignorePartFilter, - - // We don't need to sort the results because we're not depending on the - // order of the results. - sortPathnames: false, - - // We want absolute pathnames because we're going to be comparing them - // with other absolute pathnames. - relativePathnames: false, - }) - } -} diff --git a/packages/next/src/server/route-matcher-providers/dev/helpers/file-reader/file-reader.ts b/packages/next/src/server/route-matcher-providers/dev/helpers/file-reader/file-reader.ts deleted file mode 100644 index ece4fd3d65cf..000000000000 --- a/packages/next/src/server/route-matcher-providers/dev/helpers/file-reader/file-reader.ts +++ /dev/null @@ -1,8 +0,0 @@ -export interface FileReader { - /** - * Reads the directory contents recursively. - * - * @param dir directory to read recursively from - */ - read(dir: string): Promise> | ReadonlyArray -} diff --git a/packages/next/src/server/route-matcher-providers/helpers/cached-route-matcher-provider.ts b/packages/next/src/server/route-matcher-providers/helpers/cached-route-matcher-provider.ts deleted file mode 100644 index 2546e6a8b88f..000000000000 --- a/packages/next/src/server/route-matcher-providers/helpers/cached-route-matcher-provider.ts +++ /dev/null @@ -1,40 +0,0 @@ -import type { RouteMatcherProvider } from '../route-matcher-provider' -import type { RouteMatcher } from '../../route-matchers/route-matcher' - -interface LoaderComparable { - load(): Promise - compare(left: D, right: D): boolean -} - -/** - * This will memoize the matchers if the loaded data is comparable. - */ -export abstract class CachedRouteMatcherProvider< - M extends RouteMatcher = RouteMatcher, - D = any, -> implements RouteMatcherProvider -{ - private data?: D - private cached: ReadonlyArray = [] - - constructor(private readonly loader: LoaderComparable) {} - - protected abstract transform(data: D): Promise> - - public async matchers(): Promise { - const data = await this.loader.load() - if (!data) return [] - - // Return the cached matchers if the data has not changed. - if (this.data && this.loader.compare(this.data, data)) return this.cached - this.data = data - - // Transform the manifest into matchers. - const matchers = await this.transform(data) - - // Cache the matchers. - this.cached = matchers - - return matchers - } -} diff --git a/packages/next/src/server/route-matcher-providers/helpers/manifest-loaders/manifest-loader.ts b/packages/next/src/server/route-matcher-providers/helpers/manifest-loaders/manifest-loader.ts deleted file mode 100644 index dbaf73cffb9d..000000000000 --- a/packages/next/src/server/route-matcher-providers/helpers/manifest-loaders/manifest-loader.ts +++ /dev/null @@ -1,5 +0,0 @@ -export type Manifest = Record - -export interface ManifestLoader { - load(name: string): Manifest | null -} diff --git a/packages/next/src/server/route-matcher-providers/helpers/manifest-loaders/node-manifest-loader.ts b/packages/next/src/server/route-matcher-providers/helpers/manifest-loaders/node-manifest-loader.ts deleted file mode 100644 index 60745a407a89..000000000000 --- a/packages/next/src/server/route-matcher-providers/helpers/manifest-loaders/node-manifest-loader.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { SERVER_DIRECTORY } from '../../../../shared/lib/constants' -import path from '../../../../shared/lib/isomorphic/path' -import type { Manifest, ManifestLoader } from './manifest-loader' - -export class NodeManifestLoader implements ManifestLoader { - constructor(private readonly distDir: string) {} - - static require(id: string) { - try { - return require(id) - } catch { - return null - } - } - - public load(name: string): Manifest | null { - return NodeManifestLoader.require( - path.join(this.distDir, SERVER_DIRECTORY, name) - ) - } -} diff --git a/packages/next/src/server/route-matcher-providers/helpers/manifest-loaders/server-manifest-loader.ts b/packages/next/src/server/route-matcher-providers/helpers/manifest-loaders/server-manifest-loader.ts deleted file mode 100644 index 27f187bd66e6..000000000000 --- a/packages/next/src/server/route-matcher-providers/helpers/manifest-loaders/server-manifest-loader.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type { Manifest, ManifestLoader } from './manifest-loader' - -export class ServerManifestLoader implements ManifestLoader { - constructor(private readonly getter: (name: string) => Manifest | null) {} - - public load(name: string): Manifest | null { - return this.getter(name) - } -} diff --git a/packages/next/src/server/route-matcher-providers/manifest-route-matcher-provider.ts b/packages/next/src/server/route-matcher-providers/manifest-route-matcher-provider.ts deleted file mode 100644 index aef4856bd3ce..000000000000 --- a/packages/next/src/server/route-matcher-providers/manifest-route-matcher-provider.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { RouteMatcher } from '../route-matchers/route-matcher' -import type { - Manifest, - ManifestLoader, -} from './helpers/manifest-loaders/manifest-loader' -import { CachedRouteMatcherProvider } from './helpers/cached-route-matcher-provider' - -export abstract class ManifestRouteMatcherProvider< - M extends RouteMatcher = RouteMatcher, -> extends CachedRouteMatcherProvider { - constructor(manifestName: string, manifestLoader: ManifestLoader) { - super({ - load: async () => manifestLoader.load(manifestName), - compare: (left, right) => left === right, - }) - } -} diff --git a/packages/next/src/server/route-matcher-providers/pages-api-route-matcher-provider.test.ts b/packages/next/src/server/route-matcher-providers/pages-api-route-matcher-provider.test.ts deleted file mode 100644 index 3a39dfa8b2b4..000000000000 --- a/packages/next/src/server/route-matcher-providers/pages-api-route-matcher-provider.test.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { PAGES_MANIFEST, SERVER_DIRECTORY } from '../../shared/lib/constants' -import type { PagesAPIRouteDefinition } from '../route-definitions/pages-api-route-definition' -import { RouteKind } from '../route-kind' -import type { ManifestLoader } from './helpers/manifest-loaders/manifest-loader' -import { PagesAPIRouteMatcherProvider } from './pages-api-route-matcher-provider' - -describe('PagesAPIRouteMatcherProvider', () => { - it('returns no routes with an empty manifest', async () => { - const loader: ManifestLoader = { load: jest.fn(() => ({})) } - const provider = new PagesAPIRouteMatcherProvider('', loader) - expect(await provider.matchers()).toEqual([]) - expect(loader.load).toHaveBeenCalledWith(PAGES_MANIFEST) - }) - - describe('manifest matching', () => { - it.each<{ - manifest: Record - route: PagesAPIRouteDefinition - }>([ - { - manifest: { '/api/users/[id]': 'pages/api/users/[id].js' }, - route: { - kind: RouteKind.PAGES_API, - pathname: '/api/users/[id]', - filename: `/${SERVER_DIRECTORY}/pages/api/users/[id].js`, - page: '/api/users/[id]', - bundlePath: 'pages/api/users/[id]', - }, - }, - { - manifest: { '/api/users': 'pages/api/users.js' }, - route: { - kind: RouteKind.PAGES_API, - pathname: '/api/users', - filename: `/${SERVER_DIRECTORY}/pages/api/users.js`, - page: '/api/users', - bundlePath: 'pages/api/users', - }, - }, - { - manifest: { '/api': 'pages/api.js' }, - route: { - kind: RouteKind.PAGES_API, - pathname: '/api', - filename: `/${SERVER_DIRECTORY}/pages/api.js`, - page: '/api', - bundlePath: 'pages/api', - }, - }, - ])( - 'returns the correct routes for $route.pathname', - async ({ manifest, route }) => { - const loader: ManifestLoader = { - load: jest.fn(() => ({ - '/users/[id]': 'pages/users/[id].js', - '/users': 'pages/users.js', - ...manifest, - })), - } - const provider = new PagesAPIRouteMatcherProvider('', loader) - const matchers = await provider.matchers() - - expect(loader.load).toHaveBeenCalledWith(PAGES_MANIFEST) - expect(matchers).toHaveLength(1) - expect(matchers[0].definition).toEqual(route) - } - ) - }) -}) diff --git a/packages/next/src/server/route-matcher-providers/pages-api-route-matcher-provider.ts b/packages/next/src/server/route-matcher-providers/pages-api-route-matcher-provider.ts deleted file mode 100644 index 4873a40a16d2..000000000000 --- a/packages/next/src/server/route-matcher-providers/pages-api-route-matcher-provider.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { isAPIRoute } from '../../lib/is-api-route' -import { PAGES_MANIFEST } from '../../shared/lib/constants' -import { RouteKind } from '../route-kind' -import { - PagesAPILocaleRouteMatcher, - PagesAPIRouteMatcher, -} from '../route-matchers/pages-api-route-matcher' -import type { - Manifest, - ManifestLoader, -} from './helpers/manifest-loaders/manifest-loader' -import { ManifestRouteMatcherProvider } from './manifest-route-matcher-provider' -import type { I18NProvider } from '../lib/i18n-provider' -import { PagesNormalizers } from '../normalizers/built/pages' - -export class PagesAPIRouteMatcherProvider extends ManifestRouteMatcherProvider { - private readonly normalizers: PagesNormalizers - - constructor( - distDir: string, - manifestLoader: ManifestLoader, - private readonly i18nProvider?: I18NProvider - ) { - super(PAGES_MANIFEST, manifestLoader) - - this.normalizers = new PagesNormalizers(distDir) - } - - protected async transform( - manifest: Manifest - ): Promise> { - // This matcher is only for Pages API routes. - const pathnames = Object.keys(manifest).filter((pathname) => - isAPIRoute(pathname) - ) - - const matchers: Array = [] - - for (const page of pathnames) { - if (this.i18nProvider) { - // Match the locale on the page name, or default to the default locale. - const { detectedLocale, pathname } = this.i18nProvider.analyze(page) - - matchers.push( - new PagesAPILocaleRouteMatcher({ - kind: RouteKind.PAGES_API, - pathname, - page, - bundlePath: this.normalizers.bundlePath.normalize(page), - filename: this.normalizers.filename.normalize(manifest[page]), - i18n: { - locale: detectedLocale, - }, - }) - ) - } else { - matchers.push( - new PagesAPIRouteMatcher({ - kind: RouteKind.PAGES_API, - // In `pages/`, the page is the same as the pathname. - pathname: page, - page, - bundlePath: this.normalizers.bundlePath.normalize(page), - filename: this.normalizers.filename.normalize(manifest[page]), - }) - ) - } - } - - return matchers - } -} diff --git a/packages/next/src/server/route-matcher-providers/pages-route-matcher-provider.test.ts b/packages/next/src/server/route-matcher-providers/pages-route-matcher-provider.test.ts deleted file mode 100644 index e28ddeaa392d..000000000000 --- a/packages/next/src/server/route-matcher-providers/pages-route-matcher-provider.test.ts +++ /dev/null @@ -1,185 +0,0 @@ -import { PAGES_MANIFEST, SERVER_DIRECTORY } from '../../shared/lib/constants' -import { I18NProvider } from '../lib/i18n-provider' -import type { PagesRouteDefinition } from '../route-definitions/pages-route-definition' -import { RouteKind } from '../route-kind' -import type { ManifestLoader } from './helpers/manifest-loaders/manifest-loader' -import { PagesRouteMatcherProvider } from './pages-route-matcher-provider' - -describe('PagesRouteMatcherProvider', () => { - it('returns no routes with an empty manifest', async () => { - const loader: ManifestLoader = { load: jest.fn(() => ({})) } - const provider = new PagesRouteMatcherProvider('', loader) - expect(await provider.matchers()).toEqual([]) - expect(loader.load).toHaveBeenCalledWith(PAGES_MANIFEST) - }) - - describe('locale matching', () => { - describe.each<{ - manifest: Record - routes: ReadonlyArray - i18n: { locales: Array; defaultLocale: string } - }>([ - { - manifest: { - '/_app': 'pages/_app.js', - '/_error': 'pages/_error.js', - '/_document': 'pages/_document.js', - '/blog/[slug]': 'pages/blog/[slug].js', - '/en-US/404': 'pages/en-US/404.html', - '/fr/404': 'pages/fr/404.html', - '/nl-NL/404': 'pages/nl-NL/404.html', - '/en-US': 'pages/en-US.html', - '/fr': 'pages/fr.html', - '/nl-NL': 'pages/nl-NL.html', - }, - i18n: { locales: ['en-US', 'fr', 'nl-NL'], defaultLocale: 'en-US' }, - routes: [ - { - kind: RouteKind.PAGES, - pathname: '/blog/[slug]', - filename: `/${SERVER_DIRECTORY}/pages/blog/[slug].js`, - page: '/blog/[slug]', - bundlePath: 'pages/blog/[slug]', - i18n: {}, - }, - { - kind: RouteKind.PAGES, - pathname: '/', - filename: `/${SERVER_DIRECTORY}/pages/en-US.html`, - page: '/en-US', - bundlePath: 'pages/en-US', - i18n: { - locale: 'en-US', - }, - }, - { - kind: RouteKind.PAGES, - pathname: '/', - filename: `/${SERVER_DIRECTORY}/pages/fr.html`, - page: '/fr', - bundlePath: 'pages/fr', - i18n: { - locale: 'fr', - }, - }, - { - kind: RouteKind.PAGES, - pathname: '/', - filename: `/${SERVER_DIRECTORY}/pages/nl-NL.html`, - page: '/nl-NL', - bundlePath: 'pages/nl-NL', - i18n: { - locale: 'nl-NL', - }, - }, - { - kind: RouteKind.PAGES, - pathname: '/404', - filename: `/${SERVER_DIRECTORY}/pages/en-US/404.html`, - page: '/en-US/404', - bundlePath: 'pages/en-US/404', - i18n: { - locale: 'en-US', - }, - }, - { - kind: RouteKind.PAGES, - pathname: '/404', - filename: `/${SERVER_DIRECTORY}/pages/fr/404.html`, - page: '/fr/404', - bundlePath: 'pages/fr/404', - i18n: { - locale: 'fr', - }, - }, - { - kind: RouteKind.PAGES, - pathname: '/404', - filename: `/${SERVER_DIRECTORY}/pages/nl-NL/404.html`, - page: '/nl-NL/404', - bundlePath: 'pages/nl-NL/404', - i18n: { - locale: 'nl-NL', - }, - }, - ], - }, - ])('locale', ({ routes: expected, manifest, i18n }) => { - it.each(expected)('has the match for $pathname', async (route) => { - const loader: ManifestLoader = { - load: jest.fn(() => ({ - '/api/users/[id]': 'pages/api/users/[id].js', - '/api/users': 'pages/api/users.js', - ...manifest, - })), - } - const provider = new PagesRouteMatcherProvider( - '', - loader, - new I18NProvider(i18n) - ) - const matchers = await provider.matchers() - - expect(loader.load).toHaveBeenCalledWith(PAGES_MANIFEST) - const routes = matchers.map((matcher) => matcher.definition) - expect(routes).toContainEqual(route) - expect(routes).toHaveLength(expected.length) - }) - }) - }) - - describe('manifest matching', () => { - it.each<{ - manifest: Record - route: PagesRouteDefinition - }>([ - { - manifest: { '/users/[id]': 'pages/users/[id].js' }, - route: { - kind: RouteKind.PAGES, - pathname: '/users/[id]', - filename: `/${SERVER_DIRECTORY}/pages/users/[id].js`, - page: '/users/[id]', - bundlePath: 'pages/users/[id]', - }, - }, - { - manifest: { '/users': 'pages/users.js' }, - route: { - kind: RouteKind.PAGES, - pathname: '/users', - filename: `/${SERVER_DIRECTORY}/pages/users.js`, - page: '/users', - bundlePath: 'pages/users', - }, - }, - { - manifest: { '/': 'pages/index.js' }, - route: { - kind: RouteKind.PAGES, - pathname: '/', - filename: `/${SERVER_DIRECTORY}/pages/index.js`, - page: '/', - bundlePath: 'pages/index', - }, - }, - ])( - 'returns the correct routes for $route.pathname', - async ({ manifest, route }) => { - const loader: ManifestLoader = { - load: jest.fn(() => ({ - '/api/users/[id]': 'pages/api/users/[id].js', - '/api/users': 'pages/api/users.js', - ...manifest, - })), - } - const matcher = new PagesRouteMatcherProvider('', loader) - const matchers = await matcher.matchers() - - expect(loader.load).toHaveBeenCalledWith(PAGES_MANIFEST) - expect(matchers).toHaveLength(1) - expect(matchers[0].definition).toEqual(route) - } - ) - }) -}) diff --git a/packages/next/src/server/route-matcher-providers/pages-route-matcher-provider.ts b/packages/next/src/server/route-matcher-providers/pages-route-matcher-provider.ts deleted file mode 100644 index 580fd4879b19..000000000000 --- a/packages/next/src/server/route-matcher-providers/pages-route-matcher-provider.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { isAPIRoute } from '../../lib/is-api-route' -import { BLOCKED_PAGES, PAGES_MANIFEST } from '../../shared/lib/constants' -import { RouteKind } from '../route-kind' -import { - PagesLocaleRouteMatcher, - PagesRouteMatcher, -} from '../route-matchers/pages-route-matcher' -import type { - Manifest, - ManifestLoader, -} from './helpers/manifest-loaders/manifest-loader' -import { ManifestRouteMatcherProvider } from './manifest-route-matcher-provider' -import type { I18NProvider } from '../lib/i18n-provider' -import { PagesNormalizers } from '../normalizers/built/pages' - -export class PagesRouteMatcherProvider extends ManifestRouteMatcherProvider { - private readonly normalizers: PagesNormalizers - - constructor( - distDir: string, - manifestLoader: ManifestLoader, - private readonly i18nProvider?: I18NProvider - ) { - super(PAGES_MANIFEST, manifestLoader) - - this.normalizers = new PagesNormalizers(distDir) - } - - protected async transform( - manifest: Manifest - ): Promise> { - // This matcher is only for Pages routes, not Pages API routes which are - // included in this manifest. - const pathnames = Object.keys(manifest) - .filter((pathname) => !isAPIRoute(pathname)) - // Remove any blocked pages (page that can't be routed to, like error or - // internal pages). - .filter((pathname) => { - const normalized = - this.i18nProvider?.analyze(pathname).pathname ?? pathname - - // Skip any blocked pages. - if (BLOCKED_PAGES.includes(normalized)) return false - - return true - }) - - const matchers: Array = [] - for (const page of pathnames) { - if (this.i18nProvider) { - // Match the locale on the page name, or default to the default locale. - const { detectedLocale, pathname } = this.i18nProvider.analyze(page) - - matchers.push( - new PagesLocaleRouteMatcher({ - kind: RouteKind.PAGES, - pathname, - page, - bundlePath: this.normalizers.bundlePath.normalize(page), - filename: this.normalizers.filename.normalize(manifest[page]), - i18n: { - locale: detectedLocale, - }, - }) - ) - } else { - matchers.push( - new PagesRouteMatcher({ - kind: RouteKind.PAGES, - // In `pages/`, the page is the same as the pathname. - pathname: page, - page, - bundlePath: this.normalizers.bundlePath.normalize(page), - filename: this.normalizers.filename.normalize(manifest[page]), - }) - ) - } - } - - return matchers - } -} diff --git a/packages/next/src/server/route-matcher-providers/route-matcher-provider.ts b/packages/next/src/server/route-matcher-providers/route-matcher-provider.ts deleted file mode 100644 index d2c470126384..000000000000 --- a/packages/next/src/server/route-matcher-providers/route-matcher-provider.ts +++ /dev/null @@ -1,5 +0,0 @@ -import type { RouteMatcher } from '../route-matchers/route-matcher' - -export interface RouteMatcherProvider { - matchers(): Promise> -} diff --git a/packages/next/src/server/route-matchers/app-page-route-matcher.ts b/packages/next/src/server/route-matchers/app-page-route-matcher.ts deleted file mode 100644 index a87216d6843d..000000000000 --- a/packages/next/src/server/route-matchers/app-page-route-matcher.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { RouteMatcher } from './route-matcher' -import type { AppPageRouteDefinition } from '../route-definitions/app-page-route-definition' - -export class AppPageRouteMatcher extends RouteMatcher { - public get identity(): string { - return `${this.definition.pathname}?__nextPage=${this.definition.page}` - } -} diff --git a/packages/next/src/server/route-matchers/app-route-route-matcher.ts b/packages/next/src/server/route-matchers/app-route-route-matcher.ts deleted file mode 100644 index 2f64ea91a1f1..000000000000 --- a/packages/next/src/server/route-matchers/app-route-route-matcher.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { RouteMatcher } from './route-matcher' -import type { AppRouteRouteDefinition } from '../route-definitions/app-route-route-definition' - -export class AppRouteRouteMatcher extends RouteMatcher {} diff --git a/packages/next/src/server/route-matchers/locale-route-matcher.ts b/packages/next/src/server/route-matchers/locale-route-matcher.ts deleted file mode 100644 index a4c8595f2782..000000000000 --- a/packages/next/src/server/route-matchers/locale-route-matcher.ts +++ /dev/null @@ -1,85 +0,0 @@ -import type { LocaleAnalysisResult } from '../lib/i18n-provider' -import type { LocaleRouteDefinition } from '../route-definitions/locale-route-definition' -import type { LocaleRouteMatch } from '../route-matches/locale-route-match' -import { RouteMatcher } from './route-matcher' - -export type LocaleMatcherMatchOptions = { - /** - * If defined, this indicates to the matcher that the request should be - * treated as locale-aware. If this is undefined, it means that this - * application was not configured for additional locales. - */ - i18n?: LocaleAnalysisResult -} - -export class LocaleRouteMatcher< - D extends LocaleRouteDefinition = LocaleRouteDefinition, -> extends RouteMatcher { - /** - * Identity returns the identity part of the matcher. This is used to compare - * a unique matcher to another. This is also used when sorting dynamic routes, - * so it must contain the pathname part as well. - */ - public get identity(): string { - return `${this.definition.pathname}?__nextLocale=${this.definition.i18n?.locale}` - } - - /** - * Match will attempt to match the given pathname against this route while - * also taking into account the locale information. - * - * @param pathname The pathname to match against. - * @param options The options to use when matching. - * @returns The match result, or `null` if there was no match. - */ - public match( - pathname: string, - options?: LocaleMatcherMatchOptions - ): LocaleRouteMatch | null { - // This is like the parent `match` method but instead this injects the - // additional `options` into the - const result = this.test(pathname, options) - if (!result) return null - - return { - definition: this.definition, - params: result.params, - detectedLocale: - // If the options have a detected locale, then use that, otherwise use - // the route's locale. - options?.i18n?.detectedLocale ?? this.definition.i18n?.locale, - } - } - - /** - * Test will attempt to match the given pathname against this route while - * also taking into account the locale information. - * - * @param pathname The pathname to match against. - * @param options The options to use when matching. - * @returns The match result, or `null` if there was no match. - */ - public test(pathname: string, options?: LocaleMatcherMatchOptions) { - // If this route has locale information and we have detected a locale, then - // we need to compare the detected locale to the route's locale. - if (this.definition.i18n && options?.i18n) { - // If we have detected a locale and it does not match this route's locale, - // then this isn't a match! - if ( - this.definition.i18n.locale && - options.i18n.detectedLocale && - this.definition.i18n.locale !== options.i18n.detectedLocale - ) { - return null - } - - // Perform regular matching against the locale stripped pathname now, the - // locale information matches! - return super.test(options.i18n.pathname) - } - - // If we don't have locale information, then we can just perform regular - // matching. - return super.test(pathname) - } -} diff --git a/packages/next/src/server/route-matchers/pages-api-route-matcher.ts b/packages/next/src/server/route-matchers/pages-api-route-matcher.ts deleted file mode 100644 index d8a1611acb3f..000000000000 --- a/packages/next/src/server/route-matchers/pages-api-route-matcher.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type { PagesAPIRouteDefinition } from '../route-definitions/pages-api-route-definition' -import { LocaleRouteMatcher } from './locale-route-matcher' -import { RouteMatcher } from './route-matcher' - -export class PagesAPIRouteMatcher extends RouteMatcher {} - -export class PagesAPILocaleRouteMatcher extends LocaleRouteMatcher {} diff --git a/packages/next/src/server/route-matchers/pages-route-matcher.ts b/packages/next/src/server/route-matchers/pages-route-matcher.ts deleted file mode 100644 index 571db79a5247..000000000000 --- a/packages/next/src/server/route-matchers/pages-route-matcher.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type { PagesRouteDefinition } from '../route-definitions/pages-route-definition' -import { LocaleRouteMatcher } from './locale-route-matcher' -import { RouteMatcher } from './route-matcher' - -export class PagesRouteMatcher extends RouteMatcher {} - -export class PagesLocaleRouteMatcher extends LocaleRouteMatcher {} diff --git a/packages/next/src/server/route-matchers/route-matcher.ts b/packages/next/src/server/route-matchers/route-matcher.ts deleted file mode 100644 index 0dadaad131df..000000000000 --- a/packages/next/src/server/route-matchers/route-matcher.ts +++ /dev/null @@ -1,66 +0,0 @@ -import type { RouteMatch } from '../route-matches/route-match' -import type { RouteDefinition } from '../route-definitions/route-definition' -import type { Params } from '../request/params' - -import { isDynamicRoute } from '../../shared/lib/router/utils' -import { - getRouteMatcher, - type RouteMatchFn, -} from '../../shared/lib/router/utils/route-matcher' -import { getRouteRegex } from '../../shared/lib/router/utils/route-regex' - -type RouteMatchResult = { - params?: Params -} - -export class RouteMatcher { - private readonly dynamic?: RouteMatchFn - - /** - * When set, this is an array of all the other matchers that are duplicates of - * this one. This is used by the managers to warn the users about possible - * duplicate matches on routes. - */ - public duplicated?: Array - - constructor(public readonly definition: D) { - if (isDynamicRoute(definition.pathname)) { - this.dynamic = getRouteMatcher(getRouteRegex(definition.pathname)) - } - } - - /** - * Identity returns the identity part of the matcher. This is used to compare - * a unique matcher to another. This is also used when sorting dynamic routes, - * so it must contain the pathname part. - */ - public get identity(): string { - return this.definition.pathname - } - - public get isDynamic() { - return this.dynamic !== undefined - } - - public match(pathname: string): RouteMatch | null { - const result = this.test(pathname) - if (!result) return null - - return { definition: this.definition, params: result.params } - } - - public test(pathname: string): RouteMatchResult | null { - if (this.dynamic) { - const params = this.dynamic(pathname) - if (!params) return null - - return { params } - } - - if (pathname === this.definition.pathname) { - return {} - } - - return null - } -} diff --git a/packages/next/src/server/web/edge-route-module-wrapper.ts b/packages/next/src/server/web/edge-route-module-wrapper.ts index f8c002bc8944..16f942421349 100644 --- a/packages/next/src/server/web/edge-route-module-wrapper.ts +++ b/packages/next/src/server/web/edge-route-module-wrapper.ts @@ -12,7 +12,6 @@ import { } from '../lib/incremental-cache' import type { CacheHandler } from '../lib/cache-handlers/types' import { initializeCacheHandlers, setCacheHandler } from '../use-cache/handlers' -import { RouteMatcher } from '../route-matchers/route-matcher' import type { NextFetchEvent } from './spec-extension/fetch-event' import { internal_getCurrentFunctionWaitUntil } from './internal-edge-wait-until' import { getServerUtils } from '../server-utils' @@ -20,6 +19,7 @@ import { searchParamsToUrlQuery } from '../../shared/lib/router/utils/querystrin import { CloseController, trackStreamConsumed } from './web-on-close' import { getEdgePreviewProps } from './get-edge-preview-props' import { WebNextRequest } from '../../server/base-http/web' +import { isDynamicRoute } from '../../shared/lib/router/utils' export interface WrapOptions { page: string @@ -33,7 +33,7 @@ export interface WrapOptions { * Note that this class should only be used in the edge runtime. */ export class EdgeRouteModuleWrapper { - private readonly matcher: RouteMatcher + private readonly pageIsDynamic: boolean /** * The constructor is wrapped with private to ensure that it can only be @@ -45,8 +45,7 @@ export class EdgeRouteModuleWrapper { private readonly routeModule: AppRouteRouteModule, private readonly cacheHandlers: Record ) { - // TODO: (wyattjoh) possibly allow the module to define it's own matcher - this.matcher = new RouteMatcher(routeModule.definition) + this.pageIsDynamic = isDynamicRoute(routeModule.definition.pathname) } /** @@ -86,8 +85,8 @@ export class EdgeRouteModuleWrapper { evt: NextFetchEvent ): Promise { const utils = getServerUtils({ - pageIsDynamic: this.matcher.isDynamic, - page: this.matcher.definition.pathname, + pageIsDynamic: this.pageIsDynamic, + page: this.routeModule.definition.pathname, basePath: request.nextUrl.basePath, // We don't need the `handleRewrite` util, so can just pass an empty object rewrites: {}, diff --git a/packages/next/src/shared/lib/router/utils/app-paths.test.ts b/packages/next/src/shared/lib/router/utils/app-paths.test.ts index f815af3deaa1..752d8662cbc8 100644 --- a/packages/next/src/shared/lib/router/utils/app-paths.test.ts +++ b/packages/next/src/shared/lib/router/utils/app-paths.test.ts @@ -1,4 +1,73 @@ -import { normalizeRscURL } from './app-paths' +import { + compareAppPaths, + normalizeRscURL, + selectAppPageEntry, +} from './app-paths' + +describe('selectAppPageEntry', () => { + it('prefers the direct children page over an expanded catch-all slot', () => { + const appPaths = ['/@slot/[...catchAll]/page', '/foo/page'].sort( + compareAppPaths + ) + + expect(selectAppPageEntry('/foo', appPaths)).toBe('/foo/page') + }) + + it('prefers the direct children page over a direct parallel slot', () => { + const appPaths = ['/[...catchAll]/page', '/@slot/[...catchAll]/page'].sort( + compareAppPaths + ) + + expect(selectAppPageEntry('/[...catchAll]', appPaths)).toBe( + '/[...catchAll]/page' + ) + }) + + it('prefers the direct children page regardless of input order', () => { + const appPaths = [ + '/parallel/nested-2/page', + '/parallel/(new)/@baz/nested-2/page', + ] + + expect(selectAppPageEntry('/parallel/nested-2', appPaths)).toBe( + '/parallel/nested-2/page' + ) + expect( + selectAppPageEntry('/parallel/nested-2', [...appPaths].reverse()) + ).toBe('/parallel/nested-2/page') + }) + + it('deterministically selects an entry for a slot-only route', () => { + const appPaths = ['/@alpha/foo/page', '/@beta/foo/page'] + + expect(selectAppPageEntry('/foo', appPaths)).toBe('/@beta/foo/page') + expect(selectAppPageEntry('/foo', [...appPaths].reverse())).toBe( + '/@beta/foo/page' + ) + }) + + it('matches escaped underscore entries to decoded pathnames', () => { + expect(selectAppPageEntry('/_shop', ['/%5Fshop/page'])).toBe( + '/%5Fshop/page' + ) + }) + + it('rejects a route with no direct app path', () => { + const appPaths = ['/[...catchAll]/page', '/@slot/[...catchAll]/page'] + + expect(() => selectAppPageEntry('/unrelated', appPaths)).toThrow( + 'Invariant: no direct app page entry found for /unrelated' + ) + }) +}) + +describe('compareAppPaths', () => { + it('sorts parallel slots before the children page', () => { + expect( + ['/[...catchAll]/page', '/@slot/[...catchAll]/page'].sort(compareAppPaths) + ).toEqual(['/@slot/[...catchAll]/page', '/[...catchAll]/page']) + }) +}) describe('normalizeRscPath', () => { it('should normalize url with .rsc', () => { diff --git a/packages/next/src/shared/lib/router/utils/app-paths.ts b/packages/next/src/shared/lib/router/utils/app-paths.ts index 36232fe2f033..fa0152cf2b1a 100644 --- a/packages/next/src/shared/lib/router/utils/app-paths.ts +++ b/packages/next/src/shared/lib/router/utils/app-paths.ts @@ -53,9 +53,8 @@ export function normalizeAppPath(route: string) { /** * Comparator for sorting app paths so that parallel slot paths (containing - * `/@`) come before the children/root page path. This ensures the last item - * is always the children page, which is what `renderPageComponent` reads via - * `appPaths[appPaths.length - 1]`. + * `/@`) come before the children/root page path. This keeps the direct + * children/root page last so it can be selected as the canonical entry. * * Without this, route group prefixes like `(group)` (char code 0x28) sort * before `@` (0x40), causing the children page to sort first instead of last @@ -69,6 +68,40 @@ export function compareAppPaths(a: string, b: string): number { return a.localeCompare(b) } +function normalizeAppPageEntryPathname(appPath: string): string { + // Webpack app entries preserve escaped underscore segments as `%5F`, while + // normalized request pathnames expose the decoded `_` segment. + return normalizeAppPath(appPath).replace(/%5F/g, '_') +} + +/** + * Selects the app path that owns the compiled entry for a normalized route. + * Catch-all normalization can add app paths from other routes, so only direct + * paths are candidates. Among those, compareAppPaths deterministically prefers + * the children/root page, or a stable slot when the route only has slots. + */ +export function selectAppPageEntry( + pathname: string, + appPaths: readonly string[], + normalizePathname: (appPath: string) => string = normalizeAppPageEntryPathname +): string { + let entry: string | undefined + + for (const appPath of appPaths) { + if (normalizePathname(appPath) !== pathname) continue + + if (entry === undefined || compareAppPaths(entry, appPath) < 0) { + entry = appPath + } + } + + if (entry === undefined) { + throw new Error(`Invariant: no direct app page entry found for ${pathname}`) + } + + return entry +} + /** * Strips the `.rsc` extension if it's in the pathname. * Since this function is used on full urls it checks `?` for searchParams handling. diff --git a/test/development/app-dir/build-error-logs/build-error-logs.test.ts b/test/development/app-dir/build-error-logs/build-error-logs.test.ts index cb10d71df0ee..330ca6c58a11 100644 --- a/test/development/app-dir/build-error-logs/build-error-logs.test.ts +++ b/test/development/app-dir/build-error-logs/build-error-logs.test.ts @@ -22,7 +22,7 @@ describe('build-error-logs', () => { expect([2, 3]).toContain(moduleNotFoundLogs.length) } else { // FIXME: next with webpack still logs the same error too many times - expect(moduleNotFoundLogs).toHaveLength(3) + expect(moduleNotFoundLogs).toHaveLength(2) } }) }) diff --git a/test/development/app-dir/request-insights-route-preparation/app/api/route-preparation/route.ts b/test/development/app-dir/request-insights-route-preparation/app/api/route-preparation/route.ts deleted file mode 100644 index 5c000c66500d..000000000000 --- a/test/development/app-dir/request-insights-route-preparation/app/api/route-preparation/route.ts +++ /dev/null @@ -1,3 +0,0 @@ -export function GET() { - return Response.json({ route: 'prepared' }) -} diff --git a/test/development/app-dir/request-insights-route-preparation/app/layout.tsx b/test/development/app-dir/request-insights-route-preparation/app/layout.tsx deleted file mode 100644 index 888614deda3b..000000000000 --- a/test/development/app-dir/request-insights-route-preparation/app/layout.tsx +++ /dev/null @@ -1,8 +0,0 @@ -import { ReactNode } from 'react' -export default function Root({ children }: { children: ReactNode }) { - return ( - - {children} - - ) -} diff --git a/test/development/app-dir/request-insights-route-preparation/app/page.tsx b/test/development/app-dir/request-insights-route-preparation/app/page.tsx deleted file mode 100644 index ee04b3aece43..000000000000 --- a/test/development/app-dir/request-insights-route-preparation/app/page.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export default function Page() { - return

route preparation page

-} diff --git a/test/development/app-dir/request-insights-route-preparation/next.config.js b/test/development/app-dir/request-insights-route-preparation/next.config.js deleted file mode 100644 index 08fe24df52be..000000000000 --- a/test/development/app-dir/request-insights-route-preparation/next.config.js +++ /dev/null @@ -1,10 +0,0 @@ -/** - * @type {import('next').NextConfig} - */ -const nextConfig = { - experimental: { - requestInsights: true, - }, -} - -module.exports = nextConfig diff --git a/test/development/app-dir/request-insights-route-preparation/request-insights-route-preparation.test.ts b/test/development/app-dir/request-insights-route-preparation/request-insights-route-preparation.test.ts deleted file mode 100644 index be76bcc83b99..000000000000 --- a/test/development/app-dir/request-insights-route-preparation/request-insights-route-preparation.test.ts +++ /dev/null @@ -1,255 +0,0 @@ -import { nextTestSetup } from 'e2e-utils' -import { retry } from 'next-test-utils' - -describe('request-insights-route-preparation', () => { - const { next } = nextTestSetup({ - files: __dirname, - }) - - type RequestInsightSpan = { - name: string - durationMs?: number - status?: 'ok' | 'error' - traceId?: string - spanId?: string - parentSpanId?: string - attributes?: Record - } - - type RequestInsight = { - requestId: string - route?: string - status: 'ok' | 'error' | 'pending' - spans: RequestInsightSpan[] - } - - const routePreparationSpanType = 'DevRouteMatcherManager.ensureRoute' - const matcherReloadSpanType = 'DevRouteMatcherManager.reloadMatchers' - const routeCompilationSpanType = 'DevBundlerService.ensurePage' - const routeModulePrepareSpanType = 'RouteModule.prepare' - const routeManifestLoadSpanType = 'RouteModule.loadManifests' - - async function getRequestInsights() { - return (await next - .fetch('/_next/development/request-insights') - .then((response) => response.json())) as { - requests: RequestInsight[] - } - } - - async function captureRequest( - route: string, - request: () => Promise - ) { - const existingRequestIds = new Set( - (await getRequestInsights()).requests - .filter((insight) => insight.route === route) - .map((insight) => insight.requestId) - ) - - await request() - - let capturedRequest: RequestInsight | undefined - await retry(async () => { - capturedRequest = (await getRequestInsights()).requests.find( - (insight) => - insight.route === route && - insight.status === 'ok' && - !existingRequestIds.has(insight.requestId) && - insight.spans.some( - (span) => - span.attributes?.['next.span_type'] === - 'BaseServer.handleRequest' && - span.status === 'ok' && - typeof span.durationMs === 'number' - ) && - insight.spans.some( - (span) => - span.attributes?.['next.span_type'] === routePreparationSpanType - ) && - insight.spans.some( - (span) => - span.attributes?.['next.span_type'] === matcherReloadSpanType - ) && - insight.spans.some( - (span) => - span.attributes?.['next.span_type'] === routeCompilationSpanType - ) - ) - - expect(capturedRequest).toBeDefined() - }, 10_000) - - return capturedRequest! - } - - function expectRoutePreparationSpans(request: RequestInsight) { - const spanById = new Map( - request.spans.flatMap((span) => - span.spanId ? [[span.spanId, span] as const] : [] - ) - ) - const rootSpan = request.spans.find( - (span) => - span.attributes?.['next.span_type'] === 'BaseServer.handleRequest' - ) - const routePreparationSpans = request.spans.filter( - (span) => span.attributes?.['next.span_type'] === routePreparationSpanType - ) - const matcherReloadSpans = request.spans.filter( - (span) => span.attributes?.['next.span_type'] === matcherReloadSpanType - ) - - expect(rootSpan?.spanId).toBeDefined() - expect(rootSpan?.traceId).toBeDefined() - expect(routePreparationSpans).toHaveLength(1) - expect(matcherReloadSpans).toHaveLength(1) - expect(routePreparationSpans[0].spanId).toBeDefined() - expect(matcherReloadSpans[0].spanId).toBeDefined() - expect(matcherReloadSpans[0].spanId).not.toBe( - routePreparationSpans[0].spanId - ) - expect(matcherReloadSpans[0].parentSpanId).toBe( - routePreparationSpans[0].parentSpanId - ) - - for (const [span, name, type] of [ - [routePreparationSpans[0], 'prepare route', routePreparationSpanType], - [matcherReloadSpans[0], 'reload route matchers', matcherReloadSpanType], - ] as const) { - expect(span).toEqual( - expect.objectContaining({ - name, - durationMs: expect.any(Number), - status: 'ok', - attributes: { - 'next.span_category': 'nextjs', - 'next.span_name': name, - 'next.span_type': type, - }, - }) - ) - expect(Number.isFinite(span.durationMs)).toBe(true) - expect(span.durationMs).toBeGreaterThanOrEqual(0) - expect(span.traceId).toBe(rootSpan?.traceId) - - let ancestor = span.parentSpanId - ? spanById.get(span.parentSpanId) - : undefined - const visited = new Set() - while ( - ancestor?.spanId !== rootSpan?.spanId && - ancestor?.parentSpanId && - !visited.has(ancestor.parentSpanId) - ) { - visited.add(ancestor.parentSpanId) - ancestor = spanById.get(ancestor.parentSpanId) - } - expect(ancestor?.spanId).toBe(rootSpan?.spanId) - } - - const routePreparationSpan = routePreparationSpans[0] - const routeCompilationSpans = request.spans.filter( - (span) => - span.attributes?.['next.span_type'] === routeCompilationSpanType && - span.parentSpanId === routePreparationSpan.spanId - ) - expect(routeCompilationSpans).toHaveLength(1) - - const routeCompilationSpan = routeCompilationSpans[0] - expect(routeCompilationSpan).toEqual( - expect.objectContaining({ - name: 'compile route', - durationMs: expect.any(Number), - status: 'ok', - parentSpanId: routePreparationSpan.spanId, - attributes: { - 'next.span_category': 'nextjs', - 'next.span_name': 'compile route', - 'next.span_type': routeCompilationSpanType, - }, - }) - ) - expect(Number.isFinite(routeCompilationSpan.durationMs)).toBe(true) - expect(routeCompilationSpan.durationMs).toBeGreaterThanOrEqual(0) - expect(routeCompilationSpan.traceId).toBe(rootSpan?.traceId) - } - - function expectRouteModulePreparationSpans(request: RequestInsight) { - const rootSpan = request.spans.find( - (span) => - span.attributes?.['next.span_type'] === 'BaseServer.handleRequest' - ) - const prepareSpans = request.spans.filter( - (span) => - span.attributes?.['next.span_type'] === routeModulePrepareSpanType - ) - const manifestLoadSpans = request.spans.filter( - (span) => - span.attributes?.['next.span_type'] === routeManifestLoadSpanType - ) - - expect(prepareSpans).toEqual([ - expect.objectContaining({ - name: 'prepare route module', - status: 'ok', - traceId: rootSpan?.traceId, - attributes: { - 'next.span_category': 'nextjs', - 'next.span_name': 'prepare route module', - 'next.span_type': routeModulePrepareSpanType, - }, - }), - ]) - expect(manifestLoadSpans).toEqual([ - expect.objectContaining({ - name: 'load route manifests', - status: 'ok', - traceId: rootSpan?.traceId, - parentSpanId: prepareSpans[0].spanId, - attributes: { - 'next.span_category': 'nextjs', - 'next.span_name': 'load route manifests', - 'next.span_type': routeManifestLoadSpanType, - }, - }), - ]) - } - - it('records route preparation for first and subsequent App Page requests', async () => { - const coldRequest = await captureRequest('/', async () => { - const response = await next.fetch('/') - expect(response.status).toBe(200) - expect(await response.text()).toContain('route preparation page') - }) - const warmRequest = await captureRequest('/', async () => { - const response = await next.fetch('/') - expect(response.status).toBe(200) - await response.text() - }) - - expectRoutePreparationSpans(coldRequest) - expectRoutePreparationSpans(warmRequest) - expectRouteModulePreparationSpans(coldRequest) - expectRouteModulePreparationSpans(warmRequest) - }) - - it('records route preparation for first and subsequent App Route requests', async () => { - const route = '/api/route-preparation' - const coldRequest = await captureRequest(route, async () => { - const response = await next.fetch(route) - expect(response.status).toBe(200) - expect(await response.json()).toEqual({ route: 'prepared' }) - }) - const warmRequest = await captureRequest(route, async () => { - const response = await next.fetch(route) - expect(response.status).toBe(200) - await response.text() - }) - - expectRoutePreparationSpans(coldRequest) - expectRoutePreparationSpans(warmRequest) - expectRouteModulePreparationSpans(coldRequest) - expectRouteModulePreparationSpans(warmRequest) - }) -}) diff --git a/test/e2e/app-dir/adapter-dynamic-metadata/adapter-dynamic-metadata.test.ts b/test/e2e/app-dir/adapter-dynamic-metadata/adapter-dynamic-metadata.test.ts index 2a03a961fab9..52621775562f 100644 --- a/test/e2e/app-dir/adapter-dynamic-metadata/adapter-dynamic-metadata.test.ts +++ b/test/e2e/app-dir/adapter-dynamic-metadata/adapter-dynamic-metadata.test.ts @@ -6,12 +6,7 @@ describe('adapter-dynamic-metadata', () => { files: __dirname, }) - if (isNextDev) { - it('should skip next dev', () => {}) - return - } - - if (!isNextDeploy) { + if (!isNextDev && !isNextDeploy) { it('should classify dynamic metadata routes as app routes in adapter outputs', async () => { const { outputs }: Parameters[0] = await next.readJSON('build-complete.json') diff --git a/test/e2e/app-dir/app-root-params-getters/multiple-roots.test.ts b/test/e2e/app-dir/app-root-params-getters/multiple-roots.test.ts index 6cae531ccd77..b23c14b4283f 100644 --- a/test/e2e/app-dir/app-root-params-getters/multiple-roots.test.ts +++ b/test/e2e/app-dir/app-root-params-getters/multiple-roots.test.ts @@ -82,7 +82,7 @@ describe('app-root-param-getters - multiple roots', () => { // This should make the bundler re-generate 'next/root-params' again, with `things` instead of `stuff`. if (isTurbopack) { // FIXME(turbopack): Something in our routing logic doesn't handle renaming a route param in turbopack mode. - // I haven't found the cause for this, but `DefaultRouteMatcherManager.reload` calls + // I haven't found the cause for this, but dev route sorting calls // `getSortedRoutes(['/dashboard/[id]', '/new-root/[stuff]', '/new-root/[things]'])` // which makes it error because it looks like we have two overlapping routes. // I'm not sure why the previous route doesn't get removed and couldn't find a workaround, diff --git a/test/e2e/app-dir/catchall-parallel-routes-group/catchall-parallel-routes-group.test.ts b/test/e2e/app-dir/catchall-parallel-routes-group/catchall-parallel-routes-group.test.ts index 424ed8317ec8..2a079fe9c4eb 100644 --- a/test/e2e/app-dir/catchall-parallel-routes-group/catchall-parallel-routes-group.test.ts +++ b/test/e2e/app-dir/catchall-parallel-routes-group/catchall-parallel-routes-group.test.ts @@ -1,22 +1,45 @@ import { nextTestSetup } from 'e2e-utils' -import { check } from 'next-test-utils' +import { retry } from 'next-test-utils' describe('catchall-parallel-routes-group', () => { - const { next } = nextTestSetup({ + const { next, isNextStart } = nextTestSetup({ files: __dirname, }) + if (isNextStart) { + it('orders the canonical page last in the app path routes manifest', async () => { + const manifest = JSON.parse( + await next.readFile('.next/app-path-routes-manifest.json') + ) + const entries = Object.entries(manifest) + .filter(([, pathname]) => pathname === '/[...catchAll]') + .map(([entry]) => entry) + + expect(entries).toEqual([ + '/[...catchAll]/@slot/(group)/page', + '/[...catchAll]/page', + ]) + }) + } + it('should work without throwing any errors about invalid pages', async () => { const browser = await next.browser('/') - await check(() => browser.elementByCss('body').text(), /Root Page/) + await retry(async () => { + expect(await browser.elementByCss('body').text()).toMatch(/Root Page/) + }) await browser.elementByCss('[href="/foobar"]').click() // catch all matches page, but also slot with layout and group - await check(() => browser.elementByCss('body').text(), /Catch-all Page/) - await check( - () => browser.elementByCss('body').text(), - /Catch-all Slot Group Page/ - ) + await retry(async () => { + expect(await browser.elementByCss('body').text()).toMatch( + /Catch-all Page/ + ) + }) + await retry(async () => { + expect(await browser.elementByCss('body').text()).toMatch( + /Catch-all Slot Group Page/ + ) + }) }) }) diff --git a/test/e2e/custom-server/custom-server.test.ts b/test/e2e/custom-server/custom-server.test.ts index 0d2d24adadc3..31f29985fa5e 100644 --- a/test/e2e/custom-server/custom-server.test.ts +++ b/test/e2e/custom-server/custom-server.test.ts @@ -34,6 +34,13 @@ describe.each([ }) if (skipped) return + it('should render the custom 404 page for an unmatched request', async () => { + const response = await next.fetch('/does-not-exist', { agent }) + + expect(response.status).toBe(404) + expect(await response.text()).toContain('made it to 404') + }) + it('should serve internal file from render', async () => { const html = await next.render('/static/hello.txt', undefined, { agent }) expect(html).toMatch(/hello world/) @@ -389,7 +396,8 @@ describe.each([ ], ['revalidate', '/legacy-methods/revalidate'], ])('warns for NextCustomServer.%s', async (method, path) => { - await next.fetch(path, { agent }) + const response = await next.fetch(path, { agent }) + expect(response.status).toBe(200) await retry(async () => { expect(next.cliOutput).toContain(deprecatedWarning(method)) }) diff --git a/test/e2e/custom-server/server.js b/test/e2e/custom-server/server.js index b8ef147c45a3..b01abde3c90a 100644 --- a/test/e2e/custom-server/server.js +++ b/test/e2e/custom-server/server.js @@ -112,7 +112,11 @@ async function main() { if (/legacy-methods\/revalidate/.test(req.url)) { try { await app.revalidate({ urlPath: '/', headers: {}, opts: {} }) - } catch {} + } catch (err) { + res.statusCode = 500 + res.end(err.stack) + return + } res.end('ok') return } diff --git a/test/e2e/opentelemetry/client-trace-metadata/client-trace-metadata.test.ts b/test/e2e/opentelemetry/client-trace-metadata/client-trace-metadata.test.ts index a8a31c7f157e..1a18e34c04d3 100644 --- a/test/e2e/opentelemetry/client-trace-metadata/client-trace-metadata.test.ts +++ b/test/e2e/opentelemetry/client-trace-metadata/client-trace-metadata.test.ts @@ -1,7 +1,7 @@ import { nextTestSetup } from 'e2e-utils' describe('clientTraceMetadata', () => { - const { next, isNextDev } = nextTestSetup({ + const { next, isNextDev, isNextStart } = nextTestSetup({ files: __dirname, dependencies: require('./package.json').dependencies, // This test sometimes takes longer than the default timeout, extending it bit longer @@ -104,7 +104,10 @@ describe('clientTraceMetadata', () => { expect(initialSpanIdTagContent).toBe(updatedSpanIdTagContent) }) }) - } else { + } else if (isNextStart) { + // Deploy platforms may regenerate App Router prerenders inside a request + // span. These assertions specifically cover the build output served by + // next start, where no per-request propagation data should be present. describe('next start only', () => { it('should not inject propagation data for a statically server-side-rendered page', async () => { const $ = await next.render$('/app-router/static-page') diff --git a/test/production/adapter-config/adapter-config.test.ts b/test/production/adapter-config/adapter-config.test.ts index 3928c45c0b5b..27253300a6e3 100644 --- a/test/production/adapter-config/adapter-config.test.ts +++ b/test/production/adapter-config/adapter-config.test.ts @@ -1,4 +1,5 @@ import fs from 'fs' +import path from 'path' import { nextTestSetup } from 'e2e-utils' import type { AdapterOutput, NextAdapter } from 'next' import { version as nextVersion } from 'next/package.json' @@ -374,6 +375,10 @@ describe('adapter-config', () => { expect(appPageOutput).toBeDefined() expect(pagesOutput).toBeDefined() + expect(appPageOutput?.sourcePage).toBe('/node-app/page') + expect(appPageOutput?.filePath).toEndWith( + path.join('server', 'app', 'node-app', 'page.js') + ) // Check that vendored context files are included in assets const appPageAssets = Object.values(appPageOutput!.assets) diff --git a/test/production/adapter-config/app/node-app/@dialog/page.tsx b/test/production/adapter-config/app/node-app/@dialog/page.tsx new file mode 100644 index 000000000000..94cae4fd0f3b --- /dev/null +++ b/test/production/adapter-config/app/node-app/@dialog/page.tsx @@ -0,0 +1,3 @@ +export default function Page() { + return 'dialog' +} diff --git a/test/production/app-dir/empty-resume/empty-resume.test.ts b/test/production/app-dir/empty-resume/empty-resume.test.ts index e3f2bc9a01a9..2be849547e37 100644 --- a/test/production/app-dir/empty-resume/empty-resume.test.ts +++ b/test/production/app-dir/empty-resume/empty-resume.test.ts @@ -13,6 +13,19 @@ describe('empty resume', () => { }, }) + it('preserves the fallback shell for a platform route match', async () => { + const slug = 'fallback-shell' + const response = await next.fetch(`/dynamic/${slug}`, { + headers: { + 'x-matched-path': '/dynamic/[slug]', + 'x-now-route-matches': createNowRouteMatches({ slug }).toString(), + }, + }) + + expect(response.status).toBe(200) + expect(response.headers.get('x-nextjs-postponed')).toBe('1') + }) + it('treats an empty Next-Resume body as a dynamic RSC request', async () => { const slug = 'cold-rdc' const response = await next.fetch(`/dynamic/${slug}.rsc`, { diff --git a/test/production/required-server-files-ssr-404/pages/dynamic/[...path].js b/test/production/required-server-files-ssr-404/pages/dynamic/[...path].js new file mode 100644 index 000000000000..3776e1b4ab95 --- /dev/null +++ b/test/production/required-server-files-ssr-404/pages/dynamic/[...path].js @@ -0,0 +1,16 @@ +export function getServerSideProps({ params }) { + return { + props: { + path: params.path, + }, + } +} + +export default function Page(props) { + return ( + <> +

[...path] page

+

{JSON.stringify(props)}

+ + ) +} diff --git a/test/production/required-server-files-ssr-404/test/index.test.ts b/test/production/required-server-files-ssr-404/test/index.test.ts index 247ee1c36a24..d9a2c0875f21 100644 --- a/test/production/required-server-files-ssr-404/test/index.test.ts +++ b/test/production/required-server-files-ssr-404/test/index.test.ts @@ -716,6 +716,47 @@ describe('Required Server Files', () => { expect($('#slug-page').text()).toBe('[slug] page') }) + it('should preserve dynamic route identity from x-matched-path', async () => { + const res = await fetchViaHTTP( + appPort, + '/dynamic/first/second', + undefined, + withInvocationId({ + headers: { + 'x-matched-path': '/dynamic/[...path]', + }, + redirect: 'manual', + }) + ) + + const html = await res.text() + const $ = cheerio.load(html) + const props = JSON.parse($('#props').text()) + + expect($('#catch-all-page').text()).toBe('[...path] page') + expect(props.path).toEqual(['first', 'second']) + }) + + it('should rematch the concrete path after using x-matched-path', async () => { + const res = await fetchViaHTTP( + appPort, + '/dynamic/first', + undefined, + withInvocationId({ + headers: { + 'x-matched-path': '/dynamic/[...path]', + }, + redirect: 'manual', + }) + ) + + const html = await res.text() + const $ = cheerio.load(html) + + expect($('#dynamic').text()).toBe('dynamic page') + expect($('#slug').text()).toBe('first') + }) + it('should handle 404s properly', async () => { for (const pathname of [ '/static/some-file.js', diff --git a/test/production/standalone-mode/required-server-files/required-server-files-i18n.test.ts b/test/production/standalone-mode/required-server-files/required-server-files-i18n.test.ts index 3fca982583a7..a94c08a5f163 100644 --- a/test/production/standalone-mode/required-server-files/required-server-files-i18n.test.ts +++ b/test/production/standalone-mode/required-server-files/required-server-files-i18n.test.ts @@ -934,6 +934,70 @@ describe('required server files i18n', () => { expect($('#index').text()).toBe('index page') }) + it('should remove a locale captured as a dynamic route param', async () => { + const res = await fetchViaHTTP( + appPort, + '/fr', + undefined, + withInvocationId({ + headers: { + 'x-matched-path': '/[slug]', + 'x-now-route-matches': createNowRouteMatches( + { + slug: 'fr', + }, + { + nextLocale: 'fr', + } + ).toString(), + }, + redirect: 'manual', + }) + ) + + expect(res.status).toBe(200) + const html = await res.text() + const $ = cheerio.load(html) + expect($('#index').text()).toBe('index page') + expect(JSON.parse($('#router').text())).toMatchObject({ + locale: 'fr', + query: {}, + }) + }) + + it('should preserve a route param that looks like another locale', async () => { + const res = await fetchViaHTTP( + appPort, + '/fr/nl-NL', + undefined, + withInvocationId({ + headers: { + 'x-matched-path': '/fr/[slug]', + 'x-now-route-matches': createNowRouteMatches( + { + slug: 'nl-NL', + }, + { + nextLocale: 'fr', + } + ).toString(), + }, + redirect: 'manual', + }) + ) + + expect(res.status).toBe(200) + const html = await res.text() + const $ = cheerio.load(html) + expect($('#slug-page').text()).toBe('[slug] page') + expect(JSON.parse($('#router').text())).toMatchObject({ + locale: 'fr', + query: { + slug: 'nl-NL', + }, + }) + }) + it('should match the root dyanmic page correctly', async () => { const res = await fetchViaHTTP( appPort, From 904a41853a084ba55118668e440e7c2ada60c2b9 Mon Sep 17 00:00:00 2001 From: Niklas Mischkulnig <4586894+mischnic@users.noreply.github.com> Date: Sat, 15 Aug 2026 14:37:13 +0200 Subject: [PATCH 2/3] Turbopack: retain conditions when replacing resolve request keys (#97372) Closes https://github.com/vercel/next.js/issues/97358 `ResolveResult::with_replaced_request_key` overwrote the `conditions` of every result key with the conditions of the replacement key (empty at all call sites). Those conditions are what distinguishes the results one request resolved to under different export conditions, so both the `module-sync` and the `default`/`require` target of the same subpath ended up under the same `RequestKey`. That is lossy as soon as a request resolves in more than one `node_modules` directory: the results are merged through a `RequestKey`-keyed map, so one of the two targets is silently dropped. With a single candidate directory `ResolveResult::alternatives` short-circuits and never builds that map, which is why this only showed up in nested layouts. In practice this broke `output: 'standalone'` with the Turbopack production bundler for pnpm installs, whose virtual store hoists every package into `node_modules/.pnpm/node_modules` and thereby creates that second candidate directory: `next-server.js.nft.json` recorded only `@swc/helpers/cjs/_interop_require_default.cjs`, while Node >= 22.12 resolves `@swc/helpers/_/_interop_require_default` to the `module-sync` target `esm/_interop_require_default.js` (`@swc/helpers` 0.5.23 lists `module-sync` first). The copied `.next/standalone/server.js` then exited with MODULE_NOT_FOUND before listening. --------- Co-authored-by: vercel-fleet[bot] <308483924+vercel-fleet[bot]@users.noreply.github.com> --- .../next-server-nft/next-server-nft.test.ts | 1 + .../crates/turbopack-core/src/resolve/mod.rs | 18 +++++++----------- .../tests/node-file-trace/.gitignore | 3 +++ .../input.js | 2 ++ .../node_modules/@scope/helpers/cjs/helper.cjs | 1 + .../node_modules/@scope/helpers/esm/helper.js | 1 + .../node_modules/@scope/helpers/package.json | 13 +++++++++++++ .../.store/node_modules/@scope/helpers | 1 + .../pkg@1.0.0/node_modules/@scope/helpers | 1 + .../.store/pkg@1.0.0/node_modules/pkg/index.js | 2 ++ .../pkg@1.0.0/node_modules/pkg/package.json | 5 +++++ .../node_modules/pkg | 1 + .../output.js | 14 ++++++++++++++ .../cjs/helper.cjs | 1 + .../esm/helper.mjs | 1 + .../module-sync-condition-cjs-subpath/input.js | 2 ++ .../output.js | 10 ++++++++++ .../package.json | 12 ++++++++++++ .../crates/turbopack-tracing/tests/unit.rs | 6 ++++++ 19 files changed, 84 insertions(+), 11 deletions(-) create mode 100644 turbopack/crates/turbopack-tracing/tests/node-file-trace/test/unit/module-sync-condition-cjs-nested-symlink/input.js create mode 100644 turbopack/crates/turbopack-tracing/tests/node-file-trace/test/unit/module-sync-condition-cjs-nested-symlink/node_modules/.store/helpers@1.0.0/node_modules/@scope/helpers/cjs/helper.cjs create mode 100644 turbopack/crates/turbopack-tracing/tests/node-file-trace/test/unit/module-sync-condition-cjs-nested-symlink/node_modules/.store/helpers@1.0.0/node_modules/@scope/helpers/esm/helper.js create mode 100644 turbopack/crates/turbopack-tracing/tests/node-file-trace/test/unit/module-sync-condition-cjs-nested-symlink/node_modules/.store/helpers@1.0.0/node_modules/@scope/helpers/package.json create mode 120000 turbopack/crates/turbopack-tracing/tests/node-file-trace/test/unit/module-sync-condition-cjs-nested-symlink/node_modules/.store/node_modules/@scope/helpers create mode 120000 turbopack/crates/turbopack-tracing/tests/node-file-trace/test/unit/module-sync-condition-cjs-nested-symlink/node_modules/.store/pkg@1.0.0/node_modules/@scope/helpers create mode 100644 turbopack/crates/turbopack-tracing/tests/node-file-trace/test/unit/module-sync-condition-cjs-nested-symlink/node_modules/.store/pkg@1.0.0/node_modules/pkg/index.js create mode 100644 turbopack/crates/turbopack-tracing/tests/node-file-trace/test/unit/module-sync-condition-cjs-nested-symlink/node_modules/.store/pkg@1.0.0/node_modules/pkg/package.json create mode 120000 turbopack/crates/turbopack-tracing/tests/node-file-trace/test/unit/module-sync-condition-cjs-nested-symlink/node_modules/pkg create mode 100644 turbopack/crates/turbopack-tracing/tests/node-file-trace/test/unit/module-sync-condition-cjs-nested-symlink/output.js create mode 100644 turbopack/crates/turbopack-tracing/tests/node-file-trace/test/unit/module-sync-condition-cjs-subpath/cjs/helper.cjs create mode 100644 turbopack/crates/turbopack-tracing/tests/node-file-trace/test/unit/module-sync-condition-cjs-subpath/esm/helper.mjs create mode 100644 turbopack/crates/turbopack-tracing/tests/node-file-trace/test/unit/module-sync-condition-cjs-subpath/input.js create mode 100644 turbopack/crates/turbopack-tracing/tests/node-file-trace/test/unit/module-sync-condition-cjs-subpath/output.js create mode 100644 turbopack/crates/turbopack-tracing/tests/node-file-trace/test/unit/module-sync-condition-cjs-subpath/package.json diff --git a/test/production/next-server-nft/next-server-nft.test.ts b/test/production/next-server-nft/next-server-nft.test.ts index 09f16a69d05e..465ebffd2858 100644 --- a/test/production/next-server-nft/next-server-nft.test.ts +++ b/test/production/next-server-nft/next-server-nft.test.ts @@ -626,6 +626,7 @@ async function readNormalizedNFT(next, name) { "./.next/server/server-reference-manifest.js", "./.next/server/server-reference-manifest.json", "/node_modules/@swc/helpers/cjs/_interop_require_default.cjs", + "/node_modules/@swc/helpers/esm/_interop_require_default.js", "/node_modules/next/dist/build/adapter/setup-node-env.external.js", "/node_modules/next/dist/client/components/hooks-server-context.js", "/node_modules/next/dist/client/components/static-generation-bailout.js", diff --git a/turbopack/crates/turbopack-core/src/resolve/mod.rs b/turbopack/crates/turbopack-core/src/resolve/mod.rs index f2f963bc743c..5815202813af 100644 --- a/turbopack/crates/turbopack-core/src/resolve/mod.rs +++ b/turbopack/crates/turbopack-core/src/resolve/mod.rs @@ -974,7 +974,7 @@ impl ResolveResult { fn with_replaced_request_key( &self, old_request_key: RcStr, - request_key: RequestKey, + new_request_key: RcStr, ) -> Result> { let new_primary = self .primary @@ -983,11 +983,8 @@ impl ResolveResult { let remaining = k.request.as_ref()?.strip_prefix(&*old_request_key)?; Some(( RequestKey { - request: request_key - .request - .as_ref() - .map(|r| format!("{r}{remaining}").into()), - conditions: request_key.conditions.clone(), + request: Some(format!("{new_request_key}{remaining}").into()), + conditions: k.conditions.clone(), }, v.clone(), )) @@ -2665,12 +2662,11 @@ async fn apply_in_package( }; let refs = refs.clone(); - let request_key = RequestKey::new(request.clone()); if value.as_bool() == Some(false) { return Ok(Some(ResolveResultOrCell::Value( ResolveResult::primary_with_affecting_sources( - request_key, + RequestKey::new(request.clone()), ResolveResultItem::Ignore, refs, ), @@ -2689,7 +2685,7 @@ async fn apply_in_package( .with_fragment(fragment.clone()), options, ) - .with_replaced_request_key(value.into(), request_key); + .with_replaced_request_key(value.into(), request.clone()); if options_value.collect_affecting_sources && !refs.is_empty() { result = result.with_affecting_sources(refs.into_iter().map(|src| *src).collect()); } @@ -2831,7 +2827,7 @@ async fn resolve_module_request( fragment.clone(), options, ) - .with_replaced_request_key(rcstr!("."), RequestKey::new(name.clone())), + .with_replaced_request_key(rcstr!("."), name.clone()), ); } FindPackageItem::PackageFile { name, file } => { @@ -2848,7 +2844,7 @@ async fn resolve_module_request( ) .await? .into_cell() - .with_replaced_request_key(rcstr!("."), RequestKey::new(name.clone())); + .with_replaced_request_key(rcstr!("."), name.clone()); results.push(resolved_result) } } diff --git a/turbopack/crates/turbopack-tracing/tests/node-file-trace/.gitignore b/turbopack/crates/turbopack-tracing/tests/node-file-trace/.gitignore index d03ea1de4b4e..f49e57794867 100644 --- a/turbopack/crates/turbopack-tracing/tests/node-file-trace/.gitignore +++ b/turbopack/crates/turbopack-tracing/tests/node-file-trace/.gitignore @@ -4,3 +4,6 @@ integration/**/dist !integration/**/node_modules !integration/**/.pnpm + +# Some unit cases need a checked-in node_modules to model a package layout +!test/unit/**/node_modules diff --git a/turbopack/crates/turbopack-tracing/tests/node-file-trace/test/unit/module-sync-condition-cjs-nested-symlink/input.js b/turbopack/crates/turbopack-tracing/tests/node-file-trace/test/unit/module-sync-condition-cjs-nested-symlink/input.js new file mode 100644 index 000000000000..312449d68a60 --- /dev/null +++ b/turbopack/crates/turbopack-tracing/tests/node-file-trace/test/unit/module-sync-condition-cjs-nested-symlink/input.js @@ -0,0 +1,2 @@ +const { test } = require('pkg') +console.log(test) diff --git a/turbopack/crates/turbopack-tracing/tests/node-file-trace/test/unit/module-sync-condition-cjs-nested-symlink/node_modules/.store/helpers@1.0.0/node_modules/@scope/helpers/cjs/helper.cjs b/turbopack/crates/turbopack-tracing/tests/node-file-trace/test/unit/module-sync-condition-cjs-nested-symlink/node_modules/.store/helpers@1.0.0/node_modules/@scope/helpers/cjs/helper.cjs new file mode 100644 index 000000000000..ed92105da5ed --- /dev/null +++ b/turbopack/crates/turbopack-tracing/tests/node-file-trace/test/unit/module-sync-condition-cjs-nested-symlink/node_modules/.store/helpers@1.0.0/node_modules/@scope/helpers/cjs/helper.cjs @@ -0,0 +1 @@ +exports.test = 'fallback version' diff --git a/turbopack/crates/turbopack-tracing/tests/node-file-trace/test/unit/module-sync-condition-cjs-nested-symlink/node_modules/.store/helpers@1.0.0/node_modules/@scope/helpers/esm/helper.js b/turbopack/crates/turbopack-tracing/tests/node-file-trace/test/unit/module-sync-condition-cjs-nested-symlink/node_modules/.store/helpers@1.0.0/node_modules/@scope/helpers/esm/helper.js new file mode 100644 index 000000000000..e7287d8dffde --- /dev/null +++ b/turbopack/crates/turbopack-tracing/tests/node-file-trace/test/unit/module-sync-condition-cjs-nested-symlink/node_modules/.store/helpers@1.0.0/node_modules/@scope/helpers/esm/helper.js @@ -0,0 +1 @@ +export const test = 'module-sync version' diff --git a/turbopack/crates/turbopack-tracing/tests/node-file-trace/test/unit/module-sync-condition-cjs-nested-symlink/node_modules/.store/helpers@1.0.0/node_modules/@scope/helpers/package.json b/turbopack/crates/turbopack-tracing/tests/node-file-trace/test/unit/module-sync-condition-cjs-nested-symlink/node_modules/.store/helpers@1.0.0/node_modules/@scope/helpers/package.json new file mode 100644 index 000000000000..8f28f270ca0e --- /dev/null +++ b/turbopack/crates/turbopack-tracing/tests/node-file-trace/test/unit/module-sync-condition-cjs-nested-symlink/node_modules/.store/helpers@1.0.0/node_modules/@scope/helpers/package.json @@ -0,0 +1,13 @@ +{ + "name": "@scope/helpers", + "version": "1.0.0", + "type": "module", + "exports": { + "./_/helper": { + "module-sync": "./esm/helper.js", + "webpack": "./esm/helper.js", + "import": "./esm/helper.js", + "default": "./cjs/helper.cjs" + } + } +} diff --git a/turbopack/crates/turbopack-tracing/tests/node-file-trace/test/unit/module-sync-condition-cjs-nested-symlink/node_modules/.store/node_modules/@scope/helpers b/turbopack/crates/turbopack-tracing/tests/node-file-trace/test/unit/module-sync-condition-cjs-nested-symlink/node_modules/.store/node_modules/@scope/helpers new file mode 120000 index 000000000000..9bacdccd169b --- /dev/null +++ b/turbopack/crates/turbopack-tracing/tests/node-file-trace/test/unit/module-sync-condition-cjs-nested-symlink/node_modules/.store/node_modules/@scope/helpers @@ -0,0 +1 @@ +../../helpers@1.0.0/node_modules/@scope/helpers \ No newline at end of file diff --git a/turbopack/crates/turbopack-tracing/tests/node-file-trace/test/unit/module-sync-condition-cjs-nested-symlink/node_modules/.store/pkg@1.0.0/node_modules/@scope/helpers b/turbopack/crates/turbopack-tracing/tests/node-file-trace/test/unit/module-sync-condition-cjs-nested-symlink/node_modules/.store/pkg@1.0.0/node_modules/@scope/helpers new file mode 120000 index 000000000000..ad678514ecb9 --- /dev/null +++ b/turbopack/crates/turbopack-tracing/tests/node-file-trace/test/unit/module-sync-condition-cjs-nested-symlink/node_modules/.store/pkg@1.0.0/node_modules/@scope/helpers @@ -0,0 +1 @@ +../../../helpers@1.0.0/node_modules/@scope/helpers \ No newline at end of file diff --git a/turbopack/crates/turbopack-tracing/tests/node-file-trace/test/unit/module-sync-condition-cjs-nested-symlink/node_modules/.store/pkg@1.0.0/node_modules/pkg/index.js b/turbopack/crates/turbopack-tracing/tests/node-file-trace/test/unit/module-sync-condition-cjs-nested-symlink/node_modules/.store/pkg@1.0.0/node_modules/pkg/index.js new file mode 100644 index 000000000000..c88d924230f8 --- /dev/null +++ b/turbopack/crates/turbopack-tracing/tests/node-file-trace/test/unit/module-sync-condition-cjs-nested-symlink/node_modules/.store/pkg@1.0.0/node_modules/pkg/index.js @@ -0,0 +1,2 @@ +const { test } = require('@scope/helpers/_/helper') +module.exports = { test } diff --git a/turbopack/crates/turbopack-tracing/tests/node-file-trace/test/unit/module-sync-condition-cjs-nested-symlink/node_modules/.store/pkg@1.0.0/node_modules/pkg/package.json b/turbopack/crates/turbopack-tracing/tests/node-file-trace/test/unit/module-sync-condition-cjs-nested-symlink/node_modules/.store/pkg@1.0.0/node_modules/pkg/package.json new file mode 100644 index 000000000000..f2a45df05311 --- /dev/null +++ b/turbopack/crates/turbopack-tracing/tests/node-file-trace/test/unit/module-sync-condition-cjs-nested-symlink/node_modules/.store/pkg@1.0.0/node_modules/pkg/package.json @@ -0,0 +1,5 @@ +{ + "name": "pkg", + "version": "1.0.0", + "main": "index.js" +} diff --git a/turbopack/crates/turbopack-tracing/tests/node-file-trace/test/unit/module-sync-condition-cjs-nested-symlink/node_modules/pkg b/turbopack/crates/turbopack-tracing/tests/node-file-trace/test/unit/module-sync-condition-cjs-nested-symlink/node_modules/pkg new file mode 120000 index 000000000000..235fcabc6df3 --- /dev/null +++ b/turbopack/crates/turbopack-tracing/tests/node-file-trace/test/unit/module-sync-condition-cjs-nested-symlink/node_modules/pkg @@ -0,0 +1 @@ +.store/pkg@1.0.0/node_modules/pkg \ No newline at end of file diff --git a/turbopack/crates/turbopack-tracing/tests/node-file-trace/test/unit/module-sync-condition-cjs-nested-symlink/output.js b/turbopack/crates/turbopack-tracing/tests/node-file-trace/test/unit/module-sync-condition-cjs-nested-symlink/output.js new file mode 100644 index 000000000000..710fca82db55 --- /dev/null +++ b/turbopack/crates/turbopack-tracing/tests/node-file-trace/test/unit/module-sync-condition-cjs-nested-symlink/output.js @@ -0,0 +1,14 @@ +;[ + // The `@scope/helpers` package is reachable through two `node_modules` directories (as in a pnpm + // install, where the virtual store hoists every package into `node_modules/.pnpm/node_modules`). + // Resolving the same request in more than one directory must not drop the alternatives a single + // directory resolved to: both the `module-sync` target (picked by `require()` on Node >= 22.12) + // and the `default` target (picked by older Node versions) have to be traced. + 'package.json', + 'test/unit/module-sync-condition-cjs-nested-symlink/input.js', + 'test/unit/module-sync-condition-cjs-nested-symlink/node_modules/.store/helpers@1.0.0/node_modules/@scope/helpers/cjs/helper.cjs', + 'test/unit/module-sync-condition-cjs-nested-symlink/node_modules/.store/helpers@1.0.0/node_modules/@scope/helpers/esm/helper.js', + 'test/unit/module-sync-condition-cjs-nested-symlink/node_modules/.store/helpers@1.0.0/node_modules/@scope/helpers/package.json', + 'test/unit/module-sync-condition-cjs-nested-symlink/node_modules/.store/pkg@1.0.0/node_modules/pkg/index.js', + 'test/unit/module-sync-condition-cjs-nested-symlink/node_modules/.store/pkg@1.0.0/node_modules/pkg/package.json', +] diff --git a/turbopack/crates/turbopack-tracing/tests/node-file-trace/test/unit/module-sync-condition-cjs-subpath/cjs/helper.cjs b/turbopack/crates/turbopack-tracing/tests/node-file-trace/test/unit/module-sync-condition-cjs-subpath/cjs/helper.cjs new file mode 100644 index 000000000000..ed92105da5ed --- /dev/null +++ b/turbopack/crates/turbopack-tracing/tests/node-file-trace/test/unit/module-sync-condition-cjs-subpath/cjs/helper.cjs @@ -0,0 +1 @@ +exports.test = 'fallback version' diff --git a/turbopack/crates/turbopack-tracing/tests/node-file-trace/test/unit/module-sync-condition-cjs-subpath/esm/helper.mjs b/turbopack/crates/turbopack-tracing/tests/node-file-trace/test/unit/module-sync-condition-cjs-subpath/esm/helper.mjs new file mode 100644 index 000000000000..e7287d8dffde --- /dev/null +++ b/turbopack/crates/turbopack-tracing/tests/node-file-trace/test/unit/module-sync-condition-cjs-subpath/esm/helper.mjs @@ -0,0 +1 @@ +export const test = 'module-sync version' diff --git a/turbopack/crates/turbopack-tracing/tests/node-file-trace/test/unit/module-sync-condition-cjs-subpath/input.js b/turbopack/crates/turbopack-tracing/tests/node-file-trace/test/unit/module-sync-condition-cjs-subpath/input.js new file mode 100644 index 000000000000..8da0d06fb1b8 --- /dev/null +++ b/turbopack/crates/turbopack-tracing/tests/node-file-trace/test/unit/module-sync-condition-cjs-subpath/input.js @@ -0,0 +1,2 @@ +const { test } = require('test-pkg-sync-cjs-subpath/_/helper') +console.log(test) diff --git a/turbopack/crates/turbopack-tracing/tests/node-file-trace/test/unit/module-sync-condition-cjs-subpath/output.js b/turbopack/crates/turbopack-tracing/tests/node-file-trace/test/unit/module-sync-condition-cjs-subpath/output.js new file mode 100644 index 000000000000..fb6b423f52cc --- /dev/null +++ b/turbopack/crates/turbopack-tracing/tests/node-file-trace/test/unit/module-sync-condition-cjs-subpath/output.js @@ -0,0 +1,10 @@ +;[ + // A CommonJS `require()` of a subpath export whose conditions are ordered `module-sync`, + // `webpack`, `import`, `default` - the shape published packages use to hand an ESM file to + // `require()` on Node >= 22.12 and a CommonJS file to older Node versions. Both targets have to be + // traced, because which one the runtime picks depends on its Node version. + 'test/unit/module-sync-condition-cjs-subpath/cjs/helper.cjs', + 'test/unit/module-sync-condition-cjs-subpath/esm/helper.mjs', + 'test/unit/module-sync-condition-cjs-subpath/input.js', + 'test/unit/module-sync-condition-cjs-subpath/package.json', +] diff --git a/turbopack/crates/turbopack-tracing/tests/node-file-trace/test/unit/module-sync-condition-cjs-subpath/package.json b/turbopack/crates/turbopack-tracing/tests/node-file-trace/test/unit/module-sync-condition-cjs-subpath/package.json new file mode 100644 index 000000000000..1c3f44140b6b --- /dev/null +++ b/turbopack/crates/turbopack-tracing/tests/node-file-trace/test/unit/module-sync-condition-cjs-subpath/package.json @@ -0,0 +1,12 @@ +{ + "name": "test-pkg-sync-cjs-subpath", + "type": "commonjs", + "exports": { + "./_/helper": { + "module-sync": "./esm/helper.mjs", + "webpack": "./esm/helper.mjs", + "import": "./esm/helper.mjs", + "default": "./cjs/helper.cjs" + } + } +} diff --git a/turbopack/crates/turbopack-tracing/tests/unit.rs b/turbopack/crates/turbopack-tracing/tests/unit.rs index c130e2f17266..f25f037c4d0e 100644 --- a/turbopack/crates/turbopack-tracing/tests/unit.rs +++ b/turbopack/crates/turbopack-tracing/tests/unit.rs @@ -138,6 +138,12 @@ static ALLOC: turbo_tasks_malloc::TurboMalloc = turbo_tasks_malloc::TurboMalloc; #[case::module_sync_condition_cjs("module-sync-condition-cjs")] // Turbopack always includes the module-sync version, regardless of the current Node version // #[case::module_sync_condition_cjs_node20("module-sync-condition-cjs-node20")] +// A `require()` of a subpath export that hands the `module-sync` condition an ESM file and +// `default` a CommonJS one (not a case that any of the above cover): both have to be traced. +#[case::module_sync_condition_cjs_subpath("module-sync-condition-cjs-subpath")] +// The same, but with the package reachable through two `node_modules` directories, as in a pnpm +// install: merging the results of both must not drop either target. +#[case::module_sync_condition_cjs_nested_symlink("module-sync-condition-cjs-nested-symlink")] #[case::module_sync_condition_es("module-sync-condition-es")] #[case::module_sync_condition_es_nested("module-sync-condition-es-nested")] // Turbopack always includes the module-sync version, regardless of the current Node version From b5538511ed127799c82d4a30d2ed3a25fc537619 Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Sat, 15 Aug 2026 15:11:41 +0200 Subject: [PATCH 3/3] test: update React 18 redbox snapshot (#97415) --- test/development/pages-dir/client-navigation/rendering.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/development/pages-dir/client-navigation/rendering.test.ts b/test/development/pages-dir/client-navigation/rendering.test.ts index 476442ef63e5..fac7a6f849f3 100644 --- a/test/development/pages-dir/client-navigation/rendering.test.ts +++ b/test/development/pages-dir/client-navigation/rendering.test.ts @@ -124,7 +124,7 @@ describe('Client Navigation rendering', () => { test('getInitialProps circular structure', async () => { const browser = await next.browser('/circular-json-error') - if (isReact18 && isTurbopack) { + if (isReact18) { await expect(browser).toDisplayRedbox(` { "code": "E490",