import path from 'path'
import fs from 'fs/promises'
import { pathToFileURL } from 'url'
import * as Log from '../output/log'
import { isMiddlewareFilename } from '../utils'
import { RenderingMode } from '../rendering-mode'
import { interopDefault } from '../../lib/interop-default'
import type { RouteHas } from '../../lib/load-custom-routes'
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 { AdapterOutputType, type PHASE_TYPE } from '../../shared/lib/constants'
import { normalizePagePath } from '../../shared/lib/page-path/normalize-page-path'
import {
convertRedirects,
convertRewrites,
convertHeaders,
} from 'next/dist/compiled/@vercel/routing-utils'
import type {
MiddlewareManifest,
EdgeFunctionDefinition,
} from '../webpack/plugins/middleware-plugin'
import type {
RoutesManifest,
PrerenderManifest,
ManifestRewriteRoute,
FunctionsConfigManifest,
DynamicPrerenderManifestRoute,
ManifestHeaderRoute,
} from '..'
import {
CACHE_ONE_YEAR_SECONDS,
HTML_CONTENT_TYPE_HEADER,
JSON_CONTENT_TYPE_HEADER,
NEXT_QUERY_PARAM_PREFIX,
NEXT_RESUME_HEADER,
} from '../../lib/constants'
import { normalizeLocalePath } from '../../shared/lib/i18n/normalize-locale-path'
import { isStaticMetadataFile } from '../../lib/metadata/is-metadata-route'
import { addPathPrefix } from '../../shared/lib/router/utils/add-path-prefix'
import { getRedirectStatus, modifyRouteRegex } from '../../lib/redirect-status'
import { getNamedRouteRegex } from '../../shared/lib/router/utils/route-regex'
import { escapeStringRegexp } from '../../shared/lib/escape-regexp'
import { sortSortableRoutes } from '../../shared/lib/router/utils/sortable-routes'
import { defaultOverrides } from '../../server/require-hook'
import { generateRoutesManifest } from '../generate-routes-manifest'
import { Bundler } from '../../lib/bundler'
interface SharedRouteFields {
/**
* id is the unique identifier of the output
*/
id: string
/**
* filePath is the location on disk of the built entrypoint asset
*/
filePath: string
/**
* pathname is the URL pathname the asset should be served at
*/
pathname: string
/**
* sourcePage is the original source in the app or pages folder
*/
sourcePage: string
/**
* runtime is which runtime the entrypoint is built for
*/
runtime: 'nodejs' | 'edge'
/**
* assets are all necessary traced assets that could be
* loaded by the output to handle a request e.g. traced
* node_modules or necessary manifests for Next.js.
* The key is the relative path from the repo root and the value
* is the absolute path to the file
*/
assets: Record<string, string>
/**
* wasmAssets are bundled wasm files with mapping of name
* to filePath on disk
*/
wasmAssets?: Record<string, string>
/**
* edgeRuntime contains canonical entry metadata for invoking
* this output in an edge runtime.
*/
edgeRuntime?: {
/**
* modulePath is the canonical module path that registers this
* output in the edge runtime.
*/
modulePath: string
/**
* entryKey is the canonical key used for the global edge entry registry.
*/
entryKey: string
/**
* handlerExport is the export name to invoke on the edge entry.
*/
handlerExport: string
}
/**
* config related to the route
*/
config: {
/**
* maxDuration is a segment config to signal the max
* execution duration a route should be allowed before
* it's timed out
*/
maxDuration?: number
/**
* preferredRegion is a segment config to signal deployment
* region preferences to the provider being used
*/
preferredRegion?: string | string[]
/**
* env is the environment variables to expose, this is only
* populated for edge runtime currently
*/
env?: Record<string, string>
}
}
export interface AdapterOutput {
/**
* `PAGES` represents all the React pages that are under `pages/`.
*/
PAGES: SharedRouteFields & {
type: AdapterOutputType.PAGES
}
/**
* `PAGES_API` represents all the API routes under `pages/api/`.
*/
PAGES_API: SharedRouteFields & {
type: AdapterOutputType.PAGES_API
}
/**
* `APP_PAGE` represents all the React pages that are under `app/` with the
* filename of `page.{j,t}s{,x}`.
*/
APP_PAGE: SharedRouteFields & {
type: AdapterOutputType.APP_PAGE
}
/**
* `APP_ROUTE` represents all the API routes and metadata routes that are under `app/` with the
* filename of `route.{j,t}s{,x}`.
*/
APP_ROUTE: SharedRouteFields & {
type: AdapterOutputType.APP_ROUTE
}
/**
* `PRERENDER` represents an ISR enabled route that might
* have a seeded cache entry or fallback generated during build
*/
PRERENDER: {
id: string
pathname: string
type: AdapterOutputType.PRERENDER
/**
* For prerenders the parent output is the originating
* page that the prerender is created from
*/
parentOutputId: string
/**
* groupId is the identifier for a group of prerenders that should be
* revalidated together
*/
groupId: number
pprChain?: {
headers: Record<string, string>
}
/**
* parentFallbackMode signals whether additional routes can be generated
* e.g. fallback: false or 'blocking' in getStaticPaths in pages router
*/
parentFallbackMode?: DynamicPrerenderManifestRoute['fallback']
/**
* fallback is initial cache data generated during build for a prerender
*/
fallback?: {
/**
* path to the fallback file can be HTML/JSON/RSC,
*/
filePath: string | undefined
/**
* initialStatus is the status code that should be applied
* when serving the fallback
*/
initialStatus?: number
/**
* initialHeaders are the headers that should be sent when
* serving the fallback
*/
initialHeaders?: Record<string, string | string[]>
/**
* initial expiration is how long until the fallback entry
* is considered expired and no longer valid to serve
*/
initialExpiration?: number
/**
* initial revalidate is how long until the fallback is
* considered stale and should be revalidated
*/
initialRevalidate?: Revalidate
/**
* postponedState is the PPR state when it postponed and is used for resuming
*/
postponedState: string | undefined
}
/**
* config related to the route
*/
config: {
/**
* allowQuery is the allowed query values to be passed
* to an ISR function and what should be considered for the cacheKey
* e.g. for /blog/[slug], "slug" is the only allowQuery
*/
allowQuery?: string[]
/**
* allowHeader is the allowed headers to be passed to an
* ISR function to prevent accidentally poisoning the cache
* from leaking additional information that can impact the render
*/
allowHeader?: string[]
/**
* bypass for is a list of has conditions the cache
* should be bypassed and invoked directly e.g. action header
*/
bypassFor?: RouteHas[]
/**
* renderingMode signals PPR or not for a prerender
*/
renderingMode?: RenderingMode
/**
* partialFallback signals this prerender serves a partial fallback shell
* and should be upgraded to a full route in the background.
*/
partialFallback?: boolean
/**
* bypassToken is the generated token that signals a prerender cache
* should be bypassed
*/
bypassToken?: string
}
}
/**
* `STATIC_FILE` represents a static file (ie /_next/static) or a purely
* static HTML asset e.g. an automatically statically optimized page
* that does not use ISR
*/
STATIC_FILE: {
/**
* Unique identifier for this static file output
*/
id: string
/**
* Absolute filesystem path to the built file
*/
filePath: string
/**
* The routable URL pathname for this static file
*/
pathname: string
type: AdapterOutputType.STATIC_FILE
/**
* If this static file is immutable (because its filename contains a content hash), then this
* field contains the untruncated content hash.
*/
immutableHash: string | undefined
}
/**
* `MIDDLEWARE` represents the middleware output if present
*/
MIDDLEWARE: SharedRouteFields & {
type: AdapterOutputType.MIDDLEWARE
/**
* config related to the route
*/
config: SharedRouteFields['config'] & {
/**
* matchers are the configured matchers for middleware
*/
matchers?: Array<{
source: string
sourceRegex: string
has: RouteHas[] | undefined
missing: RouteHas[] | undefined
}>
}
}
}
export interface AdapterOutputs {
pages: Array<AdapterOutput['PAGES']>
middleware?: AdapterOutput['MIDDLEWARE']
appPages: Array<AdapterOutput['APP_PAGE']>
pagesApi: Array<AdapterOutput['PAGES_API']>
appRoutes: Array<AdapterOutput['APP_ROUTE']>
prerenders: Array<AdapterOutput['PRERENDER']>
staticFiles: Array<AdapterOutput['STATIC_FILE']>
}
type RewriteItem = {
source: string
sourceRegex: string
destination: string
has: RouteHas[] | undefined
missing: RouteHas[] | undefined
}
type DynamicRouteItem = {
source: string
sourceRegex: string
destination: string
has: RouteHas[] | undefined
missing: RouteHas[] | undefined
}
type Route = {
// regex as string can have named or un-named matches
source?: string
sourceRegex: string
// destination can have matches to replace in destination
// keyed by $1 for un-named and $name for named
destination?: string
headers?: Record<string, string>
has?: RouteHas[]
missing?: RouteHas[]
status?: number
priority?: boolean
}
export interface NextAdapter {
name: string
/**
* modifyConfig is called for any CLI command that loads the next.config
* to only apply for specific commands the "phase" should be used
* @param config
* @param ctx
* @returns
*/
modifyConfig?: (
config: NextConfigComplete,
ctx: {
phase: PHASE_TYPE
/**
* nextVersion is the current version of Next.js being used
*/
nextVersion: string
}
) => Promise<NextConfigComplete> | NextConfigComplete
onBuildComplete?: (ctx: {
routing: {
beforeMiddleware: Array<Route>
/**
* middlewareMatchers are the middleware matcher definitions emitted by
* Next.js for this build and can be used to decide whether middleware
* should be invoked for a given request.
*/
middlewareMatchers: Array<Route>
beforeFiles: Array<Route>
afterFiles: Array<Route>
dynamicRoutes: Array<Route>
onMatch: Array<Route>
fallback: Array<Route>
/**
* shouldNormalizeNextData indicates whether Next.js data URLs
* (e.g., /_next/data/BUILD_ID/page.json) should be normalized
* during route resolution. This is true when middleware is present
* and there are pages router items to resolve.
*/
shouldNormalizeNextData: boolean
rsc: RoutesManifest['rsc']
}
outputs: AdapterOutputs
/**
* projectDir is the absolute directory the Next.js application is in
*/
projectDir: string
/**
* repoRoot is the absolute path of the detected root of the repo
*/
repoRoot: string
/**
* distDir is the absolute path to the dist directory
*/
distDir: string
/**
* config is the loaded next.config (has modifyConfig applied)
*/
config: NextConfigComplete
/**
* nextVersion is the current version of Next.js being used
*/
nextVersion: string
/**
* buildId is the current unique ID for the build, this can be
* influenced by NextConfig.generateBuildId
*/
buildId: string
}) => Promise<void> | void
}
function normalizePathnames(
config: NextConfigComplete,
outputs: AdapterOutputs
) {
// normalize pathname field with basePath
if (config.basePath) {
for (const output of [
...outputs.pages,
...outputs.pagesApi,
...outputs.appPages,
...outputs.appRoutes,
...outputs.prerenders,
...outputs.staticFiles,
]) {
output.pathname =
addPathPrefix(output.pathname, config.basePath).replace(/\/$/, '') ||
'/'
}
}
}
export async function handleBuildComplete({
dir,
config,
appType,
buildId,
configOutDir,
distDir,
pageKeys,
bundler,
tracingRoot,
adapterPath,
appPageKeys,
staticPages,
nextVersion,
hasStatic404,
hasStatic500,
routesManifest,
serverPropsPages,
hasNodeMiddleware,
prerenderManifest,
middlewareManifest,
requiredServerFiles,
hasInstrumentationHook,
functionsConfigManifest,
}: {
dir: string
appType: 'app' | 'pages' | 'hybrid'
distDir: string
buildId: string
configOutDir: string
adapterPath: string
tracingRoot: string
nextVersion: string
hasStatic404: boolean
hasStatic500: boolean
bundler: Bundler
staticPages: Set<string>
hasNodeMiddleware: boolean
config: NextConfigComplete
pageKeys: readonly string[]
serverPropsPages: Set<string>
requiredServerFiles: string[]
routesManifest: RoutesManifest
hasInstrumentationHook: boolean
prerenderManifest: PrerenderManifest
middlewareManifest: MiddlewareManifest
appPageKeys?: readonly string[] | undefined
functionsConfigManifest: FunctionsConfigManifest
}) {
const adapterMod = interopDefault(
await import(pathToFileURL(require.resolve(adapterPath)).href)
) as NextAdapter
if (typeof adapterMod.onBuildComplete === 'function') {
const outputs: AdapterOutputs = {
pages: [],
pagesApi: [],
appPages: [],
appRoutes: [],
prerenders: [],
staticFiles: [],
}
if (config.output === 'export') {
// collect export assets and provide as static files
const exportFiles = await recursiveReadDir(configOutDir)
for (const file of exportFiles) {
let pathname = (
file.endsWith('.html') ? file.replace(/\.html$/, '') : file
).replace(/\\/g, '/')
pathname = pathname.startsWith('/') ? pathname : `/${pathname}`
outputs.staticFiles.push({
id: file,
pathname,
filePath: path.join(configOutDir, file),
type: AdapterOutputType.STATIC_FILE,
immutableHash: undefined,
} satisfies AdapterOutput['STATIC_FILE'])
}
} else {
const staticFiles = await recursiveReadDir(path.join(distDir, 'static'))
const clientHashes: Record<string, string> | undefined =
bundler === Bundler.Turbopack &&
config.experimental.supportsImmutableAssets
? JSON.parse(
await fs.readFile(
path.join(distDir, 'immutable-static-hashes.json'),
'utf8'
)
)
: undefined
for (const file of staticFiles) {
const pathname = path.posix.join('/_next/static', file)
const filePath = path.join(distDir, 'static', file)
const id = path.join('static', file)
outputs.staticFiles.push({
type: AdapterOutputType.STATIC_FILE,
id,
pathname,
filePath,
immutableHash: clientHashes?.[id],
})
}
const sharedNodeAssets: Record<string, string> = {}
const pagesSharedNodeAssets: Record<string, string> = {}
const appPagesSharedNodeAssets: Record<string, string> = {}
for (const file of requiredServerFiles) {
// add to shared node assets
const filePath = path.join(dir, file)
const fileOutputPath = path.relative(tracingRoot, filePath)
sharedNodeAssets[fileOutputPath] = filePath
}
// add "next/setup-node-env" stub so it can be required top-level
// TODO: should we make this always available without adapters
const setupNodeStubPath = path.join(
path.dirname(require.resolve('next/package.json')),
'setup-node-env.js'
)
sharedNodeAssets[path.relative(tracingRoot, setupNodeStubPath)] =
require.resolve('next/dist/build/adapter/setup-node-env.external')
const moduleTypes = ['app-page', 'pages'] as const
for (const type of moduleTypes) {
const currentDependencies: string[] = []
const modulePath = require.resolve(
`next/dist/server/route-modules/${type}/module.compiled`
)
currentDependencies.push(modulePath)
const contextDir = path.join(
path.dirname(modulePath),
'vendored',
'contexts'
)
for (const item of await fs.readdir(contextDir)) {
if (item.match(/\.(mjs|cjs|js)$/)) {
currentDependencies.push(path.join(contextDir, item))
}
}
for (const dependencyPath of currentDependencies) {
const rootRelativeFilePath = path.relative(
tracingRoot,
dependencyPath
)
if (type === 'pages') {
pagesSharedNodeAssets[rootRelativeFilePath] = path.join(
tracingRoot,
rootRelativeFilePath
)
} else {
appPagesSharedNodeAssets[rootRelativeFilePath] = path.join(
tracingRoot,
rootRelativeFilePath
)
}
}
}
if (bundler !== Bundler.Turbopack) {
const { nodeFileTrace } =
require('next/dist/compiled/@vercel/nft') as typeof import('next/dist/compiled/@vercel/nft')
const { makeIgnoreFn } =
require('../collect-build-traces') as typeof import('../collect-build-traces')
const sharedTraceIgnores = [
'**/next/dist/compiled/next-server/**/*.dev.js',
'**/next/dist/compiled/webpack/*',
'**/node_modules/webpack5/**/*',
'**/next/dist/server/lib/route-resolver*',
'next/dist/compiled/semver/semver/**/*.js',
'**/node_modules/react{,-dom,-dom-server-turbopack}/**/*.development.js',
'**/*.d.ts',
'**/*.map',
'**/next/dist/pages/**/*',
'**/node_modules/sharp/**/*',
'**/@img/sharp-libvips*/**/*',
'**/next/dist/compiled/edge-runtime/**/*',
'**/next/dist/server/web/sandbox/**/*',
'**/next/dist/server/post-process.js',
]
const sharedIgnoreFn = makeIgnoreFn(tracingRoot, sharedTraceIgnores)
// These are modules that are necessary for bootstrapping node env
const necessaryNodeDependencies = [
require.resolve('next/dist/server/node-environment'),
require.resolve('next/dist/server/require-hook'),
require.resolve('next/dist/server/node-polyfill-crypto'),
...Object.values(defaultOverrides).filter((item) =>
path.extname(item)
),
]
const { fileList, esmFileList } = await nodeFileTrace(
necessaryNodeDependencies,
{
base: tracingRoot,
ignore: sharedIgnoreFn,
}
)
esmFileList.forEach((item) => fileList.add(item))
for (const rootRelativeFilePath of fileList) {
sharedNodeAssets[rootRelativeFilePath] = path.join(
tracingRoot,
rootRelativeFilePath
)
}
}
if (hasInstrumentationHook) {
const assets = await handleTraceFiles(
path.join(distDir, 'server', 'instrumentation.js.nft.json'),
'neutral'
)
const fileOutputPath = path.relative(
tracingRoot,
path.join(distDir, 'server', 'instrumentation.js')
)
sharedNodeAssets[fileOutputPath] = path.join(
distDir,
'server',
'instrumentation.js'
)
Object.assign(sharedNodeAssets, assets)
}
async function handleTraceFiles(
traceFilePath: string,
type: 'pages' | 'app' | 'neutral'
): Promise<Record<string, string>> {
const assets: Record<string, string> = Object.assign(
{},
sharedNodeAssets,
type === 'pages' ? pagesSharedNodeAssets : {},
type === 'app' ? appPagesSharedNodeAssets : {}
)
const traceData = JSON.parse(
await fs.readFile(traceFilePath, 'utf8')
) as {
files: string[]
}
const traceFileDir = path.dirname(traceFilePath)
for (const relativeFile of traceData.files) {
const tracedFilePath = path.join(traceFileDir, relativeFile)
const fileOutputPath = path.relative(tracingRoot, tracedFilePath)
assets[fileOutputPath] = tracedFilePath
}
return assets
}
async function handleEdgeFunction(
page: EdgeFunctionDefinition,
isMiddleware: boolean = false
) {
let type: AdapterOutputType = AdapterOutputType.PAGES
const isAppPrefix = page.name.startsWith('app/')
const isAppPage = isAppPrefix && page.name.endsWith('/page')
const isAppRoute = isAppPrefix && page.name.endsWith('/route')
let currentOutputs: Array<
| AdapterOutput['PAGES']
| AdapterOutput['PAGES_API']
| AdapterOutput['APP_PAGE']
| AdapterOutput['APP_ROUTE']
> = outputs.pages
if (isMiddleware) {
type = AdapterOutputType.MIDDLEWARE
} else if (isAppPage) {
currentOutputs = outputs.appPages
type = AdapterOutputType.APP_PAGE
} else if (isAppRoute) {
currentOutputs = outputs.appRoutes
type = AdapterOutputType.APP_ROUTE
} else if (page.page.startsWith('/api')) {
currentOutputs = outputs.pagesApi
type = AdapterOutputType.PAGES_API
}
const route = page.page.replace(/^(app|pages)\//, '')
const pathname = isAppPrefix
? normalizeAppPath(route)
: route === '/index'
? '/'
: route
const edgeEntrypointRelativePath = page.entrypoint
const edgeEntrypointPath = path.join(
distDir,
edgeEntrypointRelativePath
)
const output: Omit<AdapterOutput[typeof type], 'type'> & {
type: any
} = {
type,
id: page.name,
runtime: 'edge',
sourcePage: route,
pathname,
filePath: edgeEntrypointPath,
edgeRuntime: {
modulePath: edgeEntrypointPath,
entryKey: `middleware_${page.name}`,
handlerExport: 'handler',
},
assets: {},
wasmAssets: {},
config: {
env: page.env,
},
}
function handleFile(file: string) {
const originalPath = path.join(distDir, file)
const fileOutputPath = path.relative(
config.distDir,
path.join(path.relative(tracingRoot, distDir), file)
)
if (!output.assets) {
output.assets = {}
}
output.assets[fileOutputPath] = originalPath
}
for (const file of page.files) {
handleFile(file)
}
for (const item of [...(page.assets || [])]) {
if (!output.assets) {
output.assets = {}
}
output.assets[item.name] = path.join(distDir, item.filePath)
}
for (const item of page.wasm || []) {
if (!output.wasmAssets) {
output.wasmAssets = {}
}
output.wasmAssets[item.name] = path.join(distDir, item.filePath)
}
if (type === AdapterOutputType.MIDDLEWARE) {
;(output as AdapterOutput['MIDDLEWARE']).config.matchers =
page.matchers.map((item) => {
return {
source: item.originalSource,
sourceRegex: item.regexp,
has: item.has,
missing: [
...(item.missing || []),
// always skip middleware for on-demand revalidate
{
type: 'header',
key: 'x-prerender-revalidate',
value: prerenderManifest.preview.previewModeId,
},
],
}
})
output.pathname = '/_middleware'
output.id = page.name
outputs.middleware = output
} else {
currentOutputs.push(output)
}
// need to add matching .rsc output
if (isAppPage) {
const rscPathname = normalizePagePath(output.pathname) + '.rsc'
outputs.appPages.push({
...output,
pathname: rscPathname,
id: page.name + '.rsc',
})
} else if (
type !== AdapterOutputType.MIDDLEWARE &&
serverPropsPages.has(pathname)
) {
const nextDataPath = path.posix.join(
'/_next/data/',
buildId,
normalizePagePath(pathname) + '.json'
)
outputs.pages.push({
...output,
pathname: nextDataPath,
})
}
}
const edgeFunctionHandlers: Promise<any>[] = []
for (const middleware of Object.values(middlewareManifest.middleware)) {
if (isMiddlewareFilename(middleware.name)) {
edgeFunctionHandlers.push(handleEdgeFunction(middleware, true))
}
}
for (const page of Object.values(middlewareManifest.functions)) {
edgeFunctionHandlers.push(handleEdgeFunction(page))
}
const pagesDistDir = path.join(distDir, 'server', 'pages')
const pageOutputMap: Record<
string,
AdapterOutput['PAGES'] | AdapterOutput['PAGES_API']
> = {}
const rscFallbackPath = path.join(distDir, 'server', 'rsc-fallback.json')
if (appPageKeys && appPageKeys.length > 0 && pageKeys.length > 0) {
await fs.writeFile(rscFallbackPath, '{}')
}
for (const page of pageKeys) {
if (page === '/_app' || page === '/_document') {
continue
}
if (middlewareManifest.functions.hasOwnProperty(page)) {
continue
}
const route = normalizePagePath(page)
const pageFile = path.join(pagesDistDir, `${route}.js`)
// if it's an auto static optimized page it's just
// a static file
if (staticPages.has(page)) {
if (config.i18n) {
for (const locale of config.i18n.locales || []) {
const localePage =
page === '/' ? `/${locale}` : addPathPrefix(page, `/${locale}`)
const localeOutput = {
id: localePage,
pathname: localePage,
type: AdapterOutputType.STATIC_FILE,
filePath: path.join(
pagesDistDir,
`${normalizePagePath(localePage)}.html`
),
immutableHash: undefined,
} satisfies AdapterOutput['STATIC_FILE']
outputs.staticFiles.push(localeOutput)
if (appPageKeys && appPageKeys.length > 0) {
outputs.staticFiles.push({
id: `${localePage}.rsc`,
pathname: `${localePage}.rsc`,
type: AdapterOutputType.STATIC_FILE,
filePath: rscFallbackPath,
immutableHash: undefined,
})
}
}
} else {
const staticOutput = {
id: page,
pathname: route,
type: AdapterOutputType.STATIC_FILE,
filePath: pageFile.replace(/\.js$/, '.html'),
immutableHash: undefined,
} satisfies AdapterOutput['STATIC_FILE']
outputs.staticFiles.push(staticOutput)
if (appPageKeys && appPageKeys.length > 0) {
outputs.staticFiles.push({
id: `${page}.rsc`,
pathname: `${route}.rsc`,
type: AdapterOutputType.STATIC_FILE,
filePath: rscFallbackPath,
immutableHash: undefined,
})
}
}
// if was a static file output don't create page output as well
continue
}
const pageTraceFile = `${pageFile}.nft.json`
const assets = await handleTraceFiles(pageTraceFile, 'pages').catch(
(err) => {
if (err.code !== 'ENOENT' || (page !== '/404' && page !== '/500')) {
Log.warn(`Failed to locate traced assets for ${pageFile}`, err)
}
return {} as Record<string, string>
}
)
const functionConfig = functionsConfigManifest.functions[route] || {}
let sourcePage = route.replace(/^\//, '')
sourcePage = sourcePage === 'api' ? 'api/index' : sourcePage
const output: AdapterOutput['PAGES'] | AdapterOutput['PAGES_API'] = {
id: route,
type: page.startsWith('/api')
? AdapterOutputType.PAGES_API
: AdapterOutputType.PAGES,
filePath: pageTraceFile.replace(/\.nft\.json$/, ''),
pathname: route,
sourcePage,
assets,
runtime: 'nodejs',
config: {
maxDuration: functionConfig.maxDuration,
preferredRegion: functionConfig.regions,
},
}
pageOutputMap[page] = output
if (output.type === AdapterOutputType.PAGES) {
outputs.pages.push(output)
// if page is get server side props we need to create
// the _next/data output as well
if (serverPropsPages.has(page)) {
const dataPathname = path.posix.join(
'/_next/data',
buildId,
normalizePagePath(page) + '.json'
)
outputs.pages.push({
...output,
pathname: dataPathname,
id: dataPathname,
})
if (appPageKeys && appPageKeys.length > 0) {
const rscPage = `${page === '/' ? '/index' : page}.rsc`
outputs.staticFiles.push({
id: rscPage,
pathname: rscPage,
type: AdapterOutputType.STATIC_FILE,
filePath: rscFallbackPath,
immutableHash: undefined,
})
}
}
for (const locale of config.i18n?.locales || []) {
const localePage =
page === '/' ? `/${locale}` : addPathPrefix(page, `/${locale}`)
outputs.pages.push({
...output,
id: localePage,
pathname: localePage,
})
if (serverPropsPages.has(page)) {
const dataPathname = path.posix.join(
'/_next/data',
buildId,
localePage + '.json'
)
outputs.pages.push({
...output,
pathname: dataPathname,
id: dataPathname,
})
if (appPageKeys && appPageKeys.length > 0) {
outputs.staticFiles.push({
id: `${localePage}.rsc`,
pathname: `${localePage}.rsc`,
type: AdapterOutputType.STATIC_FILE,
filePath: rscFallbackPath,
immutableHash: undefined,
})
}
}
}
} else {
outputs.pagesApi.push(output)
}
}
if (hasNodeMiddleware) {
const middlewareFile = path.join(distDir, 'server', 'middleware.js')
const middlewareTrace = `${middlewareFile}.nft.json`
const assets = await handleTraceFiles(middlewareTrace, 'neutral')
const functionConfig =
functionsConfigManifest.functions['/_middleware'] || {}
outputs.middleware = {
pathname: '/_middleware',
id: '/_middleware',
sourcePage: 'middleware',
assets,
type: AdapterOutputType.MIDDLEWARE,
runtime: 'nodejs',
filePath: middlewareFile,
config: {
matchers:
functionConfig.matchers?.map((item) => {
return {
source: item.originalSource,
sourceRegex: item.regexp,
has: item.has,
missing: [
...(item.missing || []),
// always skip middleware for on-demand revalidate
{
type: 'header',
key: 'x-prerender-revalidate',
value: prerenderManifest.preview.previewModeId,
},
],
}
}) || [],
},
} satisfies AdapterOutput['MIDDLEWARE']
}
const appOutputMap: Record<
string,
AdapterOutput['APP_PAGE'] | AdapterOutput['APP_ROUTE']
> = {}
const appDistDir = path.join(distDir, 'server', 'app')
if (appPageKeys) {
for (const page of appPageKeys) {
if (middlewareManifest.functions.hasOwnProperty(page)) {
continue
}
const normalizedPage = normalizeAppPath(page)
// Skip static metadata routes only when they are prerendered.
// Dynamic metadata routes (e.g. robots/sitemap using connection())
// should remain app routes in adapter outputs.
const isStaticMetadataRoute = isStaticMetadataFile(normalizedPage)
const isPrerenderedMetadataRoute =
prerenderManifest.routes[normalizedPage] ||
prerenderManifest.dynamicRoutes[normalizedPage] ||
config.i18n?.locales?.some((locale) => {
const localePathname = path.posix.join(
'/',
locale,
normalizedPage.slice(1)
)
return (
prerenderManifest.routes[localePathname] ||
prerenderManifest.dynamicRoutes[localePathname]
)
})
if (isStaticMetadataRoute && isPrerenderedMetadataRoute) {
continue
}
const pageFile = path.join(appDistDir, `${page}.js`)
const pageTraceFile = `${pageFile}.nft.json`
const assets = await handleTraceFiles(pageTraceFile, 'app').catch(
(err) => {
Log.warn(`Failed to copy traced files for ${pageFile}`, err)
return {} as Record<string, string>
}
)
// If this is a parallel route we just need to merge
// the assets as they share the same pathname
const existingOutput = appOutputMap[normalizedPage]
if (existingOutput) {
Object.assign(existingOutput.assets, assets)
existingOutput.assets[path.relative(tracingRoot, pageFile)] =
pageFile
continue
}
const functionConfig =
functionsConfigManifest.functions[normalizedPage] || {}
const output: AdapterOutput['APP_PAGE'] | AdapterOutput['APP_ROUTE'] =
{
pathname: normalizedPage,
id: normalizedPage,
sourcePage: page,
assets,
type: page.endsWith('/route')
? AdapterOutputType.APP_ROUTE
: AdapterOutputType.APP_PAGE,
runtime: 'nodejs',
filePath: pageFile,
config: {
maxDuration: functionConfig.maxDuration,
preferredRegion: functionConfig.regions,
},
}
appOutputMap[normalizedPage] = output
if (output.type === AdapterOutputType.APP_PAGE) {
outputs.appPages.push({
...output,
pathname: normalizePagePath(output.pathname) + '.rsc',
id: normalizePagePath(output.pathname) + '.rsc',
})
outputs.appPages.push(output)
} else {
outputs.appRoutes.push(output)
outputs.appRoutes.push({
...output,
pathname: normalizePagePath(output.pathname) + '.rsc',
id: normalizePagePath(output.pathname) + '.rsc',
})
}
}
}
const getParentOutput = (
srcRoute: string,
childRoute: string,
allowMissing?: boolean
) => {
const normalizedSrcRoute = normalizeLocalePath(
srcRoute,
config.i18n?.locales || []
).pathname
const parentOutput =
pageOutputMap[normalizedSrcRoute] || appOutputMap[normalizedSrcRoute]
if (!parentOutput && !allowMissing) {
console.error({
appOutputs: Object.keys(appOutputMap),
pageOutputs: Object.keys(pageOutputMap),
})
throw new Error(
`Invariant: failed to find source route ${srcRoute} for prerender ${childRoute}`
)
}
return parentOutput
}
const {
prefetchSegmentDirSuffix,
prefetchSegmentSuffix,
varyHeader,
didPostponeHeader,
contentTypeHeader: rscContentTypeHeader,
} = routesManifest.rsc
const handleAppMeta = async (
route: string,
initialOutput: AdapterOutput['PRERENDER'],
meta: AppRouteMeta,
ctx: {
htmlAllowQuery?: string[]
dataAllowQuery?: string[]
}
) => {
if (meta.postponed && initialOutput.fallback) {
initialOutput.fallback.postponedState = meta.postponed
}
if (meta?.segmentPaths) {
const normalizedRoute = normalizePagePath(route)
const segmentsDir = path.join(
appDistDir,
`${normalizedRoute}${prefetchSegmentDirSuffix}`
)
// If client param parsing is enabled, we follow the same logic as
// the HTML allowQuery as it's already going to vary based on if
// there's a static shell generated or if there's fallback root
// params. If there are fallback root params, and we can serve a
// fallback, then we should follow the same logic for the segment
// prerenders.
//
// If client param parsing is not enabled, we have to use the
// allowQuery because the segment payloads will contain dynamic
// segment values.
const segmentAllowQuery = routesManifest.rsc.clientParamParsing
? ctx.htmlAllowQuery
: ctx.dataAllowQuery
for (const segmentPath of meta.segmentPaths) {
const outputSegmentPath =
path.join(
normalizedRoute + prefetchSegmentDirSuffix,
segmentPath
) + prefetchSegmentSuffix
// Only use the fallback value when the allowQuery is defined and
// either: (1) it is empty, meaning segments do not vary by params,
// or (2) client param parsing is enabled, meaning the segment
// payloads are safe to reuse across params.
const shouldAttachSegmentFallback =
segmentAllowQuery &&
(segmentAllowQuery.length === 0 ||
routesManifest.rsc.clientParamParsing)
const fallbackPathname = shouldAttachSegmentFallback
? path.join(segmentsDir, segmentPath + prefetchSegmentSuffix)
: undefined
outputs.prerenders.push({
id: outputSegmentPath,
pathname: outputSegmentPath,
type: AdapterOutputType.PRERENDER,
parentOutputId: initialOutput.parentOutputId,
groupId: initialOutput.groupId,
config: {
...initialOutput.config,
bypassFor: undefined,
partialFallback: initialOutput.config.partialFallback,
},
fallback: {
filePath: fallbackPathname,
postponedState: undefined,
initialExpiration: initialOutput.fallback?.initialExpiration,
initialRevalidate: initialOutput.fallback?.initialRevalidate,
initialHeaders: {
...meta.headers,
...initialOutput.fallback?.initialHeaders,
vary: varyHeader,
'content-type': rscContentTypeHeader,
[didPostponeHeader]: '2',
},
},
} satisfies AdapterOutput['PRERENDER'])
}
}
}
let prerenderGroupId = 1
type AppRouteMeta = {
segmentPaths?: string[]
postponed?: string
headers?: Record<string, string>
status?: number
}
const getAppRouteMeta = async (
route: string,
isAppPage: boolean
): Promise<AppRouteMeta> => {
const basename = route.endsWith('/') ? `${route}index` : route
const meta: AppRouteMeta = isAppPage
? JSON.parse(
await fs
.readFile(path.join(appDistDir, `${basename}.meta`), 'utf8')
.catch(() => '{}')
)
: {}
if (meta.headers) {
// normalize these for consistency
for (const key of Object.keys(meta.headers)) {
const keyLower = key.toLowerCase()
let value = meta.headers[key]
// normalize values to strings (e.g. set-cookie can be an array)
if (Array.isArray(value)) {
value = value.join(', ')
} else if (typeof value !== 'string') {
value = String(value)
}
if (keyLower !== key) {
delete meta.headers[key]
}
meta.headers[keyLower] = value
}
}
return meta
}
const filePathCache = new Map<string, Promise<boolean>>()
const cachedFilePathCheck = async (filePath: string) => {
if (filePathCache.has(filePath)) {
return filePathCache.get(filePath)
}
const newCheck = fs
.access(filePath)
.then(() => true)
.catch(() => false)
filePathCache.set(filePath, newCheck)
return newCheck
}
for (const route in prerenderManifest.routes) {
const {
initialExpireSeconds: initialExpiration,
initialRevalidateSeconds: initialRevalidate,
initialHeaders,
initialStatus,
dataRoute,
prefetchDataRoute,
renderingMode,
allowHeader,
experimentalBypassFor,
} = prerenderManifest.routes[route]
const srcRoute = prerenderManifest.routes[route].srcRoute || route
const srcRouteInfo = prerenderManifest.dynamicRoutes[srcRoute]
const isAppPage =
Boolean(appOutputMap[srcRoute]) || srcRoute === '/_not-found'
// if we already have 404.html favor that instead of
// _not-found prerender
if (srcRoute === '/_not-found' && hasStatic404) {
continue
}
const isNotFoundTrue = prerenderManifest.notFoundRoutes.includes(route)
let allowQuery: string[] | undefined
const routeKeys = routesManifest.dynamicRoutes.find(
(item) => item.page === srcRoute
)?.routeKeys
if (!isDynamicRoute(route)) {
// for non-dynamic routes we use an empty array since
// no query values bust the cache for non-dynamic prerenders
// prerendered paths also do not pass allowQuery as they match
// during handle: 'filesystem' so should not cache differently
// by query values
allowQuery = []
} else if (routeKeys) {
// if we have routeKeys in the routes-manifest we use those
// for allowQuery for dynamic routes
allowQuery = Object.values(routeKeys)
}
let filePath = path.join(
isAppPage ? appDistDir : pagesDistDir,
`${normalizePagePath(route)}.${isAppPage && !dataRoute ? 'body' : 'html'}`
)
// Check if this is a static metadata route (e.g., /favicon.ico, /icon.png, /opengraph-image.png)
// These should be output as static files, not prerenders.
if (isStaticMetadataFile(route)) {
// For static metadata from app router, check if the .body file exists
const staticMetadataFilePath = path.join(
appDistDir,
`${normalizePagePath(route)}.body`
)
if (await cachedFilePathCheck(staticMetadataFilePath)) {
outputs.staticFiles.push({
id: route,
pathname: route,
type: AdapterOutputType.STATIC_FILE,
filePath: staticMetadataFilePath,
immutableHash: undefined,
})
continue
}
}
// we use the static 404 for notFound: true if available
// if not we do a blocking invoke on first request
if (isNotFoundTrue && hasStatic404) {
const locale =
config.i18n &&
normalizeLocalePath(route, config.i18n?.locales).detectedLocale
for (const currentFilePath of [
path.join(pagesDistDir, locale || '', '404.html'),
path.join(pagesDistDir, '404.html'),
]) {
if (await cachedFilePathCheck(currentFilePath)) {
filePath = currentFilePath
break
}
}
}
const meta = await getAppRouteMeta(route, isAppPage)
let htmlAllowQuery = allowQuery
let dataAllowQuery = allowQuery
const dataInitialHeaders: Record<string, string> = {}
// We additionally vary based on if there's a postponed prerender
// because if there isn't, then that means that we generated an
// empty shell, and producing an empty RSC shell would be a waste.
// If there is a postponed prerender, then the RSC shell would be
// non-empty, and it would be valuable to also generate an empty
// RSC shell.
if (meta.postponed) {
htmlAllowQuery = []
if (routesManifest.rsc.dynamicRSCPrerender) {
// If client param parsing is enabled, we follow the same logic as the
// HTML allowQuery as it's already going to vary based on if there's a
// static shell generated or if there's fallback root params. If there
// are fallback root params, and we can serve a fallback, then we
// should follow the same logic for the dynamic RSC routes.
//
// If client param parsing is not enabled, we have to use the
// allowQuery because the RSC payloads will contain dynamic segment
// values.
if (routesManifest.rsc.clientParamParsing) {
dataAllowQuery = htmlAllowQuery
}
}
}
if (renderingMode === RenderingMode.PARTIALLY_STATIC) {
// Dynamic RSC requests cannot be cached, so we explicity set it
// here to ensure that the response is not cached by the browser.
dataInitialHeaders['cache-control'] =
'private, no-store, no-cache, max-age=0, must-revalidate'
}
const initialOutput: AdapterOutput['PRERENDER'] = {
id: route,
type: AdapterOutputType.PRERENDER,
pathname: route,
parentOutputId:
srcRoute === '/_not-found'
? srcRoute
: getParentOutput(srcRoute, route).id,
groupId: prerenderGroupId,
pprChain:
isAppPage && renderingMode === RenderingMode.PARTIALLY_STATIC
? {
headers: {
[NEXT_RESUME_HEADER]: '1',
},
}
: undefined,
parentFallbackMode: srcRouteInfo?.fallback,
fallback:
!isNotFoundTrue || (isNotFoundTrue && hasStatic404)
? {
filePath,
postponedState: undefined,
initialStatus:
initialStatus ??
meta.status ??
(isNotFoundTrue ? 404 : undefined),
initialHeaders: {
...initialHeaders,
vary: varyHeader,
'content-type': HTML_CONTENT_TYPE_HEADER,
...meta.headers,
},
initialExpiration,
initialRevalidate:
typeof initialRevalidate === 'undefined'
? 1
: initialRevalidate,
}
: undefined,
config: {
allowQuery,
allowHeader,
renderingMode,
bypassFor:
isAppPage && srcRoute !== '/_not-found'
? experimentalBypassFor
: undefined,
bypassToken: prerenderManifest.preview.previewModeId,
},
}
outputs.prerenders.push(initialOutput)
if (!isAppPage && appPageKeys && appPageKeys.length > 0) {
const rscPage = `${route === '/' ? '/index' : route}.rsc`
outputs.staticFiles.push({
id: rscPage,
pathname: rscPage,
type: AdapterOutputType.STATIC_FILE,
filePath: rscFallbackPath,
immutableHash: undefined,
})
}
if (dataRoute) {
let dataFilePath: string | undefined = path.join(
pagesDistDir,
`${normalizePagePath(route)}.json`
)
let postponed = meta.postponed
const dataRouteToUse =
renderingMode === RenderingMode.PARTIALLY_STATIC &&
prefetchDataRoute
? prefetchDataRoute
: dataRoute
if (isAppPage) {
// When experimental PPR is enabled, we expect that the data
// that should be served as a part of the prerender should
// be from the prefetch data route. If this isn't enabled
// for ppr, the only way to get the data is from the data
// route.
dataFilePath = path.join(
appDistDir,
(dataRouteToUse ?? dataRoute)?.replace(/^\//, '')
)
}
if (
renderingMode === RenderingMode.PARTIALLY_STATIC &&
!(await cachedFilePathCheck(dataFilePath))
) {
outputs.prerenders.push({
...initialOutput,
id: dataRoute,
pathname: dataRoute,
fallback: {
...initialOutput.fallback,
postponedState: postponed,
initialStatus: undefined,
initialHeaders: {
...initialOutput.fallback?.initialHeaders,
...dataInitialHeaders,
'content-type': isAppPage
? rscContentTypeHeader
: JSON_CONTENT_TYPE_HEADER,
},
filePath: undefined,
},
})
} else {
outputs.prerenders.push({
...initialOutput,
id: dataRoute,
pathname: dataRoute,
fallback: isNotFoundTrue
? undefined
: {
...initialOutput.fallback,
initialStatus: undefined,
initialHeaders: {
...initialOutput.fallback?.initialHeaders,
...dataInitialHeaders,
'content-type': isAppPage
? rscContentTypeHeader
: JSON_CONTENT_TYPE_HEADER,
},
postponedState: undefined,
filePath: dataFilePath,
},
})
}
}
if (isAppPage) {
await handleAppMeta(route, initialOutput, meta, {
htmlAllowQuery,
dataAllowQuery,
})
}
prerenderGroupId += 1
}
for (const dynamicRoute in prerenderManifest.dynamicRoutes) {
const {
fallback,
fallbackExpire,
fallbackRevalidate,
fallbackHeaders,
fallbackStatus,
fallbackSourceRoute,
fallbackRootParams,
remainingPrerenderableParams,
allowHeader,
dataRoute,
renderingMode,
experimentalBypassFor,
} = prerenderManifest.dynamicRoutes[dynamicRoute]
const srcRoute = fallbackSourceRoute || dynamicRoute
const parentOutput = getParentOutput(srcRoute, dynamicRoute)
const isAppPage = Boolean(appOutputMap[srcRoute])
const meta = await getAppRouteMeta(dynamicRoute, isAppPage)
const routeKeys =
routesManifest.dynamicRoutes.find(
(item) => item.page === dynamicRoute
)?.routeKeys || {}
const allowQuery = Object.values(routeKeys)
const partialFallbacksEnabled =
config.experimental.partialFallbacks === true
const partialFallback =
partialFallbacksEnabled &&
isAppPage &&
remainingPrerenderableParams !== undefined &&
remainingPrerenderableParams.length > 0 &&
renderingMode === RenderingMode.PARTIALLY_STATIC &&
typeof fallback === 'string' &&
Boolean(meta.postponed)
const canEmitPartialFallback =
partialFallback && fallbackRootParams?.length === 0
let htmlAllowQuery = allowQuery
// We only want to vary on the shell contents if there is a fallback
// present and able to be served.
if (typeof fallback === 'string') {
if (fallbackRootParams && fallbackRootParams.length > 0) {
htmlAllowQuery = fallbackRootParams as string[]
}
// We additionally vary based on if there's a postponed prerender
// because if there isn't, then that means that we generated an
// empty shell, and producing an empty RSC shell would be a waste.
// If there is a postponed prerender, then the RSC shell would be
// non-empty, and it would be valuable to also generate an empty
// RSC shell.
else if (meta.postponed) {
// If there's postponed fallback content, we usually collapse to a shared shell (`[]`).
// For opt-in partial fallbacks in cache components, keep only the
// params that can still complete this shell.
const remainingPrerenderableQueryKeys = new Set(
(remainingPrerenderableParams ?? []).map(
(param) => `${NEXT_QUERY_PARAM_PREFIX}${param.paramName}`
)
)
htmlAllowQuery =
canEmitPartialFallback && routesManifest.rsc.clientParamParsing
? Object.values(routeKeys).filter((routeKey) =>
remainingPrerenderableQueryKeys.has(routeKey)
)
: []
}
}
const initialOutput: AdapterOutput['PRERENDER'] = {
id: dynamicRoute,
type: AdapterOutputType.PRERENDER,
pathname: dynamicRoute,
parentOutputId: parentOutput.id,
groupId: prerenderGroupId,
pprChain:
isAppPage && renderingMode === RenderingMode.PARTIALLY_STATIC
? {
headers: {
[NEXT_RESUME_HEADER]: '1',
},
}
: undefined,
fallback:
typeof fallback === 'string'
? {
filePath: path.join(
isAppPage ? appDistDir : pagesDistDir,
// app router dynamic route fallbacks don't have the
// extension so ensure it's added here
fallback.endsWith('.html') ? fallback : `${fallback}.html`
),
postponedState: undefined,
initialStatus: fallbackStatus ?? meta.status,
initialHeaders: {
...fallbackHeaders,
...(appPageKeys?.length ? { vary: varyHeader } : {}),
'content-type': HTML_CONTENT_TYPE_HEADER,
...meta.headers,
},
initialExpiration: fallbackExpire,
initialRevalidate: fallbackRevalidate ?? 1,
}
: undefined,
config: {
allowQuery: htmlAllowQuery,
allowHeader,
renderingMode,
partialFallback: canEmitPartialFallback || undefined,
bypassFor: isAppPage ? experimentalBypassFor : undefined,
bypassToken: prerenderManifest.preview.previewModeId,
},
}
if (!config.i18n || isAppPage) {
outputs.prerenders.push(initialOutput)
if (
!isAppPage &&
fallback !== false &&
appPageKeys &&
appPageKeys.length > 0
) {
const rscPage = `${srcRoute === '/' ? '/index' : srcRoute}.rsc`
outputs.staticFiles.push({
id: rscPage,
pathname: rscPage,
type: AdapterOutputType.STATIC_FILE,
filePath: rscFallbackPath,
immutableHash: undefined,
})
}
let dataAllowQuery = allowQuery
const dataInitialHeaders: Record<string, string> = {}
if (meta.postponed && routesManifest.rsc.dynamicRSCPrerender) {
// If client param parsing is enabled, we follow the same logic as the
// HTML allowQuery as it's already going to vary based on if there's a
// static shell generated or if there's fallback root params. If there
// are fallback root params, and we can serve a fallback, then we
// should follow the same logic for the dynamic RSC routes.
//
// If client param parsing is not enabled, we have to use the
// allowQuery because the RSC payloads will contain dynamic segment
// values.
if (routesManifest.rsc.clientParamParsing) {
dataAllowQuery = htmlAllowQuery
}
}
if (renderingMode === RenderingMode.PARTIALLY_STATIC) {
// Dynamic RSC requests cannot be cached, so we explicity set it
// here to ensure that the response is not cached by the browser.
dataInitialHeaders['cache-control'] =
'private, no-store, no-cache, max-age=0, must-revalidate'
}
if (isAppPage) {
await handleAppMeta(dynamicRoute, initialOutput, meta, {
htmlAllowQuery,
dataAllowQuery,
})
}
if (renderingMode === RenderingMode.PARTIALLY_STATIC) {
outputs.prerenders.push({
...initialOutput,
id: `${dynamicRoute}.rsc`,
pathname: `${dynamicRoute}.rsc`,
fallback: {
...initialOutput.fallback,
filePath: undefined,
postponedState: meta.postponed,
initialStatus: undefined,
initialHeaders: {
...initialOutput.fallback?.initialHeaders,
...dataInitialHeaders,
'content-type': isAppPage
? rscContentTypeHeader
: JSON_CONTENT_TYPE_HEADER,
},
},
config: {
...initialOutput.config,
allowQuery: dataAllowQuery,
partialFallback: undefined,
},
})
} else if (dataRoute) {
outputs.prerenders.push({
...initialOutput,
id: dataRoute,
pathname: dataRoute,
fallback: undefined,
config: {
...initialOutput.config,
partialFallback: undefined,
},
})
}
prerenderGroupId += 1
} else {
for (const locale of config.i18n.locales) {
const currentOutput: AdapterOutput['PRERENDER'] = {
...initialOutput,
pathname: path.posix.join(`/${locale}`, initialOutput.pathname),
id: path.posix.join(`/${locale}`, initialOutput.id),
fallback:
typeof fallback === 'string'
? {
...initialOutput.fallback,
initialStatus: undefined,
postponedState: undefined,
filePath: path.join(
pagesDistDir,
locale,
// app router dynamic route fallbacks don't have the
// extension so ensure it's added here
fallback.endsWith('.html')
? fallback
: `${fallback}.html`
),
}
: undefined,
groupId: prerenderGroupId,
}
outputs.prerenders.push(currentOutput)
if (
!isAppPage &&
fallback !== false &&
appPageKeys &&
appPageKeys.length > 0
) {
const rscPage = `${path.posix.join(`/${locale}`, initialOutput.pathname)}.rsc`
outputs.staticFiles.push({
id: rscPage,
pathname: rscPage,
type: AdapterOutputType.STATIC_FILE,
filePath: rscFallbackPath,
immutableHash: undefined,
})
}
if (dataRoute) {
const dataPathname = path.posix.join(
`/_next/data`,
buildId,
locale,
dynamicRoute + '.json'
)
outputs.prerenders.push({
...initialOutput,
id: dataPathname,
pathname: dataPathname,
// data route doesn't have skeleton fallback
fallback: undefined,
config: {
...initialOutput.config,
partialFallback: undefined,
},
groupId: prerenderGroupId,
})
}
prerenderGroupId += 1
}
}
}
// ensure 404
const staticErrorDocs = [
...(hasStatic404 ? ['/404'] : []),
...(hasStatic500 ? ['/500'] : []),
]
for (const errorDoc of staticErrorDocs) {
const errorDocPath = path.posix.join(
'/',
config.i18n?.defaultLocale || '',
errorDoc
)
if (!prerenderManifest.routes[errorDocPath]) {
for (const currentDocPath of [
errorDocPath,
...(config.i18n?.locales?.map((locale) =>
path.posix.join('/', locale, errorDoc)
) || []),
]) {
const currentFilePath = path.join(
pagesDistDir,
`${currentDocPath}.html`
)
if (await cachedFilePathCheck(currentFilePath)) {
outputs.staticFiles.push({
pathname: currentDocPath,
id: currentDocPath,
type: AdapterOutputType.STATIC_FILE,
filePath: currentFilePath,
immutableHash: undefined,
})
}
}
}
}
}
normalizePathnames(config, outputs)
const dynamicRoutes: DynamicRouteItem[] = []
const dynamicDataRoutes: DynamicRouteItem[] = []
const dynamicSegmentRoutes: DynamicRouteItem[] = []
const getDestinationQuery = (routeKeys: Record<string, string>) => {
const items = Object.entries(routeKeys ?? {})
if (items.length === 0) return ''
return '?' + items.map(([key, value]) => `${value}=$${key}`).join('&')
}
const fallbackFalseHasCondition: RouteHas[] = [
{
type: 'cookie',
key: '__prerender_bypass',
value: prerenderManifest.preview.previewModeId,
},
{
type: 'cookie',
key: '__next_preview_data',
},
]
for (const route of routesManifest.dynamicRoutes) {
const shouldLocalize = config.i18n
const routeRegex = getNamedRouteRegex(route.page, {
prefixRouteKeys: true,
})
const isFallbackFalse =
prerenderManifest.dynamicRoutes[route.page]?.fallback === false
const sourceRegex = routeRegex.namedRegex.replace(
'^',
`^${config.basePath && config.basePath !== '/' ? path.posix.join('/', config.basePath || '') : ''}[/]?${shouldLocalize ? '(?<nextLocale>[^/]{1,})' : ''}`
)
const destination =
path.posix.join(
'/',
config.basePath,
shouldLocalize ? '/$nextLocale' : '',
route.page
) + getDestinationQuery(route.routeKeys)
if (appPageKeys && appPageKeys.length > 0) {
dynamicRoutes.push({
source: route.page + '.rsc',
sourceRegex: sourceRegex.replace(
new RegExp(escapeStringRegexp('(?:/)?$')),
'(?<rscSuffix>\\.rsc|\\.segments/.+\\.segment\\.rsc)(?:/)?$'
),
destination: destination?.replace(/($|\?)/, '$rscSuffix$1'),
has:
isFallbackFalse && !pageKeys.includes(route.page)
? fallbackFalseHasCondition
: undefined,
missing: undefined,
})
}
// needs basePath and locale handling if pages router
dynamicRoutes.push({
source: route.page,
sourceRegex,
destination,
has: isFallbackFalse ? fallbackFalseHasCondition : undefined,
missing: undefined,
})
for (const segmentRoute of route.prefetchSegmentDataRoutes || []) {
dynamicSegmentRoutes.push({
source: route.page,
sourceRegex: segmentRoute.source.replace(
'^',
`^${config.basePath && config.basePath !== '/' ? path.posix.join('/', config.basePath || '') : ''}[/]?`
),
destination: path.posix.join(
'/',
config.basePath,
segmentRoute.destination +
getDestinationQuery(segmentRoute.routeKeys)
),
has: undefined,
missing: undefined,
})
}
}
const needsMiddlewareResolveRoutes =
outputs.middleware && outputs.pages.length > 0
const dataRoutePages = new Set([
...routesManifest.dataRoutes.map((item) => item.page),
])
const sortedDataPages = sortSortableRoutes([
...(needsMiddlewareResolveRoutes
? [...staticPages].map((page) => ({ sourcePage: page, page }))
: []),
...routesManifest.dataRoutes.map((item) => ({
sourcePage: item.page,
page: item.page,
})),
])
for (const { page } of sortedDataPages) {
if (needsMiddlewareResolveRoutes || isDynamicRoute(page)) {
const shouldLocalize = config.i18n
const isFallbackFalse =
prerenderManifest.dynamicRoutes[page]?.fallback === false
const routeRegex = getNamedRouteRegex(page + '.json', {
prefixRouteKeys: true,
includeSuffix: true,
})
const isDataRoute = dataRoutePages.has(page)
const destination = path.posix.join(
'/',
config.basePath,
...(isDataRoute ? [`_next/data`, buildId] : ''),
...(page === '/'
? [shouldLocalize ? '$nextLocale.json' : 'index.json']
: [
shouldLocalize ? '$nextLocale' : '',
page +
(isDataRoute ? '.json' : '') +
getDestinationQuery(routeRegex.routeKeys || {}),
])
)
dynamicDataRoutes.push({
source: page,
sourceRegex:
shouldLocalize && page === '/'
? '^' +
path.posix.join(
'/',
config.basePath,
'_next/data',
escapeStringRegexp(buildId),
'(?<nextLocale>[^/]{1,}).json'
)
: routeRegex.namedRegex.replace(
'^',
`^${path.posix.join(
'/',
config.basePath,
`_next/data`,
escapeStringRegexp(buildId)
)}[/]?${shouldLocalize ? '(?<nextLocale>[^/]{1,})' : ''}`
),
destination,
has: isFallbackFalse ? fallbackFalseHasCondition : undefined,
missing: undefined,
})
}
}
const buildRewriteItem = (route: ManifestRewriteRoute): RewriteItem => {
const converted = convertRewrites([route], ['nextInternalLocale'])[0]
const regex = converted.src || route.regex
return {
source: route.source,
sourceRegex: route.internal ? regex : modifyRouteRegex(regex),
destination: converted.dest || route.destination,
has: route.has,
missing: route.missing,
} satisfies Route
}
const buildRouteFromHeader = (route: ManifestHeaderRoute): Route => {
const converted = convertHeaders([route])[0]
const regex = converted.src || route.regex
return {
source: route.source,
sourceRegex: route.internal ? regex : modifyRouteRegex(regex),
headers: 'headers' in converted ? converted.headers || {} : {},
has: route.has,
missing: route.missing,
priority: route.internal || undefined,
} satisfies Route
}
try {
Log.info(`Running onBuildComplete from ${adapterMod.name}`)
const combinedDynamicRoutes = [
...dynamicDataRoutes,
...dynamicSegmentRoutes,
...dynamicRoutes,
] satisfies Route[]
const rewrites = {
beforeFiles: routesManifest.rewrites.beforeFiles.map(buildRewriteItem),
afterFiles: routesManifest.rewrites.afterFiles.map(buildRewriteItem),
fallback: routesManifest.rewrites.fallback.map(buildRewriteItem),
}
const redirects = routesManifest.redirects.map((route) => {
const converted = convertRedirects([route], 307)[0]
const regex = converted.src || route.regex
return {
source: route.source,
sourceRegex: route.internal ? regex : modifyRouteRegex(regex),
headers: 'headers' in converted ? converted.headers || {} : {},
status: converted.status || getRedirectStatus(route),
has: route.has,
missing: route.missing,
priority: route.internal || undefined,
} satisfies Route
})
const headers = routesManifest.headers.map((route) =>
buildRouteFromHeader(route)
)
const onMatchHeaders = routesManifest.onMatchHeaders.map((route) =>
buildRouteFromHeader(route)
)
await adapterMod.onBuildComplete({
routing: {
beforeMiddleware: [...headers, ...redirects],
middlewareMatchers:
outputs.middleware?.config.matchers?.map((matcher) => ({
source: matcher.source,
sourceRegex: matcher.sourceRegex,
has: matcher.has,
missing: matcher.missing,
})) ?? [],
beforeFiles: rewrites.beforeFiles,
afterFiles: rewrites.afterFiles,
dynamicRoutes: combinedDynamicRoutes,
onMatch: [
{
// This ensures we only match known emitted-by-Next.js files and not
// user-emitted files which may be missing a hash in their filename.
sourceRegex: `${path.posix.join(config.basePath || '/', '_next/static', `/(?:[^/]+/pages|pages|chunks|immutable|runtime|css|image|media|${escapeStringRegexp(buildId)})/.+`)}`,
// Next.js assets contain a hash or entropy in their filenames, so they
// are guaranteed to be unique and cacheable indefinitely.
headers: {
'cache-control': `public,max-age=${CACHE_ONE_YEAR_SECONDS},immutable`,
},
},
...onMatchHeaders,
],
fallback: rewrites.fallback,
shouldNormalizeNextData: !!needsMiddlewareResolveRoutes,
rsc: generateRoutesManifest({
appType,
pageKeys: {
pages: pageKeys as string[],
app: appPageKeys as string[],
},
config,
redirects: [],
headers: [],
onMatchHeaders: [],
rewrites,
restrictedRedirectPaths: [],
isAppPPREnabled: config.cacheComponents,
}).routesManifest.rsc,
},
outputs,
config,
distDir,
buildId,
nextVersion,
projectDir: dir,
repoRoot: tracingRoot,
})
} catch (err) {
Log.error(`Failed to run onBuildComplete from ${adapterMod.name}`)
throw err
}
}
}