rsnext/packages/next/build/entries.ts
Shu Ding 201f98e81a
Per-page runtime (#35011)
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`
2022-03-08 20:55:14 +00:00

426 lines
13 KiB
TypeScript

import fs from 'fs'
import chalk from 'next/dist/compiled/chalk'
import { posix, join } from 'path'
import { stringify } from 'querystring'
import { API_ROUTE, DOT_NEXT_ALIAS, PAGES_DIR_ALIAS } from '../lib/constants'
import { MIDDLEWARE_ROUTE } from '../lib/constants'
import { __ApiPreviewProps } from '../server/api-utils'
import { isTargetLikeServerless } from '../server/utils'
import { normalizePagePath } from '../server/normalize-page-path'
import { warn } from './output/log'
import { MiddlewareLoaderOptions } from './webpack/loaders/next-middleware-loader'
import { ClientPagesLoaderOptions } from './webpack/loaders/next-client-pages-loader'
import { ServerlessLoaderQuery } from './webpack/loaders/next-serverless-loader'
import { LoadedEnvFiles } from '@next/env'
import { NextConfigComplete } from '../server/config-shared'
import { parse } from '../build/swc'
import { isCustomErrorPage, isFlightPage, isReservedPage } from './utils'
import { ssrEntries } from './webpack/plugins/middleware-plugin'
import type { webpack5 } from 'next/dist/compiled/webpack/webpack'
import {
MIDDLEWARE_RUNTIME_WEBPACK,
MIDDLEWARE_SSR_RUNTIME_WEBPACK,
} from '../shared/lib/constants'
type ObjectValue<T> = T extends { [key: string]: infer V } ? V : never
export type PagesMapping = {
[page: string]: string
}
export function getPageFromPath(pagePath: string, extensions: string[]) {
let page = pagePath.replace(new RegExp(`\\.+(${extensions.join('|')})$`), '')
page = page.replace(/\\/g, '/').replace(/\/index$/, '')
return page === '' ? '/' : page
}
export function createPagesMapping(
pagePaths: string[],
extensions: string[],
{
isDev,
hasServerComponents,
globalRuntime,
}: {
isDev: boolean
hasServerComponents: boolean
globalRuntime?: 'nodejs' | 'edge'
}
): PagesMapping {
const previousPages: PagesMapping = {}
// Do not process .d.ts files inside the `pages` folder
pagePaths = extensions.includes('ts')
? pagePaths.filter((pagePath) => !pagePath.endsWith('.d.ts'))
: pagePaths
const pages: PagesMapping = pagePaths.reduce(
(result: PagesMapping, pagePath): PagesMapping => {
const pageKey = getPageFromPath(pagePath, extensions)
if (hasServerComponents && /\.client$/.test(pageKey)) {
// Assume that if there's a Client Component, that there is
// a matching Server Component that will map to the page.
return result
}
if (pageKey in result) {
warn(
`Duplicate page detected. ${chalk.cyan(
join('pages', previousPages[pageKey])
)} and ${chalk.cyan(
join('pages', pagePath)
)} both resolve to ${chalk.cyan(pageKey)}.`
)
} else {
previousPages[pageKey] = pagePath
}
result[pageKey] = join(PAGES_DIR_ALIAS, pagePath).replace(/\\/g, '/')
return result
},
{}
)
// 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${globalRuntime ? '-concurrent' : ''}`
if (isDev) {
pages['/_app'] = `${PAGES_DIR_ALIAS}/_app`
pages['/_error'] = `${PAGES_DIR_ALIAS}/_error`
pages['/_document'] = `${PAGES_DIR_ALIAS}/_document`
} else {
pages['/_app'] = pages['/_app'] || 'next/dist/pages/_app'
pages['/_error'] = pages['/_error'] || 'next/dist/pages/_error'
pages['/_document'] =
pages['/_document'] || `next/dist/pages/${documentPage}`
}
return pages
}
type Entrypoints = {
client: webpack5.EntryObject
server: webpack5.EntryObject
edgeServer: webpack5.EntryObject
}
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',
})
} 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
}
}
}
}
} 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 function invalidatePageRuntimeCache(
pageFilePath: string,
safeTime: number
) {
const cached = cachedPageRuntimeConfig.get(pageFilePath)
if (cached && cached[0] < safeTime) {
cachedPageRuntimeConfig.delete(pageFilePath)
}
}
export async function createEntrypoints(
pages: PagesMapping,
target: 'server' | 'serverless' | 'experimental-serverless-trace',
buildId: string,
previewMode: __ApiPreviewProps,
config: NextConfigComplete,
loadedEnvFiles: LoadedEnvFiles,
pagesDir: string
): Promise<Entrypoints> {
const client: webpack5.EntryObject = {}
const server: webpack5.EntryObject = {}
const edgeServer: webpack5.EntryObject = {}
const hasRuntimeConfig =
Object.keys(config.publicRuntimeConfig).length > 0 ||
Object.keys(config.serverRuntimeConfig).length > 0
const defaultServerlessOptions = {
absoluteAppPath: pages['/_app'],
absoluteDocumentPath: pages['/_document'],
absoluteErrorPath: pages['/_error'],
absolute404Path: pages['/404'] || '',
distDir: DOT_NEXT_ALIAS,
buildId,
assetPrefix: config.assetPrefix,
generateEtags: config.generateEtags ? 'true' : '',
poweredByHeader: config.poweredByHeader ? 'true' : '',
canonicalBase: config.amp.canonicalBase || '',
basePath: config.basePath,
runtimeConfig: hasRuntimeConfig
? JSON.stringify({
publicRuntimeConfig: config.publicRuntimeConfig,
serverRuntimeConfig: config.serverRuntimeConfig,
})
: '',
previewProps: JSON.stringify(previewMode),
// base64 encode to make sure contents don't break webpack URL loading
loadedEnvFiles: Buffer.from(JSON.stringify(loadedEnvFiles)).toString(
'base64'
),
i18n: config.i18n ? JSON.stringify(config.i18n) : '',
reactRoot: config.experimental.reactRoot ? 'true' : '',
}
const globalRuntime = config.experimental.runtime
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,
}
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 (isApiRoute && isLikeServerless) {
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
)}!`
}
if (page === '/_document') {
return
}
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
}
})
)
return {
client,
server,
edgeServer,
}
}
export function finalizeEntrypoint({
name,
value,
isServer,
isMiddleware,
isEdgeServer,
}: {
isServer: boolean
name: string
value: ObjectValue<webpack5.EntryObject>
isMiddleware?: boolean
isEdgeServer?: boolean
}): ObjectValue<webpack5.EntryObject> {
const entry =
typeof value !== 'object' || Array.isArray(value)
? { import: value }
: value
if (isServer) {
const isApi = name.startsWith('pages/api/')
return {
publicPath: isApi ? '' : undefined,
runtime: isApi ? 'webpack-api-runtime' : 'webpack-runtime',
layer: isApi ? 'api' : undefined,
...entry,
}
}
if (isEdgeServer) {
const ssrMiddlewareEntry = {
library: {
name: ['_ENTRIES', `middleware_[name]`],
type: 'assign',
},
runtime: MIDDLEWARE_SSR_RUNTIME_WEBPACK,
asyncChunks: false,
...entry,
}
return ssrMiddlewareEntry
}
if (isMiddleware) {
const middlewareEntry = {
filename: 'server/[name].js',
layer: 'middleware',
library: {
name: ['_ENTRIES', `middleware_[name]`],
type: 'assign',
},
runtime: MIDDLEWARE_RUNTIME_WEBPACK,
asyncChunks: false,
...entry,
}
return middlewareEntry
}
if (
name !== 'polyfills' &&
name !== 'main' &&
name !== 'amp' &&
name !== 'react-refresh'
) {
return {
dependOn:
name.startsWith('pages/') && name !== 'pages/_app'
? 'pages/_app'
: 'main',
...entry,
}
}
return entry
}