next.js/packages/next/src/build/route-discovery.ts
route-discovery.ts547 lines14.5 KB
import { join } from 'path'
import { createValidFileMatcher } from '../server/lib/find-page-file'
import { recursiveReadDir } from '../lib/recursive-readdir'
import {
  APP_DIR_ALIAS,
  PAGES_DIR_ALIAS,
  ROOT_DIR_ALIAS,
} from '../lib/constants'
import { normalizePathSep } from '../shared/lib/page-path/normalize-path-sep'
import { normalizeAppPath } from '../shared/lib/router/utils/app-paths'
import { ensureLeadingSlash } from '../shared/lib/page-path/ensure-leading-slash'
import { PAGE_TYPES } from '../lib/page-types'
import {
  extractSlotsFromRoutes,
  combineSlots,
  type SlotInfo,
  type RouteInfo,
} from './file-classifier'
import {
  normalizeMetadataRoute,
  normalizeMetadataPageToRoute,
} from '../lib/metadata/get-metadata-route'
import { isMetadataRouteFile } from '../lib/metadata/is-metadata-route'
import { getPageStaticInfo } from './analysis/get-page-static-info'
import {
  UNDERSCORE_NOT_FOUND_ROUTE,
  UNDERSCORE_NOT_FOUND_ROUTE_ENTRY,
  UNDERSCORE_GLOBAL_ERROR_ROUTE,
  UNDERSCORE_GLOBAL_ERROR_ROUTE_ENTRY,
} from '../shared/lib/entry-constants'
import { isReservedPage } from './utils'
import type { PageExtensions } from './page-extensions-type'
import type { MappedPages } from './build-context'

const PRIVATE_PAGES_PREFIX_REGEX = /^private-next-pages\//
const PRIVATE_APP_PREFIX_REGEX = /^private-next-app-dir\//
const SKIP_ROUTES = new Set([
  UNDERSCORE_NOT_FOUND_ROUTE_ENTRY,
  UNDERSCORE_GLOBAL_ERROR_ROUTE_ENTRY,
])

function removeSuffix(value: string, suffix: string): string {
  return value.endsWith(suffix) ? value.slice(0, -suffix.length) : value
}

/** Normalize a route for the app router */
function normalizeAppRoute(pageName: string): string {
  return normalizeAppPath(normalizePathSep(pageName))
}

/** Normalize a layout route (strip /layout suffix) */
function normalizeLayoutRoute(pageName: string): string {
  return ensureLeadingSlash(
    removeSuffix(normalizeAppPath(normalizePathSep(pageName)), '/layout')
  )
}

/**
 * For a given page path removes the provided extensions.
 */
export function getPageFromPath(
  pagePath: string,
  pageExtensions: PageExtensions
) {
  let page = normalizePathSep(pagePath)
  // Try longer extensions first so compound extensions like 'page.js'
  // match before shorter ones like 'js'
  const sorted = [...pageExtensions].sort((a, b) => b.length - a.length)
  for (const extension of sorted) {
    const next = removeSuffix(page, `.${extension}`)
    if (next !== page) {
      page = next
      break
    }
  }

  page = removeSuffix(page, '/index')

  return page === '' ? '/' : page
}

/**
 * Collect app pages, layouts, and default files from the app directory
 */
export async function collectAppFiles(
  appDir: string,
  validFileMatcher: ReturnType<typeof createValidFileMatcher>
): Promise<{
  appPaths: string[]
  layoutPaths: string[]
  defaultPaths: string[]
}> {
  const allAppFiles = await recursiveReadDir(appDir, {
    pathnameFilter: (absolutePath) =>
      validFileMatcher.isAppRouterPage(absolutePath) ||
      validFileMatcher.isRootNotFound(absolutePath) ||
      validFileMatcher.isAppLayoutPage(absolutePath) ||
      validFileMatcher.isAppDefaultPage(absolutePath),
    ignorePartFilter: (part) => part.startsWith('_'),
  })

  const appPaths = allAppFiles.filter(
    (absolutePath) =>
      validFileMatcher.isAppRouterPage(absolutePath) ||
      validFileMatcher.isRootNotFound(absolutePath)
  )
  const layoutPaths = allAppFiles.filter((absolutePath) =>
    validFileMatcher.isAppLayoutPage(absolutePath)
  )
  const defaultPaths = allAppFiles.filter((absolutePath) =>
    validFileMatcher.isAppDefaultPage(absolutePath)
  )

  return { appPaths, layoutPaths, defaultPaths }
}

