Files
vercel/packages/next/src/utils.ts
Wyatt Johnson c9d53d4e3e [next] Ensure only pages enable streaming (#11660)
This fixes a regression introduced by #11625 that enabled streaming for
routes on the pages router (these routes do not support streaming).
2024-05-24 15:15:32 -07:00

3495 lines
97 KiB
TypeScript

import {
FileFsRef,
Files,
Config,
debug,
FileBlob,
glob,
Lambda,
Prerender,
getLambdaOptionsFromFunction,
getPlatformEnv,
streamToBuffer,
NowBuildError,
isSymbolicLink,
NodejsLambda,
EdgeFunction,
Images,
File,
FlagDefinitions,
} from '@vercel/build-utils';
import { NodeFileTraceReasons } from '@vercel/nft';
import type {
HasField,
Header,
Rewrite,
Route,
RouteWithSrc,
} from '@vercel/routing-utils';
import { Sema } from 'async-sema';
import crc32 from 'buffer-crc32';
import fs, { lstat, stat } from 'fs-extra';
import path from 'path';
import semver from 'semver';
import url from 'url';
import { createRequire } from 'module';
import escapeStringRegexp from 'escape-string-regexp';
import { htmlContentType } from '.';
import textTable from 'text-table';
import { getNextjsEdgeFunctionSource } from './edge-function-source/get-edge-function-source';
import type { LambdaOptionsWithFiles } from '@vercel/build-utils/dist/lambda';
import { stringifySourceMap } from './sourcemapped';
import type { RawSourceMap } from 'source-map';
import { prettyBytes } from './pretty-bytes';
import {
MIB,
KIB,
LAMBDA_RESERVED_UNCOMPRESSED_SIZE,
DEFAULT_MAX_UNCOMPRESSED_LAMBDA_SIZE,
} from './constants';
type stringMap = { [key: string]: string };
export const require_ = createRequire(__filename);
export const RSC_CONTENT_TYPE = 'x-component';
export const RSC_PREFETCH_SUFFIX = '.prefetch.rsc';
export const MAX_UNCOMPRESSED_LAMBDA_SIZE = !isNaN(
Number(process.env.MAX_UNCOMPRESSED_LAMBDA_SIZE)
)
? Number(process.env.MAX_UNCOMPRESSED_LAMBDA_SIZE)
: DEFAULT_MAX_UNCOMPRESSED_LAMBDA_SIZE;
// Identify /[param]/ in route string
// eslint-disable-next-line no-useless-escape
const TEST_DYNAMIC_ROUTE = /\/\[[^\/]+?\](?=\/|$)/;
function isDynamicRoute(route: string): boolean {
route = route.startsWith('/') ? route : `/${route}`;
return TEST_DYNAMIC_ROUTE.test(route);
}
/**
* Validate if the entrypoint is allowed to be used
*/
function validateEntrypoint(entrypoint: string) {
if (
!/package\.json$/.exec(entrypoint) &&
!/next\.config\.js$/.exec(entrypoint)
) {
throw new NowBuildError({
message:
'Specified "src" for "@vercel/next" has to be "package.json" or "next.config.js"',
code: 'NEXT_INCORRECT_SRC',
});
}
}
/**
* Exclude certain files from the files object
*/
function excludeFiles(
files: Files,
matcher: (filePath: string) => boolean
): Files {
return Object.keys(files).reduce((newFiles, filePath) => {
if (matcher(filePath)) {
return newFiles;
}
return {
...newFiles,
[filePath]: files[filePath],
};
}, {});
}
/**
* Enforce specific package.json configuration for smallest possible lambda
*/
function normalizePackageJson(
defaultPackageJson: {
dependencies?: stringMap;
devDependencies?: stringMap;
scripts?: stringMap;
} = {}
) {
const dependencies: stringMap = {};
const devDependencies: stringMap = {
...defaultPackageJson.dependencies,
...defaultPackageJson.devDependencies,
};
if (devDependencies.react) {
dependencies.react = devDependencies.react;
delete devDependencies.react;
}
if (devDependencies['react-dom']) {
dependencies['react-dom'] = devDependencies['react-dom'];
delete devDependencies['react-dom'];
}
delete devDependencies['next-server'];
return {
...defaultPackageJson,
dependencies: {
// react and react-dom can be overwritten
react: 'latest',
'react-dom': 'latest',
...dependencies, // override react if user provided it
// next-server is forced to canary
'next-server': 'v7.0.2-canary.49',
},
devDependencies: {
...devDependencies,
// next is forced to canary
next: 'v7.0.2-canary.49',
},
scripts: {
...defaultPackageJson.scripts,
'now-build':
'NODE_OPTIONS=--max_old_space_size=3000 next build --lambdas',
},
};
}
async function getNextConfig(workPath: string, entryPath: string) {
const entryConfig = path.join(entryPath, './next.config.js');
if (await fs.pathExists(entryConfig)) {
return fs.readFile(entryConfig, 'utf8');
}
const workConfig = path.join(workPath, './next.config.js');
if (await fs.pathExists(workConfig)) {
return fs.readFile(workConfig, 'utf8');
}
return null;
}
function getImagesConfig(
imagesManifest: NextImagesManifest | undefined
): Images | undefined {
return imagesManifest?.images?.loader === 'default' &&
imagesManifest.images?.unoptimized !== true
? {
domains: imagesManifest.images.domains,
sizes: imagesManifest.images.sizes,
remotePatterns: imagesManifest.images.remotePatterns,
minimumCacheTTL: imagesManifest.images.minimumCacheTTL,
formats: imagesManifest.images.formats,
dangerouslyAllowSVG: imagesManifest.images.dangerouslyAllowSVG,
contentSecurityPolicy: imagesManifest.images.contentSecurityPolicy,
contentDispositionType: imagesManifest.images.contentDispositionType,
}
: undefined;
}
function normalizePage(page: string): string {
// Resolve on anything that doesn't start with `/`
if (!page.startsWith('/')) {
page = `/${page}`;
}
// Replace the `/index` with `/`
if (page === '/index') {
page = '/';
}
return page;
}
export type Redirect = Rewrite & {
statusCode?: number;
permanent?: boolean;
};
type RoutesManifestRegex = {
regex: string;
regexKeys: string[];
};
type RoutesManifestRoute = {
page: string;
regex: string;
namedRegex?: string;
routeKeys?: { [named: string]: string };
};
type RoutesManifestOld = {
pages404: boolean;
basePath: string | undefined;
redirects: (Redirect & RoutesManifestRegex)[];
rewrites:
| (Rewrite & RoutesManifestRegex)[]
| {
beforeFiles: (Rewrite & RoutesManifestRegex)[];
afterFiles: (Rewrite & RoutesManifestRegex)[];
fallback: (Rewrite & RoutesManifestRegex)[];
};
headers?: (Header & RoutesManifestRegex)[];
dynamicRoutes: RoutesManifestRoute[];
staticRoutes: RoutesManifestRoute[];
version: 1 | 2 | 3;
dataRoutes?: Array<{
page: string;
dataRouteRegex: string;
namedDataRouteRegex?: string;
routeKeys?: { [named: string]: string };
}>;
i18n?: {
localeDetection?: boolean;
defaultLocale: string;
locales: string[];
domains?: Array<{
http?: boolean;
domain: string;
locales?: string[];
defaultLocale: string;
}>;
};
rsc?: {
header: string;
varyHeader: string;
prefetchHeader?: string;
didPostponeHeader?: string;
contentTypeHeader: string;
};
skipMiddlewareUrlNormalize?: boolean;
};
type RoutesManifestV4 = Omit<RoutesManifestOld, 'dynamicRoutes' | 'version'> & {
version: 4;
dynamicRoutes: (RoutesManifestRoute | { page: string; isMiddleware: true })[];
};
export type RoutesManifest = RoutesManifestV4 | RoutesManifestOld;
export async function getRoutesManifest(
entryPath: string,
outputDirectory: string,
nextVersion?: string
): Promise<RoutesManifest | undefined> {
const shouldHaveManifest =
nextVersion && semver.gte(nextVersion, '9.1.4-canary.0');
if (!shouldHaveManifest) return;
const pathRoutesManifest = path.join(
entryPath,
outputDirectory,
'routes-manifest.json'
);
const hasRoutesManifest = await fs
.access(pathRoutesManifest)
.then(() => true)
.catch(() => false);
if (shouldHaveManifest && !hasRoutesManifest) {
throw new NowBuildError({
message: `The file "${pathRoutesManifest}" couldn't be found. This is often caused by a misconfiguration in your project.`,
link: 'https://err.sh/vercel/vercel/now-next-routes-manifest',
code: 'NEXT_NO_ROUTES_MANIFEST',
});
}
const routesManifest: RoutesManifest = await fs.readJSON(pathRoutesManifest);
// remove temporary array based routeKeys from v1/v2 of routes
// manifest since it can result in invalid routes
for (const route of routesManifest.dataRoutes || []) {
if (Array.isArray(route.routeKeys)) {
delete route.routeKeys;
delete route.namedDataRouteRegex;
}
}
for (const route of routesManifest.dynamicRoutes || []) {
if ('routeKeys' in route && Array.isArray(route.routeKeys)) {
delete route.routeKeys;
delete route.namedRegex;
}
}
return routesManifest;
}
export async function getDynamicRoutes({
entryPath,
entryDirectory,
dynamicPages,
isDev,
routesManifest,
omittedRoutes,
canUsePreviewMode,
bypassToken,
isServerMode,
dynamicMiddlewareRouteMap,
hasActionOutputSupport,
isAppPPREnabled,
}: {
entryPath: string;
entryDirectory: string;
dynamicPages: string[];
isDev?: boolean;
routesManifest?: RoutesManifest;
omittedRoutes?: ReadonlySet<string>;
canUsePreviewMode?: boolean;
bypassToken?: string;
isServerMode?: boolean;
dynamicMiddlewareRouteMap?: ReadonlyMap<string, RouteWithSrc>;
hasActionOutputSupport: boolean;
isAppPPREnabled: boolean;
}): Promise<RouteWithSrc[]> {
if (routesManifest) {
switch (routesManifest.version) {
case 1:
case 2: {
return routesManifest.dynamicRoutes
.filter(({ page }) => canUsePreviewMode || !omittedRoutes?.has(page))
.map(({ page, regex }: { page: string; regex: string }) => {
return {
src: regex,
dest: !isDev ? path.posix.join('/', entryDirectory, page) : page,
check: true,
status:
canUsePreviewMode && omittedRoutes?.has(page) ? 404 : undefined,
};
});
}
case 3:
case 4: {
const routes: RouteWithSrc[] = [];
for (const dynamicRoute of routesManifest.dynamicRoutes) {
if (!canUsePreviewMode && omittedRoutes?.has(dynamicRoute.page)) {
continue;
}
const params = dynamicRoute;
if ('isMiddleware' in params) {
const route = dynamicMiddlewareRouteMap?.get(params.page);
if (!route) {
throw new Error(
`Could not find dynamic middleware route for ${params.page}`
);
}
routes.push(route);
continue;
}
const { page, namedRegex, regex, routeKeys } = params;
const route: RouteWithSrc = {
src: namedRegex || regex,
dest: `${
!isDev ? path.posix.join('/', entryDirectory, page) : page
}${
routeKeys
? `?${Object.keys(routeKeys)
.map(key => `${routeKeys[key]}=$${key}`)
.join('&')}`
: ''
}`,
};
if (!isServerMode) {
route.check = true;
}
if (isServerMode && canUsePreviewMode && omittedRoutes?.has(page)) {
// only match this route when in preview mode so
// preview works for non-prerender fallback: false pages
route.has = [
{
type: 'cookie',
key: '__prerender_bypass',
value: bypassToken || undefined,
},
{
type: 'cookie',
key: '__next_preview_data',
},
];
}
if (isAppPPREnabled) {
let dest = route.dest?.replace(/($|\?)/, '.prefetch.rsc$1');
if (page === '/' || page === '/index') {
dest = dest?.replace(/([^/]+\.prefetch\.rsc(\?.*|$))/, '__$1');
}
routes.push({
...route,
src: route.src.replace(
new RegExp(escapeStringRegexp('(?:/)?$')),
'(?:\\.prefetch\\.rsc)(?:/)?$'
),
dest,
});
}
if (hasActionOutputSupport) {
routes.push({
...route,
src: route.src.replace(
new RegExp(escapeStringRegexp('(?:/)?$')),
'(?<nxtsuffix>(?:\\.action|\\.rsc))(?:/)?$'
),
dest: route.dest?.replace(/($|\?)/, '$nxtsuffix$1'),
});
} else {
routes.push({
...route,
src: route.src.replace(
new RegExp(escapeStringRegexp('(?:/)?$')),
'(?:\\.rsc)(?:/)?$'
),
dest: route.dest?.replace(/($|\?)/, '.rsc$1'),
});
}
routes.push(route);
}
return routes;
}
default: {
// update MIN_ROUTES_MANIFEST_VERSION
throw new NowBuildError({
message:
'This version of `@vercel/next` does not support the version of Next.js you are trying to deploy.\n' +
'Please upgrade your `@vercel/next` builder and try again. Contact support if this continues to happen.',
code: 'NEXT_VERSION_UPGRADE',
});
}
}
}
// FALLBACK:
// When `routes-manifest.json` does not exist (old Next.js versions), we'll try to
// require the methods we need from Next.js' internals.
if (!dynamicPages.length) {
return [];
}
let getRouteRegex: ((pageName: string) => { re: RegExp }) | undefined =
undefined;
let getSortedRoutes:
| ((normalizedPages: ReadonlyArray<string>) => string[])
| undefined;
try {
const resolved = require_.resolve('next-server/dist/lib/router/utils', {
paths: [entryPath],
});
({ getRouteRegex, getSortedRoutes } = require_(resolved));
if (typeof getRouteRegex !== 'function') {
getRouteRegex = undefined;
}
} catch (_) {} // eslint-disable-line no-empty
if (!getRouteRegex || !getSortedRoutes) {
try {
const resolved = require_.resolve(
'next/dist/next-server/lib/router/utils',
{ paths: [entryPath] }
);
({ getRouteRegex, getSortedRoutes } = require_(resolved));
if (typeof getRouteRegex !== 'function') {
getRouteRegex = undefined;
}
} catch (_) {} // eslint-disable-line no-empty
}
if (!getRouteRegex || !getSortedRoutes) {
throw new NowBuildError({
message:
'Found usage of dynamic routes but not on a new enough version of Next.js.',
code: 'NEXT_DYNAMIC_ROUTES_OUTDATED',
});
}
const pageMatchers = getSortedRoutes(dynamicPages).map(pageName => ({
pageName,
matcher: getRouteRegex && getRouteRegex(pageName).re,
}));
const routes: RouteWithSrc[] = [];
pageMatchers.forEach(pageMatcher => {
// in `vercel dev` we don't need to prefix the destination
const dest = !isDev
? path.posix.join('/', entryDirectory, pageMatcher.pageName)
: pageMatcher.pageName;
if (pageMatcher && pageMatcher.matcher) {
routes.push({
src: pageMatcher.matcher.source,
dest,
check: !isDev,
});
}
});
return routes;
}
export function localizeDynamicRoutes(
dynamicRoutes: RouteWithSrc[],
dynamicPrefix: string,
entryDirectory: string,
staticPages: Files,
prerenderManifest: NextPrerenderedRoutes,
routesManifest?: RoutesManifest,
isServerMode?: boolean,
isCorrectLocaleAPIRoutes?: boolean,
inversedAppPathRoutesManifest?: Record<string, string>
): RouteWithSrc[] {
return dynamicRoutes.map((route: RouteWithSrc) => {
// i18n is already handled for middleware
if (route.middleware !== undefined || route.middlewarePath !== undefined)
return route;
const { i18n } = routesManifest || {};
if (i18n) {
const { pathname } = url.parse(route.dest!);
const pathnameNoPrefix = pathname?.replace(dynamicPrefix, '');
const isFallback = prerenderManifest.fallbackRoutes[pathname!];
const isBlocking = prerenderManifest.blockingFallbackRoutes[pathname!];
const isApiRoute =
pathnameNoPrefix === '/api' || pathnameNoPrefix?.startsWith('/api/');
const isAutoExport =
staticPages[addLocaleOrDefault(pathname!, routesManifest).substring(1)];
const isAppRoute =
inversedAppPathRoutesManifest?.[pathnameNoPrefix || ''];
const isLocalePrefixed =
isFallback || isBlocking || isAutoExport || isServerMode;
route.src = route.src.replace(
'^',
`^${dynamicPrefix ? `${dynamicPrefix}[/]?` : '[/]?'}(?${
isLocalePrefixed ? '<nextLocale>' : ':'
}${i18n.locales.map(locale => escapeStringRegexp(locale)).join('|')})?`
);
if (
isLocalePrefixed &&
!(isCorrectLocaleAPIRoutes && isApiRoute) &&
!isAppRoute
) {
// ensure destination has locale prefix to match prerender output
// path so that the prerender object is used
route.dest = route.dest!.replace(
`${path.posix.join('/', entryDirectory, '/')}`,
`${path.posix.join('/', entryDirectory, '$nextLocale', '/')}`
);
}
} else {
route.src = route.src.replace('^', `^${dynamicPrefix}`);
}
return route;
});
}
type LoaderKey = 'imgix' | 'cloudinary' | 'akamai' | 'default';
export type NextImagesManifest = {
version: number;
images: {
loader: LoaderKey;
sizes: number[];
domains: string[];
remotePatterns: Images['remotePatterns'];
minimumCacheTTL?: Images['minimumCacheTTL'];
formats?: Images['formats'];
unoptimized?: boolean;
dangerouslyAllowSVG?: Images['dangerouslyAllowSVG'];
contentSecurityPolicy?: Images['contentSecurityPolicy'];
contentDispositionType?: Images['contentDispositionType'];
};
};
export async function getImagesManifest(
entryPath: string,
outputDirectory: string
): Promise<NextImagesManifest | undefined> {
const pathImagesManifest = path.join(
entryPath,
outputDirectory,
'images-manifest.json'
);
const hasImagesManifest = await fs
.access(pathImagesManifest)
.then(() => true)
.catch(() => false);
if (!hasImagesManifest) {
return undefined;
}
return fs.readJson(pathImagesManifest);
}
type FileMap = { [page: string]: FileFsRef };
export function filterStaticPages(
staticPageFiles: FileMap,
dynamicPages: string[],
entryDirectory: string,
htmlContentType: string,
prerenderManifest: NextPrerenderedRoutes,
routesManifest?: RoutesManifest
) {
const staticPages: FileMap = {};
Object.keys(staticPageFiles).forEach((page: string) => {
const pathname = page.replace(/\.html$/, '');
const routeName = normalizeLocalePath(
normalizePage(pathname),
routesManifest?.i18n?.locales
).pathname;
// Prerendered routes emit a `.html` file but should not be treated as a
// static page.
// Lazily prerendered routes have a fallback `.html` file on newer
// Next.js versions so we need to also not treat it as a static page here.
if (
prerenderManifest.staticRoutes[routeName] ||
prerenderManifest.fallbackRoutes[routeName] ||
prerenderManifest.staticRoutes[normalizePage(pathname)] ||
prerenderManifest.fallbackRoutes[normalizePage(pathname)]
) {
return;
}
const staticRoute = path.posix.join(entryDirectory, pathname);
staticPages[staticRoute] = staticPageFiles[page];
staticPages[staticRoute].contentType = htmlContentType;
if (isDynamicRoute(pathname)) {
dynamicPages.push(routeName);
return;
}
});
return staticPages;
}
export function getFilesMapFromReasons(
fileList: ReadonlySet<string>,
reasons: NodeFileTraceReasons,
ignoreFn?: (file: string, parent?: string) => boolean
): ReadonlyMap<string, Set<string>> {
// this uses the reasons tree to collect files specific to a
// certain parent allowing us to not have to trace each parent
// separately
const parentFilesMap = new Map<string, Set<string>>();
function propagateToParents(
parents: Set<string>,
file: string,
seen = new Set<string>()
) {
for (const parent of parents || []) {
if (!seen.has(parent)) {
seen.add(parent);
let parentFiles = parentFilesMap.get(parent);
if (!parentFiles) {
parentFiles = new Set();
parentFilesMap.set(parent, parentFiles);
}
if (!ignoreFn?.(file, parent)) {
parentFiles.add(file);
}
const parentReason = reasons.get(parent);
if (parentReason?.parents) {
propagateToParents(parentReason.parents, file, seen);
}
}
}
}
for (const file of fileList!) {
const reason = reasons!.get(file);
const isInitial =
reason?.type.length === 1 && reason.type.includes('initial');
if (
!reason ||
!reason.parents ||
(isInitial && reason.parents.size === 0)
) {
continue;
}
propagateToParents(reason.parents, file);
}
return parentFilesMap;
}
export const collectTracedFiles =
(
baseDir: string,
lstatResults: { [key: string]: ReturnType<typeof lstat> },
lstatSema: Sema,
reasons: NodeFileTraceReasons,
files: { [filePath: string]: FileFsRef }
) =>
async (file: string) => {
const reason = reasons.get(file);
if (reason && reason.type.includes('initial')) {
// Initial files are manually added to the lambda later
return;
}
const filePath = path.join(baseDir, file);
if (!lstatResults[filePath]) {
lstatResults[filePath] = lstatSema
.acquire()
.then(() => lstat(filePath))
.finally(() => lstatSema.release());
}
const { mode } = await lstatResults[filePath];
files[file] = new FileFsRef({
fsPath: path.join(baseDir, file),
mode,
});
};
export const ExperimentalTraceVersion = `9.0.4-canary.1`;
export type PseudoLayer = {
[fileName: string]: PseudoFile | PseudoSymbolicLink;
};
export type PseudoFile = {
file: FileFsRef;
isSymlink: false;
crc32: number;
uncompressedSize: number;
};
export type PseudoSymbolicLink = {
file: FileFsRef;
isSymlink: true;
symlinkTarget: string;
};
export type PseudoLayerResult = {
pseudoLayer: PseudoLayer;
pseudoLayerBytes: number;
};
export async function createPseudoLayer(files: {
[fileName: string]: FileFsRef;
}): Promise<PseudoLayerResult> {
const pseudoLayer: PseudoLayer = {};
let pseudoLayerBytes = 0;
for (const fileName of Object.keys(files)) {
const file = files[fileName];
if (isSymbolicLink(file.mode)) {
const symlinkTarget = await fs.readlink(file.fsPath);
pseudoLayer[fileName] = {
file,
isSymlink: true,
symlinkTarget,
};
} else {
const origBuffer = await streamToBuffer(file.toStream());
pseudoLayerBytes += origBuffer.byteLength;
pseudoLayer[fileName] = {
file,
isSymlink: false,
crc32: crc32.unsigned(origBuffer),
uncompressedSize: origBuffer.byteLength,
};
}
}
return { pseudoLayer, pseudoLayerBytes };
}
export interface CreateLambdaFromPseudoLayersOptions
extends LambdaOptionsWithFiles {
layers: PseudoLayer[];
isStreaming?: boolean;
nextVersion?: string;
experimentalAllowBundling?: boolean;
}
// measured with 1, 2, 5, 10, and `os.cpus().length || 5`
// and sema(1) produced the best results
const createLambdaSema = new Sema(1);
export async function createLambdaFromPseudoLayers({
files: baseFiles,
layers,
isStreaming,
nextVersion,
experimentalAllowBundling,
...lambdaOptions
}: CreateLambdaFromPseudoLayersOptions) {
await createLambdaSema.acquire();
const files: Files = {};
const addedFiles = new Set();
// Add files from pseudo layers
for (const layer of layers) {
for (const seedKey of Object.keys(layer)) {
if (addedFiles.has(seedKey)) {
// File was already added in a previous pseudo layer
continue;
}
const item = layer[seedKey];
files[seedKey] = item.file;
addedFiles.add(seedKey);
}
}
for (const fileName of Object.keys(baseFiles)) {
if (addedFiles.has(fileName)) {
// File was already added in a previous pseudo layer
continue;
}
const file = baseFiles[fileName];
files[fileName] = file;
addedFiles.add(fileName);
}
createLambdaSema.release();
return new NodejsLambda({
...lambdaOptions,
...(isStreaming
? {
supportsResponseStreaming: true,
}
: {}),
files,
shouldAddHelpers: false,
shouldAddSourcemapSupport: false,
supportsMultiPayloads: true,
framework: {
slug: 'nextjs',
version: nextVersion,
},
experimentalAllowBundling,
});
}
export type NextRequiredServerFilesManifest = {
appDir?: string;
relativeAppDir?: string;
files: string[];
ignore: string[];
config: Record<string, any>;
};
export type NextPrerenderedRoutes = {
bypassToken: string | null;
staticRoutes: {
[route: string]: {
initialRevalidate: number | false;
dataRoute: string | null;
prefetchDataRoute?: string | null;
srcRoute: string | null;
initialStatus?: number;
initialHeaders?: Record<string, string>;
experimentalBypassFor?: HasField;
experimentalPPR?: boolean;
};
};
blockingFallbackRoutes: {
[route: string]: {
routeRegex: string;
dataRoute: string | null;
dataRouteRegex: string | null;
prefetchDataRoute?: string | null;
prefetchDataRouteRegex?: string | null;
experimentalBypassFor?: HasField;
experimentalPPR?: boolean;
};
};
fallbackRoutes: {
[route: string]: {
fallback: string;
routeRegex: string;
dataRoute: string | null;
dataRouteRegex: string | null;
prefetchDataRoute?: string | null;
prefetchDataRouteRegex?: string | null;
experimentalBypassFor?: HasField;
experimentalPPR?: boolean;
};
};
/**
* Routes that have their fallback behavior is disabled. All routes would've
* been provided in the top-level `routes` key (`staticRoutes`).
*/
omittedRoutes: {
[route: string]: {
routeRegex: string;
dataRoute: string | null;
dataRouteRegex: string | null;
prefetchDataRoute: string | null | undefined;
prefetchDataRouteRegex: string | null | undefined;
experimentalBypassFor?: HasField;
experimentalPPR?: boolean;
};
};
notFoundRoutes: string[];
isLocalePrefixed: boolean;
};
export async function getExportIntent(
entryPath: string
): Promise<false | { trailingSlash: boolean }> {
const pathExportMarker = path.join(entryPath, '.next', 'export-marker.json');
const hasExportMarker: boolean = await fs
.access(pathExportMarker, fs.constants.F_OK)
.then(() => true)
.catch(() => false);
if (!hasExportMarker) {
return false;
}
const manifest: {
version: 1;
exportTrailingSlash: boolean;
hasExportPathMap: boolean;
} = JSON.parse(await fs.readFile(pathExportMarker, 'utf8'));
switch (manifest.version) {
case 1: {
if (manifest.hasExportPathMap !== true) {
return false;
}
return { trailingSlash: manifest.exportTrailingSlash };
}
default: {
return false;
}
}
}
export async function getExportStatus(
entryPath: string
): Promise<false | { success: boolean; outDirectory: string }> {
const pathExportDetail = path.join(entryPath, '.next', 'export-detail.json');
const hasExportDetail: boolean = await fs
.access(pathExportDetail, fs.constants.F_OK)
.then(() => true)
.catch(() => false);
if (!hasExportDetail) {
return false;
}
const manifest: {
version: 1;
success: boolean;
outDirectory: string;
} = JSON.parse(await fs.readFile(pathExportDetail, 'utf8'));
switch (manifest.version) {
case 1: {
return {
success: !!manifest.success,
outDirectory: manifest.outDirectory,
};
}
default: {
return false;
}
}
}
export async function getRequiredServerFilesManifest(
entryPath: string,
outputDirectory: string
): Promise<NextRequiredServerFilesManifest | false> {
const pathRequiredServerFilesManifest = path.join(
entryPath,
outputDirectory,
'required-server-files.json'
);
const hasManifest: boolean = await fs
.access(pathRequiredServerFilesManifest, fs.constants.F_OK)
.then(() => true)
.catch(() => false);
if (!hasManifest) {
return false;
}
const manifestData = JSON.parse(
await fs.readFile(pathRequiredServerFilesManifest, 'utf8')
);
const requiredServerFiles = {
files: [],
ignore: [],
config: {},
appDir: manifestData.appDir,
relativeAppDir: manifestData.relativeAppDir,
};
switch (manifestData.version) {
case 1: {
requiredServerFiles.files = manifestData.files;
requiredServerFiles.ignore = manifestData.ignore;
requiredServerFiles.config = manifestData.config;
requiredServerFiles.appDir = manifestData.appDir;
break;
}
default: {
throw new Error(
`Invalid required-server-files manifest version ${manifestData.version}, please contact support if this error persists`
);
}
}
return requiredServerFiles;
}
export async function getPrerenderManifest(
entryPath: string,
outputDirectory: string
): Promise<NextPrerenderedRoutes> {
const pathPrerenderManifest = path.join(
entryPath,
outputDirectory,
'prerender-manifest.json'
);
const hasManifest: boolean = await fs
.access(pathPrerenderManifest, fs.constants.F_OK)
.then(() => true)
.catch(() => false);
if (!hasManifest) {
return {
staticRoutes: {},
blockingFallbackRoutes: {},
fallbackRoutes: {},
bypassToken: null,
omittedRoutes: {},
notFoundRoutes: [],
isLocalePrefixed: false,
};
}
const manifest:
| {
version: 1;
routes: {
[key: string]: {
initialRevalidateSeconds: number | false;
dataRoute: string;
srcRoute: string | null;
};
};
dynamicRoutes: {
[key: string]: {
fallback?: string;
routeRegex: string;
dataRoute: string;
dataRouteRegex: string;
};
};
preview?: {
previewModeId: string;
};
}
| {
version: 2 | 3;
routes: {
[route: string]: {
initialRevalidateSeconds: number | false;
srcRoute: string | null;
dataRoute: string;
};
};
dynamicRoutes: {
[route: string]: {
routeRegex: string;
fallback: string | false;
dataRoute: string;
dataRouteRegex: string;
};
};
preview: {
previewModeId: string;
};
notFoundRoutes?: string[];
}
| {
version: 4;
routes: {
[route: string]: {
initialRevalidateSeconds: number | false;
srcRoute: string | null;
dataRoute: string | null;
prefetchDataRoute: string | null | undefined;
initialStatus?: number;
initialHeaders?: Record<string, string>;
experimentalBypassFor?: HasField;
experimentalPPR?: boolean;
};
};
dynamicRoutes: {
[route: string]: {
routeRegex: string;
fallback: string | false;
dataRoute: string | null;
dataRouteRegex: string | null;
prefetchDataRoute: string | null | undefined;
prefetchDataRouteRegex: string | null | undefined;
experimentalBypassFor?: HasField;
experimentalPPR?: boolean;
};
};
preview: {
previewModeId: string;
};
notFoundRoutes?: string[];
} = JSON.parse(await fs.readFile(pathPrerenderManifest, 'utf8'));
switch (manifest.version) {
case 1: {
const routes = Object.keys(manifest.routes);
const lazyRoutes = Object.keys(manifest.dynamicRoutes);
const ret: NextPrerenderedRoutes = {
staticRoutes: {},
blockingFallbackRoutes: {},
fallbackRoutes: {},
bypassToken:
(manifest.preview && manifest.preview.previewModeId) || null,
omittedRoutes: {},
notFoundRoutes: [],
isLocalePrefixed: false,
};
routes.forEach(route => {
const { initialRevalidateSeconds, dataRoute, srcRoute } =
manifest.routes[route];
ret.staticRoutes[route] = {
initialRevalidate:
initialRevalidateSeconds === false
? false
: Math.max(1, initialRevalidateSeconds),
dataRoute,
srcRoute,
};
});
lazyRoutes.forEach(lazyRoute => {
const { routeRegex, fallback, dataRoute, dataRouteRegex } =
manifest.dynamicRoutes[lazyRoute];
if (fallback) {
ret.fallbackRoutes[lazyRoute] = {
routeRegex,
fallback,
dataRoute,
dataRouteRegex,
};
} else {
ret.blockingFallbackRoutes[lazyRoute] = {
routeRegex,
dataRoute,
dataRouteRegex,
};
}
});
return ret;
}
case 2:
case 3:
case 4: {
const routes = Object.keys(manifest.routes);
const lazyRoutes = Object.keys(manifest.dynamicRoutes);
const ret: NextPrerenderedRoutes = {
staticRoutes: {},
blockingFallbackRoutes: {},
fallbackRoutes: {},
bypassToken: manifest.preview.previewModeId,
omittedRoutes: {},
notFoundRoutes: [],
isLocalePrefixed: manifest.version > 2,
};
if (manifest.notFoundRoutes) {
ret.notFoundRoutes.push(...manifest.notFoundRoutes);
}
routes.forEach(route => {
const { initialRevalidateSeconds, dataRoute, srcRoute } =
manifest.routes[route];
let initialStatus: undefined | number;
let initialHeaders: undefined | Record<string, string>;
let experimentalBypassFor: undefined | HasField;
let experimentalPPR: undefined | boolean;
let prefetchDataRoute: undefined | string | null;
if (manifest.version === 4) {
initialStatus = manifest.routes[route].initialStatus;
initialHeaders = manifest.routes[route].initialHeaders;
experimentalBypassFor = manifest.routes[route].experimentalBypassFor;
experimentalPPR = manifest.routes[route].experimentalPPR;
prefetchDataRoute = manifest.routes[route].prefetchDataRoute;
}
ret.staticRoutes[route] = {
initialRevalidate:
initialRevalidateSeconds === false
? false
: Math.max(1, initialRevalidateSeconds),
dataRoute,
prefetchDataRoute,
srcRoute,
initialStatus,
initialHeaders,
experimentalBypassFor,
experimentalPPR,
};
});
lazyRoutes.forEach(lazyRoute => {
const { routeRegex, fallback, dataRoute, dataRouteRegex } =
manifest.dynamicRoutes[lazyRoute];
let experimentalBypassFor: undefined | HasField;
let experimentalPPR: undefined | boolean;
let prefetchDataRoute: undefined | string | null;
let prefetchDataRouteRegex: undefined | string | null;
if (manifest.version === 4) {
experimentalBypassFor =
manifest.dynamicRoutes[lazyRoute].experimentalBypassFor;
experimentalPPR = manifest.dynamicRoutes[lazyRoute].experimentalPPR;
prefetchDataRoute =
manifest.dynamicRoutes[lazyRoute].prefetchDataRoute;
prefetchDataRouteRegex =
manifest.dynamicRoutes[lazyRoute].prefetchDataRouteRegex;
}
if (typeof fallback === 'string') {
ret.fallbackRoutes[lazyRoute] = {
experimentalBypassFor,
experimentalPPR,
routeRegex,
fallback,
dataRoute,
dataRouteRegex,
prefetchDataRoute,
prefetchDataRouteRegex,
};
} else if (fallback === null) {
ret.blockingFallbackRoutes[lazyRoute] = {
experimentalBypassFor,
experimentalPPR,
routeRegex,
dataRoute,
dataRouteRegex,
prefetchDataRoute,
prefetchDataRouteRegex,
};
} else {
ret.omittedRoutes[lazyRoute] = {
experimentalBypassFor,
experimentalPPR,
routeRegex,
dataRoute,
dataRouteRegex,
prefetchDataRoute,
prefetchDataRouteRegex,
};
}
});
return ret;
}
default: {
return {
staticRoutes: {},
blockingFallbackRoutes: {},
fallbackRoutes: {},
bypassToken: null,
omittedRoutes: {},
notFoundRoutes: [],
isLocalePrefixed: false,
};
}
}
}
// We only need this once per build
let _usesSrcCache: boolean | undefined;
async function usesSrcDirectory(workPath: string): Promise<boolean> {
if (!_usesSrcCache) {
const sourcePages = path.join(workPath, 'src', 'pages');
try {
if ((await fs.stat(sourcePages)).isDirectory()) {
_usesSrcCache = true;
}
} catch (_err) {
_usesSrcCache = false;
}
}
if (!_usesSrcCache) {
const sourceAppdir = path.join(workPath, 'src', 'app');
try {
if ((await fs.stat(sourceAppdir)).isDirectory()) {
_usesSrcCache = true;
}
} catch (_err) {
_usesSrcCache = false;
}
}
return Boolean(_usesSrcCache);
}
async function getSourceFilePathFromPage({
workPath,
page,
pageExtensions,
}: {
workPath: string;
page: string;
pageExtensions?: ReadonlyArray<string>;
}) {
const usesSrcDir = await usesSrcDirectory(workPath);
const extensionsToTry = pageExtensions || ['js', 'jsx', 'ts', 'tsx'];
for (const pageType of ['pages', 'app']) {
let fsPath = path.join(workPath, pageType, page);
if (usesSrcDir) {
fsPath = path.join(workPath, 'src', pageType, page);
}
if (fs.existsSync(fsPath)) {
return path.relative(workPath, fsPath);
}
const extensionless = fsPath.replace(path.extname(fsPath), '');
for (const ext of extensionsToTry) {
fsPath = `${extensionless}.${ext}`;
// for appDir, we need to treat "index.js" as root-level "page.js"
if (
pageType === 'app' &&
extensionless ===
path.join(workPath, `${usesSrcDir ? 'src/' : ''}app/index`)
) {
fsPath = `${extensionless.replace(/index$/, 'page')}.${ext}`;
}
if (fs.existsSync(fsPath)) {
return path.relative(workPath, fsPath);
}
}
if (isDirectory(extensionless)) {
if (pageType === 'pages') {
for (const ext of extensionsToTry) {
fsPath = path.join(extensionless, `index.${ext}`);
if (fs.existsSync(fsPath)) {
return path.relative(workPath, fsPath);
}
}
// appDir
} else {
for (const ext of extensionsToTry) {
// RSC
fsPath = path.join(extensionless, `page.${ext}`);
if (fs.existsSync(fsPath)) {
return path.relative(workPath, fsPath);
}
// Route Handlers
fsPath = path.join(extensionless, `route.${ext}`);
if (fs.existsSync(fsPath)) {
return path.relative(workPath, fsPath);
}
}
}
}
}
// if we got here, and didn't find a source not-found file, then it was the one injected
// by Next.js. There's no need to warn or return a source file in this case, as it won't have
// any configuration applied to it.
if (page === '/_not-found/page') {
return '';
}
console.log(
`WARNING: Unable to find source file for page ${page} with extensions: ${extensionsToTry.join(
', '
)}, this can cause functions config from \`vercel.json\` to not be applied`
);
return '';
}
function isDirectory(path: string) {
return fs.existsSync(path) && fs.lstatSync(path).isDirectory();
}
export function normalizeLocalePath(
pathname: string,
locales?: string[]
): {
detectedLocale?: string;
pathname: string;
} {
let detectedLocale: string | undefined;
// first item will be empty string from splitting at first char
const pathnameParts = pathname.split('/');
(locales || []).some(locale => {
if (pathnameParts[1].toLowerCase() === locale.toLowerCase()) {
detectedLocale = locale;
pathnameParts.splice(1, 1);
pathname = pathnameParts.join('/') || '/';
return true;
}
return false;
});
return {
pathname,
detectedLocale,
};
}
export function addLocaleOrDefault(
pathname: string,
routesManifest?: RoutesManifest,
locale?: string
) {
if (!routesManifest?.i18n) return pathname;
if (!locale) locale = routesManifest.i18n.defaultLocale;
return locale
? `/${locale}${pathname === '/index' ? '' : pathname}`
: pathname;
}
export type LambdaGroup = {
pages: string[];
memory?: number;
maxDuration?: number;
isAppRouter?: boolean;
isAppRouteHandler?: boolean;
isStreaming?: boolean;
readonly isPrerenders: boolean;
readonly isExperimentalPPR: boolean;
isActionLambda?: boolean;
isPages?: boolean;
isApiLambda: boolean;
pseudoLayer: PseudoLayer;
pseudoLayerBytes: number;
pseudoLayerUncompressedBytes: number;
};
export async function getPageLambdaGroups({
entryPath,
config,
functionsConfigManifest,
pages,
prerenderRoutes,
experimentalPPRRoutes,
pageTraces,
compressedPages,
tracedPseudoLayer,
initialPseudoLayer,
initialPseudoLayerUncompressed,
internalPages,
pageExtensions,
inversedAppPathManifest,
experimentalAllowBundling,
}: {
entryPath: string;
config: Config;
functionsConfigManifest?: FunctionsConfigManifestV1;
pages: ReadonlyArray<string>;
prerenderRoutes: ReadonlySet<string>;
experimentalPPRRoutes: ReadonlySet<string> | undefined;
pageTraces: {
[page: string]: {
[key: string]: FileFsRef;
};
};
compressedPages: {
[page: string]: PseudoFile;
};
tracedPseudoLayer: PseudoLayer;
initialPseudoLayer: PseudoLayerResult;
initialPseudoLayerUncompressed: number;
internalPages: ReadonlyArray<string>;
pageExtensions?: ReadonlyArray<string>;
inversedAppPathManifest?: Record<string, string>;
experimentalAllowBundling?: boolean;
}) {
const groups: Array<LambdaGroup> = [];
for (const page of pages) {
const newPages = [...internalPages, page];
const routeName = normalizePage(page.replace(/\.js$/, ''));
const isPrerenderRoute = prerenderRoutes.has(routeName);
const isExperimentalPPR = experimentalPPRRoutes?.has(routeName) ?? false;
let opts: { memory?: number; maxDuration?: number } = {};
if (
functionsConfigManifest &&
functionsConfigManifest.functions[routeName]
) {
opts = functionsConfigManifest.functions[routeName];
}
if (config && config.functions) {
const sourceFile = await getSourceFilePathFromPage({
workPath: entryPath,
page: normalizeSourceFilePageFromManifest(
routeName,
page,
inversedAppPathManifest
),
pageExtensions,
});
const vercelConfigOpts = await getLambdaOptionsFromFunction({
sourceFile,
config,
});
opts = { ...vercelConfigOpts, ...opts };
}
let matchingGroup = experimentalAllowBundling
? undefined
: groups.find(group => {
const matches =
group.maxDuration === opts.maxDuration &&
group.memory === opts.memory &&
group.isPrerenders === isPrerenderRoute &&
group.isExperimentalPPR === isExperimentalPPR;
if (matches) {
let newTracedFilesUncompressedSize =
group.pseudoLayerUncompressedBytes;
for (const newPage of newPages) {
Object.keys(pageTraces[newPage] || {}).map(file => {
if (!group.pseudoLayer[file]) {
const item = tracedPseudoLayer[file] as PseudoFile;
newTracedFilesUncompressedSize += item.uncompressedSize || 0;
}
});
newTracedFilesUncompressedSize +=
compressedPages[newPage].uncompressedSize;
}
const underUncompressedLimit =
newTracedFilesUncompressedSize <
MAX_UNCOMPRESSED_LAMBDA_SIZE - LAMBDA_RESERVED_UNCOMPRESSED_SIZE;
return underUncompressedLimit;
}
return false;
});
if (matchingGroup) {
matchingGroup.pages.push(page);
} else {
const newGroup: LambdaGroup = {
pages: [page],
...opts,
isPrerenders: isPrerenderRoute,
isExperimentalPPR,
isApiLambda: !!isApiPage(page),
pseudoLayerBytes: initialPseudoLayer.pseudoLayerBytes,
pseudoLayerUncompressedBytes: initialPseudoLayerUncompressed,
pseudoLayer: Object.assign({}, initialPseudoLayer.pseudoLayer),
};
groups.push(newGroup);
matchingGroup = newGroup;
}
for (const newPage of newPages) {
Object.keys(pageTraces[newPage] || {}).map(file => {
const pseudoItem = tracedPseudoLayer[file] as PseudoFile;
if (!matchingGroup!.pseudoLayer[file]) {
matchingGroup!.pseudoLayer[file] = pseudoItem;
matchingGroup!.pseudoLayerUncompressedBytes +=
pseudoItem.uncompressedSize || 0;
}
});
// ensure the page file itself is accounted for when grouping as
// large pages can be created that can push the group over the limit
matchingGroup!.pseudoLayerUncompressedBytes +=
compressedPages[newPage].uncompressedSize;
}
}
return groups;
}
// `pages` are normalized without route groups (e.g., /app/(group)/page.js).
// we keep track of that mapping in `inversedAppPathManifest`
// `getSourceFilePathFromPage` needs to use the path from source to properly match the config
function normalizeSourceFilePageFromManifest(
routeName: string,
page: string,
inversedAppPathManifest?: Record<string, string>
) {
const pageFromManifest = inversedAppPathManifest?.[routeName];
if (!pageFromManifest) {
// since this function is used by both `pages` and `app`, the manifest might not be provided
// so fallback to normal behavior of just checking the `page`.
return page;
}
const metadataConventions = [
'/favicon.',
'/icon.',
'/apple-icon.',
'/opengraph-image.',
'/twitter-image.',
'/sitemap.',
'/robots.',
];
// these special metadata files for will not contain `/route` or `/page` suffix, so return the routeName as-is.
const isSpecialFile = metadataConventions.some(convention =>
routeName.startsWith(convention)
);
if (isSpecialFile) {
return routeName;
}
return pageFromManifest;
}
export const outputFunctionFileSizeInfo = (
pages: string[],
pseudoLayer: PseudoLayer,
pseudoLayerUncompressedBytes: number,
compressedPages: {
[page: string]: PseudoFile;
}
) => {
const exceededLimitOutput: Array<string[]> = [];
console.log(
`Serverless Function's page${pages.length === 1 ? '' : 's'}: ${pages.join(
', '
)}`
);
exceededLimitOutput.push(['Large Dependencies', 'Uncompressed size']);
const dependencies: {
[key: string]: {
uncompressed: number;
};
} = {};
for (const fileKey of Object.keys(pseudoLayer)) {
if (!pseudoLayer[fileKey].isSymlink) {
const fileItem = pseudoLayer[fileKey] as PseudoFile;
const depKey = fileKey.split('/').slice(0, 3).join('/');
if (!dependencies[depKey]) {
dependencies[depKey] = {
uncompressed: 0,
};
}
dependencies[depKey].uncompressed += fileItem.uncompressedSize;
}
}
for (const page of pages) {
dependencies[`pages/${page}`] = {
uncompressed: compressedPages[page].uncompressedSize,
};
}
let numLargeDependencies = 0;
Object.keys(dependencies)
.sort((a, b) => {
// move largest dependencies to the top
const aDep = dependencies[a];
const bDep = dependencies[b];
if (aDep.uncompressed > bDep.uncompressed) {
return -1;
}
if (aDep.uncompressed < bDep.uncompressed) {
return 1;
}
return 0;
})
.forEach(depKey => {
const dep = dependencies[depKey];
if (dep.uncompressed < 500 * KIB) {
// ignore smaller dependencies to reduce noise
return;
}
exceededLimitOutput.push([depKey, prettyBytes(dep.uncompressed)]);
numLargeDependencies += 1;
});
if (numLargeDependencies === 0) {
exceededLimitOutput.push([
'No large dependencies found (> 500KB compressed)',
]);
}
exceededLimitOutput.push([]);
exceededLimitOutput.push([
'All dependencies',
prettyBytes(pseudoLayerUncompressedBytes),
]);
console.log(
textTable(exceededLimitOutput, {
align: ['l', 'r'],
})
);
};
export const detectLambdaLimitExceeding = async (
lambdaGroups: LambdaGroup[],
compressedPages: {
[page: string]: PseudoFile;
}
) => {
// show debug info if within 5 MB of exceeding the limit
const UNCOMPRESSED_SIZE_LIMIT_CLOSE = MAX_UNCOMPRESSED_LAMBDA_SIZE - 5 * MIB;
let numExceededLimit = 0;
let numCloseToLimit = 0;
let loggedHeadInfo = false;
// pre-iterate to see if we are going to exceed the limit
// or only get close so our first log line can be correct
const filteredGroups = lambdaGroups.filter(group => {
const exceededLimit =
group.pseudoLayerUncompressedBytes > MAX_UNCOMPRESSED_LAMBDA_SIZE;
const closeToLimit =
group.pseudoLayerUncompressedBytes > UNCOMPRESSED_SIZE_LIMIT_CLOSE;
if (
closeToLimit ||
exceededLimit ||
getPlatformEnv('BUILDER_DEBUG') ||
process.env.NEXT_DEBUG_FUNCTION_SIZE
) {
if (exceededLimit) {
numExceededLimit += 1;
}
if (closeToLimit) {
numCloseToLimit += 1;
}
return true;
}
});
for (const group of filteredGroups) {
if (!loggedHeadInfo) {
if (numExceededLimit || numCloseToLimit) {
console.log(
`Warning: Max serverless function size of ${prettyBytes(
MAX_UNCOMPRESSED_LAMBDA_SIZE
)} uncompressed${numExceededLimit ? '' : ' almost'} reached`
);
} else {
console.log(`Serverless function size info`);
}
loggedHeadInfo = true;
}
outputFunctionFileSizeInfo(
group.pages,
group.pseudoLayer,
group.pseudoLayerUncompressedBytes,
compressedPages
);
}
if (numExceededLimit) {
console.log(
`Max serverless function size was exceeded for ${numExceededLimit} function${
numExceededLimit === 1 ? '' : 's'
}`
);
}
};
// checks if prerender files are all static or not before creating lambdas
export const onPrerenderRouteInitial = (
prerenderManifest: NextPrerenderedRoutes,
canUsePreviewMode: boolean,
entryDirectory: string,
nonLambdaSsgPages: Set<string>,
routeKey: string,
hasPages404: boolean,
routesManifest?: RoutesManifest,
appDir?: string | null
) => {
let static404Page: string | undefined;
let static500Page: string | undefined;
// Get the route file as it'd be mounted in the builder output
const pr = prerenderManifest.staticRoutes[routeKey];
const { initialRevalidate, srcRoute, dataRoute } = pr;
const route = srcRoute || routeKey;
const isAppPathRoute = appDir && (!dataRoute || dataRoute?.endsWith('.rsc'));
const routeNoLocale = routesManifest?.i18n
? normalizeLocalePath(routeKey, routesManifest.i18n.locales).pathname
: routeKey;
// if the 404 page used getStaticProps we need to update static404Page
// since it wasn't populated from the staticPages group
if (routeNoLocale === '/404') {
static404Page = path.posix.join(entryDirectory, routeKey);
}
if (routeNoLocale === '/500') {
static500Page = path.posix.join(entryDirectory, routeKey);
}
if (
// App paths must be Prerenders to ensure Vary header is
// correctly added
!isAppPathRoute &&
initialRevalidate === false &&
(!canUsePreviewMode || (hasPages404 && routeNoLocale === '/404')) &&
!prerenderManifest.fallbackRoutes[route] &&
!prerenderManifest.blockingFallbackRoutes[route]
) {
if (
routesManifest?.i18n &&
Object.keys(prerenderManifest.staticRoutes).some(route => {
const staticRoute = prerenderManifest.staticRoutes[route];
return (
staticRoute.srcRoute === srcRoute &&
staticRoute.initialRevalidate !== false
);
})
) {
// if any locale static routes are using revalidate the page
// requires a lambda
return {
static404Page,
static500Page,
};
}
nonLambdaSsgPages.add(route === '/' ? '/index' : route);
}
return {
static404Page,
static500Page,
};
};
type OnPrerenderRouteArgs = {
appDir: string | null;
pagesDir: string;
localePrefixed404?: boolean;
static404Page?: string;
hasPages404: boolean;
entryDirectory: string;
appPathRoutesManifest?: Record<string, string>;
prerenderManifest: NextPrerenderedRoutes;
isSharedLambdas: boolean;
isServerMode: boolean;
canUsePreviewMode: boolean;
lambdas: { [key: string]: Lambda };
experimentalStreamingLambdaPaths: ReadonlyMap<string, string> | undefined;
prerenders: { [key: string]: Prerender | File };
pageLambdaMap: { [key: string]: string };
routesManifest?: RoutesManifest;
isCorrectNotFoundRoutes?: boolean;
isEmptyAllowQueryForPrendered?: boolean;
isAppPPREnabled: boolean;
};
let prerenderGroup = 1;
export const onPrerenderRoute =
(prerenderRouteArgs: OnPrerenderRouteArgs) =>
async (
routeKey: string,
{
isBlocking,
isFallback,
isOmitted,
locale,
}: {
isBlocking?: boolean;
isFallback?: boolean;
isOmitted?: boolean;
locale?: string;
}
) => {
const {
appDir,
pagesDir,
static404Page,
localePrefixed404,
entryDirectory,
prerenderManifest,
isSharedLambdas,
isServerMode,
canUsePreviewMode,
lambdas,
experimentalStreamingLambdaPaths,
prerenders,
pageLambdaMap,
routesManifest,
isCorrectNotFoundRoutes,
isEmptyAllowQueryForPrendered,
isAppPPREnabled,
} = prerenderRouteArgs;
if (isBlocking && isFallback) {
throw new NowBuildError({
code: 'NEXT_ISBLOCKING_ISFALLBACK',
message: 'invariant: isBlocking and isFallback cannot both be true',
});
}
if (isFallback && isOmitted) {
throw new NowBuildError({
code: 'NEXT_ISOMITTED_ISFALLBACK',
message: 'invariant: isOmitted and isFallback cannot both be true',
});
}
// Get the route file as it'd be mounted in the builder output
let routeFileNoExt = routeKey === '/' ? '/index' : routeKey;
let origRouteFileNoExt = routeFileNoExt;
const { isLocalePrefixed } = prerenderManifest;
if (!locale && isLocalePrefixed) {
const localePathResult = normalizeLocalePath(
routeKey,
routesManifest?.i18n?.locales || []
);
locale = localePathResult.detectedLocale;
origRouteFileNoExt =
localePathResult.pathname === '/'
? '/index'
: localePathResult.pathname;
}
const nonDynamicSsg =
!isFallback &&
!isBlocking &&
!isOmitted &&
!prerenderManifest.staticRoutes[routeKey].srcRoute;
// if there isn't a srcRoute then it's a non-dynamic SSG page
if ((nonDynamicSsg && !isLocalePrefixed) || isFallback || isOmitted) {
routeFileNoExt = addLocaleOrDefault(
// root index files are located without folder/index.html
routeFileNoExt,
routesManifest,
locale
);
}
const isNotFound = prerenderManifest.notFoundRoutes.includes(routeKey);
let initialRevalidate: false | number;
let srcRoute: string | null;
let dataRoute: string | null;
let prefetchDataRoute: string | null | undefined;
let initialStatus: number | undefined;
let initialHeaders: Record<string, string> | undefined;
let experimentalBypassFor: HasField | undefined;
let experimentalPPR: boolean | undefined;
if (isFallback || isBlocking) {
const pr = isFallback
? prerenderManifest.fallbackRoutes[routeKey]
: prerenderManifest.blockingFallbackRoutes[routeKey];
initialRevalidate = 1; // TODO: should Next.js provide this default?
// @ts-ignore
if (initialRevalidate === false) {
// Lazy routes cannot be "snapshotted" in time.
throw new NowBuildError({
code: 'NEXT_ISLAZY_INITIALREVALIDATE',
message: 'invariant isLazy: initialRevalidate !== false',
});
}
srcRoute = null;
dataRoute = pr.dataRoute;
experimentalBypassFor = pr.experimentalBypassFor;
experimentalPPR = pr.experimentalPPR;
prefetchDataRoute = pr.prefetchDataRoute;
} else if (isOmitted) {
initialRevalidate = false;
srcRoute = routeKey;
dataRoute = prerenderManifest.omittedRoutes[routeKey].dataRoute;
experimentalBypassFor =
prerenderManifest.omittedRoutes[routeKey].experimentalBypassFor;
experimentalPPR =
prerenderManifest.omittedRoutes[routeKey].experimentalPPR;
prefetchDataRoute =
prerenderManifest.omittedRoutes[routeKey].prefetchDataRoute;
} else {
const pr = prerenderManifest.staticRoutes[routeKey];
({
initialRevalidate,
srcRoute,
dataRoute,
initialHeaders,
initialStatus,
experimentalBypassFor,
experimentalPPR,
prefetchDataRoute,
} = pr);
}
let isAppPathRoute = false;
// experimentalPPR signals app path route
if (appDir && experimentalPPR) {
isAppPathRoute = true;
}
// TODO: leverage manifest to determine app paths more accurately
if (appDir && srcRoute && (!dataRoute || dataRoute?.endsWith('.rsc'))) {
isAppPathRoute = true;
}
const isOmittedOrNotFound = isOmitted || isNotFound;
let htmlFsRef: File | null = null;
// If enabled, try to get the postponed route information from the file
// system and use it to assemble the prerender.
let prerender: string | undefined;
if (experimentalPPR && appDir) {
const htmlPath = path.join(appDir, `${routeFileNoExt}.html`);
const metaPath = path.join(appDir, `${routeFileNoExt}.meta`);
if (fs.existsSync(htmlPath) && fs.existsSync(metaPath)) {
const meta = JSON.parse(await fs.readFile(metaPath, 'utf8'));
if ('postponed' in meta && typeof meta.postponed === 'string') {
prerender = meta.postponed;
// Assign the headers Content-Type header to the prerendered type.
initialHeaders ??= {};
initialHeaders[
'content-type'
] = `application/x-nextjs-pre-render; state-length=${meta.postponed.length}`;
// Read the HTML file and append it to the prerendered content.
const html = await fs.readFileSync(htmlPath, 'utf8');
prerender += html;
}
}
if (!dataRoute?.endsWith('.rsc')) {
throw new Error(
`Invariant: unexpected output path for ${dataRoute} and PPR`
);
}
if (!prefetchDataRoute?.endsWith('.prefetch.rsc')) {
throw new Error(
`Invariant: unexpected output path for ${prefetchDataRoute} and PPR`
);
}
}
if (prerender) {
const contentType = initialHeaders?.['content-type'];
if (!contentType) {
throw new Error("Invariant: contentType can't be undefined");
}
// Assemble the prerendered file.
htmlFsRef = new FileBlob({ contentType, data: prerender });
} else if (
appDir &&
!dataRoute &&
!prefetchDataRoute &&
isAppPathRoute &&
!(isBlocking || isFallback)
) {
const contentType = initialHeaders?.['content-type'];
// If the route has a body file, use it as the fallback, otherwise it may
// not have an associated fallback. This could be the case for routes that
// have dynamic segments.
const fsPath = path.join(appDir, `${routeFileNoExt}.body`);
if (fs.existsSync(fsPath)) {
htmlFsRef = new FileFsRef({
fsPath,
contentType: contentType || 'text/html;charset=utf-8',
});
}
} else {
htmlFsRef =
isBlocking || (isNotFound && !static404Page)
? // Blocking pages do not have an HTML fallback
null
: new FileFsRef({
fsPath: path.join(
isAppPathRoute && !isOmittedOrNotFound && appDir
? appDir
: pagesDir,
isFallback
? // Fallback pages have a special file.
addLocaleOrDefault(
prerenderManifest.fallbackRoutes[routeKey].fallback,
routesManifest,
locale
)
: // Otherwise, the route itself should exist as a static HTML
// file.
`${
isOmittedOrNotFound
? localePrefixed404
? addLocaleOrDefault('/404', routesManifest, locale)
: '/404'
: routeFileNoExt
}.html`
),
});
}
const jsonFsRef =
// JSON data does not exist for fallback or blocking pages
isFallback || isBlocking || (isNotFound && !static404Page) || !dataRoute
? null
: new FileFsRef({
fsPath: path.join(
isAppPathRoute && !isOmittedOrNotFound && appDir
? appDir
: pagesDir,
`${
isOmittedOrNotFound
? localePrefixed404
? addLocaleOrDefault('/404.html', routesManifest, locale)
: '/404.html'
: isAppPathRoute
? prefetchDataRoute || dataRoute
: routeFileNoExt + '.json'
}`
),
});
if (isOmittedOrNotFound) {
initialStatus = 404;
}
let outputPathPage = path.posix.join(entryDirectory, routeFileNoExt);
if (!isAppPathRoute) {
outputPathPage = normalizeIndexOutput(outputPathPage, isServerMode);
}
const outputPathPageOrig = path.posix.join(
entryDirectory,
origRouteFileNoExt
);
let lambda: undefined | Lambda;
function normalizeDataRoute(route: string) {
let normalized = path.posix.join(entryDirectory, route);
if (nonDynamicSsg || isFallback || isOmitted) {
normalized = normalized.replace(
new RegExp(`${escapeStringRegexp(origRouteFileNoExt)}.json$`),
// ensure we escape "$" correctly while replacing as "$" is a special
// character, we need to do double escaping as first is for the initial
// replace on the routeFile and then the second on the outputPath
`${routeFileNoExt.replace(/\$/g, '$$$$')}.json`
);
}
return normalized;
}
let outputPathData: null | string = null;
if (dataRoute) {
outputPathData = normalizeDataRoute(dataRoute);
}
let outputPathPrefetchData: null | string = null;
if (prefetchDataRoute) {
if (!isAppPPREnabled) {
throw new Error(
"Invariant: prefetchDataRoute can't be set without PPR"
);
}
outputPathPrefetchData = normalizeDataRoute(prefetchDataRoute);
} else if (experimentalPPR) {
throw new Error('Invariant: expected to find prefetch data route PPR');
}
// When the prefetch data path is available, use it for the prerender,
// otherwise use the data path.
const outputPrerenderPathData = outputPathPrefetchData || outputPathData;
if (isSharedLambdas) {
const outputSrcPathPage = normalizeIndexOutput(
path.join(
'/',
srcRoute == null
? outputPathPageOrig
: path.posix.join(
entryDirectory,
srcRoute === '/' ? '/index' : srcRoute
)
),
isServerMode
);
const lambdaId = pageLambdaMap[outputSrcPathPage];
lambda = lambdas[lambdaId];
} else {
let outputSrcPathPage =
srcRoute == null
? outputPathPageOrig
: path.posix.join(
entryDirectory,
srcRoute === '/' ? '/index' : srcRoute
);
if (!isAppPathRoute) {
outputSrcPathPage = normalizeIndexOutput(
outputSrcPathPage,
isServerMode
);
}
lambda = lambdas[outputSrcPathPage];
}
if (!isAppPathRoute && !isNotFound && initialRevalidate === false) {
if (htmlFsRef == null || jsonFsRef == null) {
throw new NowBuildError({
code: 'NEXT_HTMLFSREF_JSONFSREF',
message: `invariant: htmlFsRef != null && jsonFsRef != null ${routeFileNoExt}`,
});
}
// if preview mode/On-Demand ISR can't be leveraged
// we can output pure static outputs instead of prerenders
if (
!canUsePreviewMode ||
(routeKey === '/404' && !lambdas[outputPathPage])
) {
htmlFsRef.contentType = htmlContentType;
prerenders[outputPathPage] = htmlFsRef;
if (outputPrerenderPathData) {
prerenders[outputPrerenderPathData] = jsonFsRef;
}
}
}
const isNotFoundPreview =
isCorrectNotFoundRoutes &&
!initialRevalidate &&
canUsePreviewMode &&
isServerMode &&
isNotFound;
if (
prerenders[outputPathPage] == null &&
(!isNotFound || initialRevalidate || isNotFoundPreview)
) {
if (lambda == null) {
throw new NowBuildError({
code: 'NEXT_MISSING_LAMBDA',
message: `Unable to find lambda for route: ${routeFileNoExt}`,
});
}
// `allowQuery` is an array of query parameter keys that are allowed for
// a given path. All other query keys will be striped. We can automatically
// detect this for prerender (ISR) pages by reading the routes manifest file.
const pageKey = srcRoute || routeKey;
const route = routesManifest?.dynamicRoutes.find(
(r): r is RoutesManifestRoute =>
r.page === pageKey && !('isMiddleware' in r)
) as RoutesManifestRoute | undefined;
const routeKeys = route?.routeKeys;
// by default allowQuery should be undefined and only set when
// we have sufficient information to set it
let allowQuery: string[] | undefined;
if (isEmptyAllowQueryForPrendered) {
const isDynamic = isDynamicRoute(routeKey);
if (!isDynamic) {
// 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);
}
} else {
const isDynamic = isDynamicRoute(pageKey);
if (routeKeys) {
// if we have routeKeys in the routes-manifest we use those
// for allowQuery for dynamic routes
allowQuery = Object.values(routeKeys);
} else if (!isDynamic) {
// for non-dynamic routes we use an empty array since
// no query values bust the cache for non-dynamic prerenders
allowQuery = [];
}
}
const rscEnabled = !!routesManifest?.rsc;
const rscVaryHeader =
routesManifest?.rsc?.varyHeader ||
'RSC, Next-Router-State-Tree, Next-Router-Prefetch';
const rscContentTypeHeader =
routesManifest?.rsc?.contentTypeHeader || RSC_CONTENT_TYPE;
const rscDidPostponeHeader = routesManifest?.rsc?.didPostponeHeader;
let sourcePath: string | undefined;
if (`/${outputPathPage}` !== srcRoute && srcRoute) {
sourcePath = srcRoute;
}
let experimentalStreamingLambdaPath: string | undefined;
if (experimentalPPR) {
if (!experimentalStreamingLambdaPaths) {
throw new Error(
"Invariant: experimentalStreamingLambdaPaths doesn't exist"
);
}
// Try to get the experimental streaming lambda path for the specific
// static route first, then try the srcRoute if it doesn't exist. If we
// can't find it at all, this constitutes an error.
experimentalStreamingLambdaPath = experimentalStreamingLambdaPaths.get(
pathnameToOutputName(entryDirectory, routeKey)
);
if (!experimentalStreamingLambdaPath && srcRoute) {
experimentalStreamingLambdaPath =
experimentalStreamingLambdaPaths.get(
pathnameToOutputName(entryDirectory, srcRoute)
);
}
if (!experimentalStreamingLambdaPath) {
throw new Error(
`Invariant: experimentalStreamingLambdaPath is undefined for routeKey=${routeKey} and srcRoute=${
srcRoute ?? 'null'
}`
);
}
}
prerenders[outputPathPage] = new Prerender({
expiration: initialRevalidate,
lambda,
allowQuery,
fallback: htmlFsRef,
group: prerenderGroup,
bypassToken: prerenderManifest.bypassToken,
experimentalBypassFor,
initialStatus,
initialHeaders,
sourcePath,
experimentalStreamingLambdaPath,
...(isNotFound
? {
initialStatus: 404,
}
: {}),
...(rscEnabled
? {
initialHeaders: {
...initialHeaders,
vary: rscVaryHeader,
},
}
: {}),
});
if (outputPrerenderPathData) {
let normalizedPathData = outputPrerenderPathData;
if (
(srcRoute === '/' || srcRoute == '/index') &&
outputPrerenderPathData.endsWith(RSC_PREFETCH_SUFFIX)
) {
delete lambdas[normalizedPathData];
normalizedPathData = normalizedPathData.replace(
/([^/]+\.prefetch\.rsc)$/,
'__$1'
);
}
prerenders[normalizedPathData] = new Prerender({
expiration: initialRevalidate,
lambda,
allowQuery,
fallback: jsonFsRef,
group: prerenderGroup,
bypassToken: prerenderManifest.bypassToken,
experimentalBypassFor,
...(isNotFound
? {
initialStatus: 404,
}
: {}),
...(rscEnabled
? {
initialHeaders: {
...initialHeaders,
'content-type': rscContentTypeHeader,
vary: rscVaryHeader,
// If it contains a pre-render, then it was postponed.
...(prerender && rscDidPostponeHeader
? { [rscDidPostponeHeader]: '1' }
: {}),
},
}
: {}),
});
}
// we need to ensure all prerenders have a matching .rsc output
// otherwise routing could fall through unexpectedly for the
// fallback: false case as it doesn't have a dynamic route
// to catch the `.rsc` request for app -> pages routing
if (outputPrerenderPathData?.endsWith('.json') && appDir) {
const dummyOutput = new FileBlob({
data: '{}',
contentType: 'application/json',
});
const rscKey = `${outputPathPage}.rsc`;
const prefetchRscKey = `${outputPathPage}${RSC_PREFETCH_SUFFIX}`;
prerenders[rscKey] = dummyOutput;
prerenders[prefetchRscKey] = dummyOutput;
}
++prerenderGroup;
if (routesManifest?.i18n && isBlocking) {
for (const locale of routesManifest.i18n.locales) {
const localeRouteFileNoExt = addLocaleOrDefault(
routeFileNoExt,
routesManifest,
locale
);
let localeOutputPathPage = path.posix.join(
entryDirectory,
localeRouteFileNoExt
);
if (!isAppPathRoute) {
localeOutputPathPage = normalizeIndexOutput(
localeOutputPathPage,
isServerMode
);
}
const origPrerenderPage = prerenders[outputPathPage];
prerenders[localeOutputPathPage] = {
...origPrerenderPage,
group: prerenderGroup,
} as Prerender;
if (outputPathData) {
const localeOutputPathData = outputPathData.replace(
new RegExp(`${escapeStringRegexp(origRouteFileNoExt)}.json$`),
`${localeRouteFileNoExt}${
localeRouteFileNoExt !== origRouteFileNoExt &&
origRouteFileNoExt === '/index'
? '/index'
: ''
}.json`
);
const origPrerenderData = prerenders[outputPathData];
prerenders[localeOutputPathData] = {
...origPrerenderData,
group: prerenderGroup,
} as Prerender;
}
++prerenderGroup;
}
}
}
if (
((nonDynamicSsg && !isLocalePrefixed) || isFallback || isOmitted) &&
routesManifest?.i18n &&
!locale
) {
// load each locale
for (const locale of routesManifest.i18n.locales) {
if (locale === routesManifest.i18n.defaultLocale) continue;
onPrerenderRoute(prerenderRouteArgs)(routeKey, {
isBlocking,
isFallback,
isOmitted,
locale,
});
}
}
};
export type UnwrapPromise<T> = T extends Promise<infer U> ? U : T;
export async function getStaticFiles(
entryPath: string,
entryDirectory: string,
outputDirectory: string
) {
const collectLabel =
'Collected static files (public/, static/, .next/static)';
console.time(collectLabel);
const nextStaticFiles = await glob(
'**',
path.join(entryPath, outputDirectory, 'static')
);
const staticFolderFiles = await glob('**', path.join(entryPath, 'static'));
let publicFolderFiles: UnwrapPromise<ReturnType<typeof glob>> = {};
let publicFolderPath: string | undefined;
if (await fs.pathExists(path.join(entryPath, 'public'))) {
publicFolderPath = path.join(entryPath, 'public');
} else if (
// check at the same level as the output directory also
await fs.pathExists(path.join(entryPath, outputDirectory, '../public'))
) {
publicFolderPath = path.join(entryPath, outputDirectory, '../public');
}
if (publicFolderPath) {
debug(`Using public folder at ${publicFolderPath}`);
publicFolderFiles = await glob('**/*', publicFolderPath);
} else {
debug('No public folder found');
}
const staticFiles: Record<string, FileFsRef> = {};
const staticDirectoryFiles: Record<string, FileFsRef> = {};
const publicDirectoryFiles: Record<string, FileFsRef> = {};
for (const file of Object.keys(nextStaticFiles)) {
staticFiles[path.posix.join(entryDirectory, `_next/static/${file}`)] =
nextStaticFiles[file];
}
for (const file of Object.keys(staticFolderFiles)) {
staticDirectoryFiles[path.posix.join(entryDirectory, 'static', file)] =
staticFolderFiles[file];
}
for (const file of Object.keys(publicFolderFiles)) {
publicDirectoryFiles[path.posix.join(entryDirectory, file)] =
publicFolderFiles[file];
}
console.timeEnd(collectLabel);
return {
staticFiles,
staticDirectoryFiles,
publicDirectoryFiles,
};
}
/**
* Strips the trailing `/index` from the output name if it's not the root if
* the server mode is enabled.
*/
export function normalizeIndexOutput(
outputName: string,
isServerMode: boolean
) {
if (outputName !== 'index' && outputName !== '/index' && isServerMode) {
return outputName.replace(/\/index$/, '');
}
return outputName;
}
/**
* The path to next-server was changed in
* https://github.com/vercel/next.js/pull/26756
*/
export function getNextServerPath(nextVersion: string) {
return semver.gte(nextVersion, 'v11.0.2-canary.4')
? 'next/dist/server'
: 'next/dist/next-server/server';
}
function pathnameToOutputName(entryDirectory: string, pathname: string) {
if (pathname === '/') {
pathname = '/index';
}
return path.posix.join(entryDirectory, pathname);
}
export function getPostponeResumePathname(
entryDirectory: string,
pathname: string
): string {
if (pathname === '/') pathname = '/index';
return path.posix.join(entryDirectory, '_next/postponed/resume', pathname);
}
// update to leverage
export function updateRouteSrc(
route: Route,
index: number,
manifestItems: Array<{ regex: string }>
) {
if (route.src) {
route.src = manifestItems[index].regex;
}
return route;
}
export async function getPrivateOutputs(
dir: string,
entries: Record<string, string>
) {
const files: Files = {};
const routes: Route[] = [];
for (const [existingFile, outputFile] of Object.entries(entries)) {
const fsPath = path.join(dir, existingFile);
try {
const { mode, size } = await stat(fsPath);
if (size > 30 * 1024 * 1024) {
throw new Error(`Exceeds maximum file size: ${size}`);
}
files[outputFile] = new FileFsRef({ mode, fsPath });
routes.push({
src: `/${outputFile}`,
dest: '/404',
status: 404,
continue: true,
});
} catch (error) {
debug(
`Private file ${existingFile} had an error and will not be uploaded: ${error}`
);
}
}
return { files, routes };
}
export {
excludeFiles,
validateEntrypoint,
normalizePackageJson,
getNextConfig,
getImagesConfig,
stringMap,
normalizePage,
isDynamicRoute,
getSourceFilePathFromPage,
};
export type FunctionsConfigManifestV1 = {
version: 1;
functions: Record<
string,
{
maxDuration?: number;
}
>;
};
type MiddlewareManifest =
| MiddlewareManifestV1
| MiddlewareManifestV2
| MiddlewareManifestV3;
interface MiddlewareManifestV1 {
version: 1;
sortedMiddleware: string[];
middleware: { [page: string]: EdgeFunctionInfoV1 };
functions?: { [page: string]: EdgeFunctionInfoV1 };
}
interface MiddlewareManifestV2 {
version: 2;
sortedMiddleware: string[];
middleware: { [page: string]: EdgeFunctionInfoV2 };
functions?: { [page: string]: EdgeFunctionInfoV2 };
}
interface MiddlewareManifestV3 {
version: 3;
sortedMiddleware: string[];
middleware: { [page: string]: EdgeFunctionInfoV3 };
functions?: { [page: string]: EdgeFunctionInfoV3 };
}
type Regions = 'home' | 'global' | 'auto' | string[] | 'all' | 'default';
interface BaseEdgeFunctionInfo {
files: string[];
name: string;
page: string;
wasm?: { filePath: string; name: string }[];
assets?: { filePath: string; name: string }[];
regions?: Regions;
}
interface EdgeFunctionInfoV1 extends BaseEdgeFunctionInfo {
regexp: string;
}
interface EdgeFunctionInfoV2 extends BaseEdgeFunctionInfo {
matchers: EdgeFunctionMatcher[];
}
interface EdgeFunctionInfoV3 extends BaseEdgeFunctionInfo {
matchers: EdgeFunctionMatcher[];
env: Record<string, string>;
}
interface EdgeFunctionMatcher {
regexp: string;
has?: HasField;
missing?: HasField;
originalSource?: string;
}
const vercelFunctionRegionsVar = process.env.VERCEL_FUNCTION_REGIONS;
let vercelFunctionRegions: string[] | undefined;
if (vercelFunctionRegionsVar) {
vercelFunctionRegions = vercelFunctionRegionsVar.split(',');
}
/**
* Normalizes the regions config that comes from the Next.js edge functions manifest.
* Ensures that config like `home` and `global` are converted to the corresponding Vercel region config.
* In the future we'll want to make `home` and `global` part of the Build Output API.
* - `home` refers to the regions set in vercel.json or on the Vercel dashboard project config.
* - `global` refers to all regions.
*/
function normalizeRegions(regions: Regions): undefined | string | string[] {
if (typeof regions === 'string') {
regions = [regions];
}
const newRegions: string[] = [];
for (const region of regions) {
// Explicitly mentioned as `home` is one of the explicit values for preferredRegion in Next.js.
if (region === 'home') {
if (vercelFunctionRegions) {
// Includes the regions from the VERCEL_FUNCTION_REGIONS env var.
newRegions.push(...vercelFunctionRegions);
}
continue;
}
// Explicitly mentioned as `global` is one of the explicit values for preferredRegion in Next.js.
if (region === 'global') {
// Uses `all` instead as that's how it's implemented on Vercel.
// Returns here as when all is provided all regions will be matched.
return 'all';
}
// Explicitly mentioned as `auto` is one of the explicit values for preferredRegion in Next.js.
if (region === 'auto') {
// Returns here as when auto is provided all regions will be matched.
return 'auto';
}
newRegions.push(region);
}
// Ensure we don't pass an empty array as that is not supported.
if (newRegions.length === 0) {
return undefined;
}
return newRegions;
}
export function normalizeEdgeFunctionPath(
shortPath: string,
appPathRoutesManifest: Record<string, string>
) {
if (
shortPath.startsWith('app/') &&
(shortPath.endsWith('/page') ||
shortPath.endsWith('/route') ||
shortPath === 'app/_not-found')
) {
const ogRoute = shortPath.replace(/^app\//, '/');
shortPath = (
appPathRoutesManifest[ogRoute] ||
shortPath.replace(/(^|\/)(page|route)$/, '')
).replace(/^\//, '');
if (!shortPath || shortPath === '/') {
shortPath = 'index';
}
}
if (shortPath.startsWith('pages/')) {
shortPath = shortPath.replace(/^pages\//, '');
}
return shortPath;
}
export async function getMiddlewareBundle({
entryPath,
outputDirectory,
routesManifest,
isCorrectMiddlewareOrder,
prerenderBypassToken,
nextVersion,
appPathRoutesManifest,
}: {
config: Config;
entryPath: string;
outputDirectory: string;
prerenderBypassToken: string;
routesManifest: RoutesManifest;
isCorrectMiddlewareOrder: boolean;
nextVersion: string;
appPathRoutesManifest: Record<string, string>;
}): Promise<{
staticRoutes: Route[];
dynamicRouteMap: ReadonlyMap<string, RouteWithSrc>;
edgeFunctions: Record<string, EdgeFunction>;
}> {
const middlewareManifest = await getMiddlewareManifest(
entryPath,
outputDirectory
);
const sortedFunctions = [
...(!middlewareManifest
? []
: middlewareManifest.sortedMiddleware.map(key => ({
key,
edgeFunction: middlewareManifest?.middleware[key],
type: 'middleware' as const,
}))),
...Object.entries(middlewareManifest?.functions ?? {}).map(
([key, edgeFunction]) => {
return {
key,
edgeFunction,
type: 'function' as const,
};
}
),
];
if (middlewareManifest && sortedFunctions.length > 0) {
const workerConfigs = await Promise.all(
sortedFunctions.map(async ({ key, edgeFunction, type }) => {
try {
const wrappedModuleSource = await getNextjsEdgeFunctionSource(
edgeFunction.files,
{
name: edgeFunction.name,
staticRoutes: routesManifest.staticRoutes,
dynamicRoutes: routesManifest.dynamicRoutes.filter(
r => !('isMiddleware' in r)
),
nextConfig: {
basePath: routesManifest.basePath,
i18n: routesManifest.i18n,
},
},
path.resolve(entryPath, outputDirectory),
edgeFunction.wasm
);
return {
type,
page: edgeFunction.page,
name: edgeFunction.name,
edgeFunction: (() => {
const { source, map } = wrappedModuleSource.sourceAndMap();
const transformedMap = stringifySourceMap(
transformSourceMap(map)
);
const wasmFiles = (edgeFunction.wasm ?? []).reduce(
(acc: Files, { filePath, name }) => {
const fullFilePath = path.join(
entryPath,
outputDirectory,
filePath
);
acc[`wasm/${name}.wasm`] = new FileFsRef({
mode: 0o644,
contentType: 'application/wasm',
fsPath: fullFilePath,
});
return acc;
},
{}
);
const assetFiles = (edgeFunction.assets ?? []).reduce(
(acc: Files, { filePath, name }) => {
const fullFilePath = path.join(
entryPath,
outputDirectory,
filePath
);
acc[`assets/${name}`] = new FileFsRef({
mode: 0o644,
contentType: 'application/octet-stream',
fsPath: fullFilePath,
});
return acc;
},
{}
);
return new EdgeFunction({
deploymentTarget: 'v8-worker',
name: edgeFunction.name,
files: {
'index.js': new FileBlob({
data: source,
contentType: 'application/javascript',
mode: 0o644,
}),
...(transformedMap && {
'index.js.map': new FileBlob({
data: transformedMap,
contentType: 'application/json',
mode: 0o644,
}),
}),
...wasmFiles,
...assetFiles,
},
regions: edgeFunction.regions
? normalizeRegions(edgeFunction.regions)
: undefined,
entrypoint: 'index.js',
assets: (edgeFunction.assets ?? []).map(({ name }) => {
return {
name,
path: `assets/${name}`,
};
}),
framework: {
slug: 'nextjs',
version: nextVersion,
},
environment: edgeFunction.env,
});
})(),
routeMatchers: getRouteMatchers(edgeFunction, routesManifest),
};
} catch (e: any) {
e.message = `Can't build edge function ${key}: ${e.message}`;
throw e;
}
})
);
const source: {
staticRoutes: Route[];
dynamicRouteMap: Map<string, RouteWithSrc>;
edgeFunctions: Record<string, EdgeFunction>;
} = {
staticRoutes: [],
dynamicRouteMap: new Map(),
edgeFunctions: {},
};
for (const worker of workerConfigs.values()) {
let shortPath = worker.name;
// Replacing the folder prefix for the page
//
// For `pages/`, use file base name directly:
// pages/index -> index
// For `app/`, use folder name, handle the root page as index:
// app/route/page -> route
// app/page -> index
// app/index/page -> index/index
if (shortPath.startsWith('pages/')) {
shortPath = shortPath.replace(/^pages\//, '');
} else {
shortPath = normalizeEdgeFunctionPath(shortPath, appPathRoutesManifest);
}
if (routesManifest?.basePath) {
const isAppPathRoute = !!appPathRoutesManifest[shortPath];
shortPath = path.posix.join(
'./',
routesManifest?.basePath,
shortPath.replace(/^\//, '')
);
if (!isAppPathRoute) {
shortPath = normalizeIndexOutput(shortPath, true);
}
}
worker.edgeFunction.name = shortPath;
source.edgeFunctions[shortPath] = worker.edgeFunction;
// we don't add the route for edge functions as these
// are already added in the routes-manifest under dynamicRoutes
if (worker.type === 'function') {
continue;
}
for (const matcher of worker.routeMatchers) {
const route: Route = {
continue: true,
src: matcher.regexp,
has: matcher.has,
missing: [
{
type: 'header',
key: 'x-prerender-revalidate',
value: prerenderBypassToken,
},
...(matcher.missing || []),
],
};
route.middlewarePath = shortPath;
route.middlewareRawSrc = matcher.originalSource
? [matcher.originalSource]
: [];
if (isCorrectMiddlewareOrder) {
route.override = true;
}
if (routesManifest.version > 3 && isDynamicRoute(worker.page)) {
source.dynamicRouteMap.set(worker.page, route);
} else {
source.staticRoutes.push(route);
}
}
}
return source;
}
return {
staticRoutes: [],
dynamicRouteMap: new Map(),
edgeFunctions: {},
};
}
/**
* Attempts to read the functions config manifest from the pre-defined
* location. If the manifest can't be found it will resolve to
* undefined.
*/
export async function getFunctionsConfigManifest(
entryPath: string,
outputDirectory: string
): Promise<FunctionsConfigManifestV1 | undefined> {
const functionConfigManifestPath = path.join(
entryPath,
outputDirectory,
'./server/functions-config-manifest.json'
);
const hasManifest = await fs
.access(functionConfigManifestPath)
.then(() => true)
.catch(() => false);
if (!hasManifest) {
return;
}
const manifest: FunctionsConfigManifestV1 = await fs.readJSON(
functionConfigManifestPath
);
return manifest.version === 1 ? manifest : undefined;
}
/**
* Attempts to read the middleware manifest from the pre-defined
* location. If the manifest can't be found it will resolve to
* undefined.
*/
export async function getMiddlewareManifest(
entryPath: string,
outputDirectory: string
): Promise<MiddlewareManifestV3 | undefined> {
const middlewareManifestPath = path.join(
entryPath,
outputDirectory,
'./server/middleware-manifest.json'
);
const hasManifest = await fs
.access(middlewareManifestPath)
.then(() => true)
.catch(() => false);
if (!hasManifest) {
return;
}
const manifest = (await fs.readJSON(
middlewareManifestPath
)) as MiddlewareManifest;
if (manifest.version === 1) {
return upgradeMiddlewareManifestV1(manifest);
}
if (manifest.version === 2) {
return upgradeMiddlewareManifestV2(manifest);
}
return manifest;
}
export function upgradeMiddlewareManifestV1(
v1: MiddlewareManifestV1
): MiddlewareManifestV3 {
function updateInfo(v1Info: EdgeFunctionInfoV1): EdgeFunctionInfoV3 {
const { regexp, ...rest } = v1Info;
return {
...rest,
matchers: [{ regexp }],
env: {},
};
}
const middleware = Object.fromEntries(
Object.entries(v1.middleware).map(([p, info]) => [p, updateInfo(info)])
);
const functions = v1.functions
? Object.fromEntries(
Object.entries(v1.functions).map(([p, info]) => [p, updateInfo(info)])
)
: undefined;
return {
...v1,
version: 3,
middleware,
functions,
};
}
export function upgradeMiddlewareManifestV2(
v2: MiddlewareManifestV2
): MiddlewareManifestV3 {
function updateInfo(v2Info: EdgeFunctionInfoV2): EdgeFunctionInfoV3 {
const { ...rest } = v2Info;
return {
...rest,
env: {},
};
}
const middleware = Object.fromEntries(
Object.entries(v2.middleware).map(([p, info]) => [p, updateInfo(info)])
);
const functions = v2.functions
? Object.fromEntries(
Object.entries(v2.functions).map(([p, info]) => [p, updateInfo(info)])
)
: undefined;
return {
...v2,
version: 3,
middleware,
functions,
};
}
/**
* For an object containing middleware info and a routes manifest this will
* generate a string with the route that will activate the middleware on
* Vercel Proxy.
*
* @param param0 The middleware info including matchers and page.
* @param param1 The routes manifest
* @returns matchers for the middleware route.
*/
function getRouteMatchers(
info: EdgeFunctionInfoV2,
{ basePath = '', i18n }: RoutesManifest
): EdgeFunctionMatcher[] {
function getRegexp(regexp: string) {
if (info.page === '/') {
return regexp;
}
const locale = i18n?.locales.length
? `(?:/(${i18n.locales
.map(locale => escapeStringRegexp(locale))
.join('|')}))?`
: '';
return `(?:^${basePath}${locale}${regexp.substring(1)})`;
}
function normalizeHas(has: HasField): HasField {
return has.map(v =>
v.type === 'header'
? {
...v,
key: v.key.toLowerCase(),
}
: v
);
}
return info.matchers.map(matcher => {
const m: EdgeFunctionMatcher = {
regexp: getRegexp(matcher.regexp),
originalSource: matcher.originalSource,
};
if (matcher.has) {
m.has = normalizeHas(matcher.has);
}
if (matcher.missing) {
m.missing = normalizeHas(matcher.missing);
}
return m;
});
}
/**
* Makes the sources more human-readable in the source map
* by removing webpack-specific prefixes
*/
function transformSourceMap(
sourcemap: RawSourceMap | null
): RawSourceMap | undefined {
if (!sourcemap) return;
const sources = sourcemap.sources
?.map(source => {
return source.replace(/^webpack:\/\/?_N_E\/(?:\.\/)?/, '');
})
// Hide the Next.js entrypoint
.map(source => {
return source.startsWith('?') ? '[native code]' : source;
});
return { ...sourcemap, sources };
}
interface LambdaGroupTypeInterface {
isApiLambda: boolean;
isPrerenders?: boolean;
}
export function getOperationType({
group,
prerenderManifest,
pageFileName,
}: {
group?: LambdaGroupTypeInterface;
prerenderManifest?: NextPrerenderedRoutes;
pageFileName?: string;
}) {
if (group?.isApiLambda || isApiPage(pageFileName)) {
return 'API';
}
if (group?.isPrerenders) {
return 'ISR';
}
if (pageFileName && prerenderManifest) {
const { blockingFallbackRoutes = {}, fallbackRoutes = {} } =
prerenderManifest;
if (
pageFileName in blockingFallbackRoutes ||
pageFileName in fallbackRoutes
) {
return 'ISR';
}
}
return 'Page'; // aka SSR
}
export function isApiPage(page: string | undefined) {
if (!page) {
return false;
}
return page
.replace(/\\/g, '/')
.match(/(serverless|server)\/pages\/api(\/|\.js$)/);
}
export type VariantsManifest = {
definitions: FlagDefinitions;
};
export async function getVariantsManifest(
entryPath: string,
outputDirectory: string
): Promise<null | VariantsManifest> {
const pathVariantsManifest = path.join(
entryPath,
outputDirectory,
'variants-manifest.json'
);
const hasVariantsManifest = await fs
.access(pathVariantsManifest)
.then(() => true)
.catch(() => false);
if (!hasVariantsManifest) return null;
const variantsManifest: VariantsManifest = await fs.readJSON(
pathVariantsManifest
);
return variantsManifest;
}
export async function getServerlessPages(params: {
pagesDir: string;
entryPath: string;
outputDirectory: string;
appPathRoutesManifest?: Record<string, string>;
}) {
const appDir = path.join(params.pagesDir, '../app');
const [pages, appPaths, middlewareManifest] = await Promise.all([
glob('**/!(_middleware).js', params.pagesDir),
params.appPathRoutesManifest
? Promise.all([
glob('**/page.js', appDir),
glob('**/route.js', appDir),
glob('**/_not-found.js', appDir),
]).then(items => Object.assign(...items))
: Promise.resolve({} as Record<string, FileFsRef>),
getMiddlewareManifest(params.entryPath, params.outputDirectory),
]);
const normalizedAppPaths: typeof appPaths = {};
if (params.appPathRoutesManifest) {
for (const [entry, normalizedEntry] of Object.entries(
params.appPathRoutesManifest
)) {
const normalizedPath = `${path.join(
'.',
normalizedEntry === '/' ? '/index' : normalizedEntry
)}.js`;
const globPath = `${path.posix.join('.', entry)}.js`;
if (appPaths[globPath]) {
normalizedAppPaths[normalizedPath] = appPaths[globPath];
}
}
}
// Edge Functions do not consider as Serverless Functions
for (const edgeFunctionFile of Object.keys(
middlewareManifest?.functions ?? {}
)) {
let edgePath =
middlewareManifest?.functions?.[edgeFunctionFile].name ||
edgeFunctionFile;
edgePath = normalizeEdgeFunctionPath(
edgePath,
params.appPathRoutesManifest || {}
);
edgePath = (edgePath || 'index') + '.js';
delete normalizedAppPaths[edgePath];
delete pages[edgePath];
}
return { pages, appPaths: normalizedAppPaths };
}
// to avoid any conflict with route matching/resolving, we prefix all prefetches (ie, __index.prefetch.rsc)
// this is to ensure that prefetches are never matched for things like a greedy match on `index.{ext}`
export function normalizePrefetches(prefetches: Record<string, FileFsRef>) {
const updatedPrefetches: Record<string, FileFsRef> = {};
for (const key in prefetches) {
if (key === 'index.prefetch.rsc') {
const newKey = key.replace(/([^/]+\.prefetch\.rsc)$/, '__$1');
updatedPrefetches[newKey] = prefetches[key];
} else {
updatedPrefetches[key] = prefetches[key];
}
}
return updatedPrefetches;
}