Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Per-page runtime #35011

Merged
merged 14 commits into from Mar 8, 2022
316 changes: 181 additions & 135 deletions packages/next/build/entries.ts
Expand Up @@ -39,11 +39,11 @@ export function createPagesMapping(
{
isDev,
hasServerComponents,
runtime,
globalRuntime,
}: {
isDev: boolean
hasServerComponents: boolean
runtime?: 'nodejs' | 'edge'
globalRuntime?: 'nodejs' | 'edge'
}
): PagesMapping {
const previousPages: PagesMapping = {}
Expand Down Expand Up @@ -83,7 +83,7 @@ export function createPagesMapping(
// we alias these in development and allow webpack to
// allow falling back to the correct source file so
// that HMR can work properly when a file is added/removed
const documentPage = `_document${runtime ? '-concurrent' : ''}`
const documentPage = `_document${globalRuntime ? '-concurrent' : ''}`
if (isDev) {
pages['/_app'] = `${PAGES_DIR_ALIAS}/_app`
pages['/_error'] = `${PAGES_DIR_ALIAS}/_error`
Expand All @@ -103,68 +103,108 @@ type Entrypoints = {
edgeServer: webpack5.EntryObject
}

export async function getPageRuntime(pageFilePath: string) {
let pageRuntime: string | undefined = undefined
const pageContent = await fs.promises.readFile(pageFilePath, {
encoding: 'utf8',
})
// branch prunes for entry page without runtime option
if (pageContent.includes('runtime')) {
const { body } = await parse(pageContent, {
filename: pageFilePath,
isModule: true,
const cachedPageRuntimeConfig = new Map<
string,
[number, 'nodejs' | 'edge' | undefined]
>()

// @TODO: We should limit the maximum concurrency of this function as there
// could be thousands of pages existing.
export async function getPageRuntime(
pageFilePath: string,
globalRuntimeFallback?: 'nodejs' | 'edge'
): Promise<'nodejs' | 'edge' | undefined> {
const cached = cachedPageRuntimeConfig.get(pageFilePath)
if (cached) {
return cached[1]
}

let pageContent: string
try {
pageContent = await fs.promises.readFile(pageFilePath, {
encoding: 'utf8',
})
body.some((node: any) => {
const { type, declaration } = node
const valueNode = declaration?.declarations?.[0]
if (type === 'ExportDeclaration' && valueNode?.id?.value === 'config') {
const props = valueNode.init.properties
const runtimeKeyValue = props.find(
(prop: any) => prop.key.value === 'runtime'
)
const runtime = runtimeKeyValue?.value?.value
pageRuntime =
runtime === 'edge' || runtime === 'nodejs' ? runtime : pageRuntime
return true
} catch (err) {
return undefined
}

// When gSSP or gSP is used, this page requires an execution runtime. If the
// page config is not present, we fallback to the global runtime. Related
// discussion:
// https://github.com/vercel/next.js/discussions/34179
let isRuntimeRequired: boolean = false
let pageRuntime: 'nodejs' | 'edge' | undefined = undefined

// Since these configurations should always be static analyzable, we can
// skip these cases that "runtime" and "gSP", "gSSP" are not included in the
// source code.
if (/runtime|getStaticProps|getServerSideProps/.test(pageContent)) {
try {
const { body } = await parse(pageContent, {
filename: pageFilePath,
isModule: true,
})

for (const node of body) {
const { type, declaration } = node
if (type === 'ExportDeclaration') {
// `export const config`
const valueNode = declaration?.declarations?.[0]
if (valueNode?.id?.value === 'config') {
const props = valueNode.init.properties
const runtimeKeyValue = props.find(
(prop: any) => prop.key.value === 'runtime'
)
const runtime = runtimeKeyValue?.value?.value
pageRuntime =
runtime === 'edge' || runtime === 'nodejs' ? runtime : pageRuntime
} else if (declaration?.type === 'FunctionDeclaration') {
// `export function getStaticProps` and
// `export function getServerSideProps`
if (
declaration.identifier?.value === 'getStaticProps' ||
declaration.identifier?.value === 'getServerSideProps'
) {
isRuntimeRequired = true
}
}
}
}
return false
})
} catch (err) {}
}

if (!pageRuntime) {
if (isRuntimeRequired) {
pageRuntime = globalRuntimeFallback
} else {
// @TODO: Remove this branch to fully implement the RFC.
pageRuntime = globalRuntimeFallback
}
}

cachedPageRuntimeConfig.set(pageFilePath, [Date.now(), pageRuntime])
return pageRuntime
}

export async function createPagesRuntimeMapping(
pagesDir: string,
pages: PagesMapping
export function invalidatePageRuntimeCache(
pageFilePath: string,
safeTime: number
) {
const pagesRuntime: Record<string, string> = {}

const promises = Object.keys(pages).map(async (page) => {
const absolutePagePath = pages[page]
const isReserved = isReservedPage(page)
if (!isReserved) {
const pageFilePath = join(
pagesDir,
absolutePagePath.replace(PAGES_DIR_ALIAS, '')
)
const runtime = await getPageRuntime(pageFilePath)
if (runtime) {
pagesRuntime[page] = runtime
}
}
})
return await Promise.all(promises)
const cached = cachedPageRuntimeConfig.get(pageFilePath)
if (cached && cached[0] < safeTime) {
cachedPageRuntimeConfig.delete(pageFilePath)
}
}

export function createEntrypoints(
export async function createEntrypoints(
pages: PagesMapping,
target: 'server' | 'serverless' | 'experimental-serverless-trace',
buildId: string,
previewMode: __ApiPreviewProps,
config: NextConfigComplete,
loadedEnvFiles: LoadedEnvFiles
): Entrypoints {
loadedEnvFiles: LoadedEnvFiles,
pagesDir: string
): Promise<Entrypoints> {
const client: webpack5.EntryObject = {}
const server: webpack5.EntryObject = {}
const edgeServer: webpack5.EntryObject = {}
Expand Down Expand Up @@ -201,103 +241,109 @@ export function createEntrypoints(
}

const globalRuntime = config.experimental.runtime
const edgeRuntime = globalRuntime === 'edge'

Object.keys(pages).forEach((page) => {
const absolutePagePath = pages[page]
const bundleFile = normalizePagePath(page)
const isApiRoute = page.match(API_ROUTE)

const clientBundlePath = posix.join('pages', bundleFile)
const serverBundlePath = posix.join('pages', bundleFile)

const isLikeServerless = isTargetLikeServerless(target)
const isReserved = isReservedPage(page)
const isCustomError = isCustomErrorPage(page)
const isFlight = isFlightPage(config, absolutePagePath)
await Promise.all(
Object.keys(pages).map(async (page) => {
const absolutePagePath = pages[page]
const bundleFile = normalizePagePath(page)
const isApiRoute = page.match(API_ROUTE)

const clientBundlePath = posix.join('pages', bundleFile)
const serverBundlePath = posix.join('pages', bundleFile)

const isLikeServerless = isTargetLikeServerless(target)
const isReserved = isReservedPage(page)
const isCustomError = isCustomErrorPage(page)
const isFlight = isFlightPage(config, absolutePagePath)
const isEdgeRuntime =
(await getPageRuntime(
join(pagesDir, absolutePagePath.slice(PAGES_DIR_ALIAS.length + 1)),
globalRuntime
)) === 'edge'

if (page.match(MIDDLEWARE_ROUTE)) {
const loaderOpts: MiddlewareLoaderOptions = {
absolutePagePath: pages[page],
page,
}

if (page.match(MIDDLEWARE_ROUTE)) {
const loaderOpts: MiddlewareLoaderOptions = {
absolutePagePath: pages[page],
page,
client[clientBundlePath] = `next-middleware-loader?${stringify(
loaderOpts
)}!`
return
}

client[clientBundlePath] = `next-middleware-loader?${stringify(
loaderOpts
)}!`
return
}
if (isEdgeRuntime && !isReserved && !isCustomError && !isApiRoute) {
ssrEntries.set(clientBundlePath, { requireFlightManifest: isFlight })
edgeServer[serverBundlePath] = finalizeEntrypoint({
name: '[name].js',
value: `next-middleware-ssr-loader?${stringify({
dev: false,
page,
stringifiedConfig: JSON.stringify(config),
absolute500Path: pages['/500'] || '',
absolutePagePath,
isServerComponent: isFlight,
...defaultServerlessOptions,
} as any)}!`,
isServer: false,
isEdgeServer: true,
})
}

if (edgeRuntime && !isReserved && !isCustomError && !isApiRoute) {
ssrEntries.set(clientBundlePath, { requireFlightManifest: isFlight })
edgeServer[serverBundlePath] = finalizeEntrypoint({
name: '[name].js',
value: `next-middleware-ssr-loader?${stringify({
dev: false,
if (isApiRoute && isLikeServerless) {
const serverlessLoaderOptions: ServerlessLoaderQuery = {
page,
stringifiedConfig: JSON.stringify(config),
absolute500Path: pages['/500'] || '',
absolutePagePath,
isServerComponent: isFlight,
...defaultServerlessOptions,
} as any)}!`,
isServer: false,
isEdgeServer: true,
})
}

if (isApiRoute && isLikeServerless) {
const serverlessLoaderOptions: ServerlessLoaderQuery = {
page,
absolutePagePath,
...defaultServerlessOptions,
}
server[serverBundlePath] = `next-serverless-loader?${stringify(
serverlessLoaderOptions
)}!`
} else if (isApiRoute || target === 'server') {
if (!edgeRuntime || isReserved || isCustomError) {
server[serverBundlePath] = [absolutePagePath]
}
} else if (
isLikeServerless &&
page !== '/_app' &&
page !== '/_document' &&
!edgeRuntime
) {
const serverlessLoaderOptions: ServerlessLoaderQuery = {
page,
absolutePagePath,
...defaultServerlessOptions,
}
server[serverBundlePath] = `next-serverless-loader?${stringify(
serverlessLoaderOptions
)}!`
} else if (isApiRoute || target === 'server') {
if (!isEdgeRuntime || isReserved || isCustomError) {
server[serverBundlePath] = [absolutePagePath]
}
} else if (
isLikeServerless &&
page !== '/_app' &&
page !== '/_document' &&
!isEdgeRuntime
) {
const serverlessLoaderOptions: ServerlessLoaderQuery = {
page,
absolutePagePath,
...defaultServerlessOptions,
}
server[serverBundlePath] = `next-serverless-loader?${stringify(
serverlessLoaderOptions
)}!`
}
server[serverBundlePath] = `next-serverless-loader?${stringify(
serverlessLoaderOptions
)}!`
}

if (page === '/_document') {
return
}
if (page === '/_document') {
return
}

if (!isApiRoute) {
const pageLoaderOpts: ClientPagesLoaderOptions = {
page,
absolutePagePath,
if (!isApiRoute) {
const pageLoaderOpts: ClientPagesLoaderOptions = {
page,
absolutePagePath,
}
const pageLoader = `next-client-pages-loader?${stringify(
pageLoaderOpts
)}!`

// Make sure next/router is a dependency of _app or else chunk splitting
// might cause the router to not be able to load causing hydration
// to fail

client[clientBundlePath] =
page === '/_app'
? [pageLoader, require.resolve('../client/router')]
: pageLoader
}
const pageLoader = `next-client-pages-loader?${stringify(
pageLoaderOpts
)}!`

// Make sure next/router is a dependency of _app or else chunk splitting
// might cause the router to not be able to load causing hydration
// to fail

client[clientBundlePath] =
page === '/_app'
? [pageLoader, require.resolve('../client/router')]
: pageLoader
}
})
})
)

return {
client,
Expand Down