/**
 * Collect pages from the pages directory
 */
export async function collectPagesFiles(
  pagesDir: string,
  validFileMatcher: ReturnType<typeof createValidFileMatcher>
): Promise<string[]> {
  return await recursiveReadDir(pagesDir, {
    pathnameFilter: validFileMatcher.isPageFile,
  })
}

/**
 * Create a relative file path from a mapped page path
 */
export function createRelativeFilePath(
  baseDir: string,
  filePath: string,
  prefix: 'pages' | 'app',
  isSrcDir: boolean
): string {
  const privatePrefixRegex =
    prefix === 'pages' ? PRIVATE_PAGES_PREFIX_REGEX : PRIVATE_APP_PREFIX_REGEX
  const srcPrefix = isSrcDir ? 'src/' : ''
  return join(
    baseDir,
    filePath.replace(privatePrefixRegex, `${srcPrefix}${prefix}/`)
  )
}

/**
 * Process pages routes from mapped pages
 */
export function processPageRoutes(
  mappedPages: { [page: string]: string },
  baseDir: string,
  isSrcDir: boolean
): {
  pageRoutes: RouteInfo[]
  pageApiRoutes: RouteInfo[]
} {
  const pageRoutes: RouteInfo[] = []
  const pageApiRoutes: RouteInfo[] = []

  for (const [route, filePath] of Object.entries(mappedPages)) {
    const relativeFilePath = createRelativeFilePath(
      baseDir,
      filePath,
      'pages',
      isSrcDir
    )

    if (route.startsWith('/api/')) {
      pageApiRoutes.push({
        route: normalizePathSep(route),
        filePath: relativeFilePath,
      })
    } else {
      if (isReservedPage(route)) continue

      pageRoutes.push({
        route: normalizePathSep(route),
        filePath: relativeFilePath,
      })
    }
  }

  return { pageRoutes, pageApiRoutes }
}

/**
 * Process app routes from mapped app pages
 */
export function processAppRoutes(
  mappedAppPages: { [page: string]: string },
  validFileMatcher: ReturnType<typeof createValidFileMatcher>,
  baseDir: string,
  isSrcDir: boolean
): {
  appRoutes: RouteInfo[]
  appRouteHandlers: RouteInfo[]
} {
  const appRoutes: RouteInfo[] = []
  const appRouteHandlers: RouteInfo[] = []

  for (const [page, filePath] of Object.entries(mappedAppPages)) {
    if (
      page === UNDERSCORE_NOT_FOUND_ROUTE_ENTRY ||
      page === UNDERSCORE_GLOBAL_ERROR_ROUTE_ENTRY
    ) {
      continue
    }

    const relativeFilePath = createRelativeFilePath(
      baseDir,
      filePath,
      'app',
      isSrcDir
    )
    const route = normalizeAppRoute(page)

    if (validFileMatcher.isAppRouterRoute(filePath)) {
      appRouteHandlers.push({ route, filePath: relativeFilePath })
    } else {
      appRoutes.push({ route, filePath: relativeFilePath })
    }
  }

  return { appRoutes, appRouteHandlers }
}

/**
 * Process layout routes from mapped app layouts
 */
export function processLayoutRoutes(
  mappedAppLayouts: { [page: string]: string },
  baseDir: string,
  isSrcDir: boolean
): RouteInfo[] {
  return Object.entries(mappedAppLayouts).map(([route, filePath]) => ({
    route: normalizeLayoutRoute(route),
    filePath: createRelativeFilePath(baseDir, filePath, 'app', isSrcDir),
  }))
}

/**
 * Creates a mapping of route to page file path for a given list of page paths.
 */
