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/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", 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/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/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, 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