Skip to content

Commit

Permalink
Per-page runtime (#35011)
Browse files Browse the repository at this point in the history
Partially implements #31317 and #31506. There're also some trade-offs made with this PR: since we can't know if a certain runtime will be used or not beforehand, we have to start both runtime compilers (Node.js and Edge) and then generate entrypoints correspondingly.

Note that with this PR, the global runtime is still required to use the per-page runtime.

## Bug

- [ ] Related issues linked using `fixes #number`
- [x] Integration tests added
- [ ] Errors have helpful link attached, see `contributing.md`

## Feature

- [ ] Implements an existing feature request or RFC. Make sure the feature request has been accepted for implementation before opening a PR.
- [ ] Related issues linked using `fixes #number`
- [ ] Integration tests added
- [ ] Documentation added
- [ ] Telemetry added. In case of a feature if it's used or not.
- [ ] Errors have helpful link attached, see `contributing.md`

## Documentation / Examples

- [ ] Make sure the linting passes by running `yarn lint`
  • Loading branch information
shuding committed Mar 8, 2022
1 parent 9e4724d commit 201f98e
Show file tree
Hide file tree
Showing 20 changed files with 366 additions and 266 deletions.
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

0 comments on commit 201f98e

Please sign in to comment.