export async function createPagesMapping({
  isDev,
  pageExtensions,
  pagePaths,
  pagesType,
  pagesDir,
  appDir,
  appDirOnly,
}: {
  isDev: boolean
  pageExtensions: PageExtensions
  pagePaths: string[]
  pagesType: PAGE_TYPES
  pagesDir: string | undefined
  appDir: string | undefined
  appDirOnly: boolean
}): Promise<MappedPages> {
  const isAppRoute = pagesType === 'app'

  const promises = pagePaths.map<Promise<[string, string] | undefined>>(
    async (pagePath) => {
      if (pagePath.endsWith('.d.ts') && pageExtensions.includes('ts')) {
        return
      }

      let pageKey = getPageFromPath(pagePath, pageExtensions)
      if (isAppRoute) {
        // Turbopack encodes '_' as '%5F' in app paths; normalize to underscores.
        pageKey = pageKey.replace(/%5F/g, '_')
        if (pageKey === UNDERSCORE_NOT_FOUND_ROUTE) {
          pageKey = UNDERSCORE_NOT_FOUND_ROUTE_ENTRY
        }
        if (pageKey === UNDERSCORE_GLOBAL_ERROR_ROUTE) {
          pageKey = UNDERSCORE_GLOBAL_ERROR_ROUTE_ENTRY
        }
      }

      const normalizedPath = normalizePathSep(
        join(
          pagesType === PAGE_TYPES.PAGES
            ? PAGES_DIR_ALIAS
            : pagesType === PAGE_TYPES.APP
              ? APP_DIR_ALIAS
              : ROOT_DIR_ALIAS,
          pagePath
        )
      )

      let route =
        pagesType === PAGE_TYPES.APP ? normalizeMetadataRoute(pageKey) : pageKey

      if (
        pagesType === PAGE_TYPES.APP &&
        isMetadataRouteFile(pagePath, pageExtensions, true)
      ) {
        const filePath = join(appDir!, pagePath)
        const staticInfo = await getPageStaticInfo({
          nextConfig: {},
          pageFilePath: filePath,
          isDev,
          page: pageKey,
          pageType: pagesType,
        })

        route = normalizeMetadataPageToRoute(
          route,
          !!(staticInfo.generateImageMetadata || staticInfo.generateSitemaps)
        )
      }

      return [route, normalizedPath]
    }
  )

  const pages: MappedPages = Object.fromEntries(
    (await Promise.all(promises)).filter((entry) => entry != null)
  )

  switch (pagesType) {
    case PAGE_TYPES.ROOT: {
      return pages
    }
    case PAGE_TYPES.APP: {
      const hasAppPages = Object.keys(pages).length > 0
      const hasAppGlobalError = !isDev && appDirOnly
      return {
        ...(hasAppPages && {
          [UNDERSCORE_NOT_FOUND_ROUTE_ENTRY]: require.resolve(
            'next/dist/client/components/builtin/global-not-found'
          ),
        }),
        ...(hasAppGlobalError && {
          [UNDERSCORE_GLOBAL_ERROR_ROUTE_ENTRY]: require.resolve(
            'next/dist/client/components/builtin/app-error'
          ),
        }),
        ...pages,
      }
    }
    case PAGE_TYPES.PAGES: {
      if (isDev) {
        delete pages['/_app']
        delete pages['/_error']
        delete pages['/_document']
      }

      const root = isDev && pagesDir ? PAGES_DIR_ALIAS : 'next/dist/pages'

      if (Object.keys(pages).length === 0 && !appDirOnly) {
        appDirOnly = true
      }

      return {
        ...((isDev || !appDirOnly) && {
          '/_app': `${root}/_app`,
          '/_error': `${root}/_error`,
          '/_document': `${root}/_document`,
          ...pages,
        }),
      }
    }
    default: {
      return {}
    }
  }
}

export interface RouteDiscoveryOptions {
  appDir?: string
  pagesDir?: string
  pageExtensions: string[]
  isDev: boolean
  baseDir: string
  /** Whether the app/pages directories are under a /src directory. */
  isSrcDir?: boolean
  /** Override app-dir-only mode (e.g. from --experimental-app-only CLI flag) */
  appDirOnly?: boolean
  validFileMatcher?: ReturnType<typeof createValidFileMatcher>
  debugBuildPaths?: { app: string[]; pages: string[] }
}

export interface RouteDiscoveryResult {
  appRoutes: RouteInfo[]
  appRouteHandlers: RouteInfo[]
  layoutRoutes: RouteInfo[]
  slots: SlotInfo[]
  pageRoutes: RouteInfo[]
  pageApiRoutes: RouteInfo[]
  mappedAppPages?: MappedPages
  mappedAppLayouts?: MappedPages
  mappedPages?: MappedPages
  /** Raw page file paths (post-filtering), useful for telemetry */
  pagesPaths: string[]
  /** Resolved app-dir-only state (may have been updated during discovery) */
  appDirOnly: boolean
}

/**
 * High-level API: Collect, map, and process all routes in one call
 */
export async function discoverRoutes(
  options: RouteDiscoveryOptions
): Promise<RouteDiscoveryResult> {
  const {
    appDir,
    pagesDir,
    pageExtensions,
    isDev,
    baseDir,
    isSrcDir,
    debugBuildPaths,
  } = options

  const validFileMatcher =
    options.validFileMatcher || createValidFileMatcher(pageExtensions, appDir)

  let appDirOnly = options.appDirOnly ?? (!!appDir && !pagesDir)

  // Helper to reduce createPagesMapping boilerplate
  const mapPaths = (pagePaths: string[], pagesType: PAGE_TYPES) =>
    createPagesMapping({
      pagePaths,
      isDev,
      pagesType,
      pageExtensions,
      pagesDir,
      appDir,
      appDirOnly,
    })

  // Helper to apply debugBuildPaths filtering
  const applyDebugFilter = (
    paths: string[],
    debugPaths: string[]
  ): string[] => {
    if (debugPaths.length > 0) {
      const debugPathsSet = new Set(debugPaths)
      return paths.filter((p) => debugPathsSet.has(p))
    }
    // Empty array means build none
    return []
  }

  let pageRoutes: RouteInfo[] = []
  let pageApiRoutes: RouteInfo[] = []
  let mappedPages: MappedPages | undefined
  let pagesPaths: string[] = []

  if (pagesDir && !appDirOnly) {
    if (process.env.NEXT_PRIVATE_PAGE_PATHS) {
      pagesPaths = JSON.parse(process.env.NEXT_PRIVATE_PAGE_PATHS)
    } else {
      pagesPaths = await collectPagesFiles(pagesDir, validFileMatcher)

      if (debugBuildPaths) {
        pagesPaths = applyDebugFilter(pagesPaths, debugBuildPaths.pages)
      }
    }

    mappedPages = await mapPaths(pagesPaths, PAGE_TYPES.PAGES)

    // Update appDirOnly if no user page routes were found, so the
    // subsequent app mapping can emit the global error entry.
    if (Object.keys(mappedPages).length === 0) {
      appDirOnly = true
    }

    ;({ pageRoutes, pageApiRoutes } = processPageRoutes(
      mappedPages,
      baseDir,
      !!isSrcDir
    ))
  }

  let appRoutes: RouteInfo[] = []
  let appRouteHandlers: RouteInfo[] = []
  let layoutRoutes: RouteInfo[] = []
  let slots: SlotInfo[] = []
  let mappedAppPages: MappedPages | undefined
  let mappedAppLayouts: MappedPages | undefined

  if (appDir) {
    let appPaths: string[]
    let layoutPaths: string[]
    let defaultPaths: string[]

    if (process.env.NEXT_PRIVATE_APP_PATHS) {
      // Used for testing — override collected app paths
      appPaths = JSON.parse(process.env.NEXT_PRIVATE_APP_PATHS)
      layoutPaths = []
      defaultPaths = []
    } else {
      const result = await collectAppFiles(appDir, validFileMatcher)
      appPaths = result.appPaths
      layoutPaths = result.layoutPaths
      defaultPaths = result.defaultPaths

      if (debugBuildPaths) {
        appPaths = applyDebugFilter(appPaths, debugBuildPaths.app)
      }
    }

    // Map all app file types in parallel
    let mappedDefaultFiles: MappedPages
    ;[mappedAppPages, mappedAppLayouts, mappedDefaultFiles] = await Promise.all(
      [
        mapPaths(appPaths, PAGE_TYPES.APP),
        mapPaths(layoutPaths, PAGE_TYPES.APP),
        mapPaths(defaultPaths, PAGE_TYPES.APP),
      ]
    )

    // Extract slots from pages and default files
    slots = combineSlots(
      extractSlotsFromRoutes(mappedAppPages, SKIP_ROUTES),
      extractSlotsFromRoutes(mappedDefaultFiles)
    )

    // Process routes
    ;({ appRoutes, appRouteHandlers } = processAppRoutes(
      mappedAppPages,
      validFileMatcher,
      baseDir,
      !!isSrcDir
    ))
    layoutRoutes = processLayoutRoutes(mappedAppLayouts, baseDir, !!isSrcDir)
  }

  return {
    appRoutes,
    appRouteHandlers,
    layoutRoutes,
    slots,
    pageRoutes,
    pageApiRoutes,
    mappedAppPages,
    mappedAppLayouts,
    mappedPages,
    pagesPaths,
    appDirOnly,
  }
}
Quest for Codev2.0.0
/
SIGN IN