0de109baab
This PR brings some significant refactoring in preparation for upcoming middleware changes. Each commit can be reviewed independently, here is a summary of what each one does and the reasoning behind it: - [Move pagesDir to next-dev-server](f2fe154c00
) simply moves the `pagesDir` property to the dev server which is the only place where it is needed. Having it for every server is misleading. - [Move (de)normalize page path utils to a file page-path-utils.ts](27cedf0871
) Moves the functions to normalize and denormalize page paths to a single file that is intended to hold every utility function that transforms page paths. Since those are complementary it makes sense to have them together. I also added explanatory comments on why they are not idempotent and examples for input -> output that I find very useful. - [Extract removePagePathTail](6b121332aa
) This extracts a function to remove the tail on a page path (absolute or relative). I'm sure there will be other contexts where we can use it. - [Extract getPagePaths and refactor findPageFile](cf2c7b842e
) This extracts a function `getPagePaths` that is used to generate an array of paths to inspect when looking for a page file from `findPageFile`. Then it refactors such function to use it parallelizing lookups. This will allow us to print every path we look at when looking for a file which can be useful for debugging. It also adds a `flatten` helper. - [Refactor onDemandEntryHandler](4be685c37e
) I've found this one quite difficult to understand so it is refactored to use some of the previously mentioned functions and make it easier to read. - [Extract absolutePagePath util](3bc0783474
) Extracts yet another util from the `next-dev-server` that transforms an absolute path into a page name. Of course it adds comments, parameters and examples. - [Refactor MiddlewarePlugin](c595a2cc62
) This is the most significant change. The logic here was very hard to understand so it is totally redistributed with comments. This also removes a global variable `ssrEntries` that was deprecated in favour of module metadata added to Webpack from loaders keeping less dependencies. It also adds types and makes a clear distinction between phases where we statically analyze the code, find metadata and generate the manifest file cc @shuding @huozhi EDIT: - [Split page path utils](158fb002d0
) After seeing one of the utils was being used by the client while it was defined originally in the server, with this PR we are splitting the util into multiple files and moving it to `shared/lib` in order to make explicit that those can be also imported from client.
464 lines
14 KiB
TypeScript
464 lines
14 KiB
TypeScript
import type { ClientPagesLoaderOptions } from './webpack/loaders/next-client-pages-loader'
|
|
import type { MiddlewareLoaderOptions } from './webpack/loaders/next-middleware-loader'
|
|
import type { MiddlewareSSRLoaderQuery } from './webpack/loaders/next-middleware-ssr-loader'
|
|
import type { NextConfigComplete, NextConfig } from '../server/config-shared'
|
|
import type { PageRuntime } from '../server/config-shared'
|
|
import type { ServerlessLoaderQuery } from './webpack/loaders/next-serverless-loader'
|
|
import type { webpack5 } from 'next/dist/compiled/webpack/webpack'
|
|
import type { LoadedEnvFiles } from '@next/env'
|
|
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 { EDGE_RUNTIME_WEBPACK } from '../shared/lib/constants'
|
|
import { MIDDLEWARE_ROUTE } from '../lib/constants'
|
|
import { __ApiPreviewProps } from '../server/api-utils'
|
|
import { isTargetLikeServerless } from '../server/utils'
|
|
import { warn } from './output/log'
|
|
import { parse } from '../build/swc'
|
|
import { isFlightPage, withoutRSCExtensions } from './utils'
|
|
import { normalizePathSep } from '../shared/lib/page-path/normalize-path-sep'
|
|
import { normalizePagePath } from '../shared/lib/page-path/normalize-page-path'
|
|
|
|
type ObjectValue<T> = T extends { [key: string]: infer V } ? V : never
|
|
|
|
/**
|
|
* For a given page path removes the provided extensions. `/_app.server` is a
|
|
* special case because it is the only page where we want to preserve the RSC
|
|
* server extension.
|
|
*/
|
|
export function getPageFromPath(pagePath: string, pageExtensions: string[]) {
|
|
const extensions = pagePath.includes('/_app.server.')
|
|
? withoutRSCExtensions(pageExtensions)
|
|
: pageExtensions
|
|
|
|
const page = normalizePathSep(
|
|
pagePath.replace(new RegExp(`\\.+(${extensions.join('|')})$`), '')
|
|
).replace(/\/index$/, '')
|
|
|
|
return page === '' ? '/' : page
|
|
}
|
|
|
|
export function createPagesMapping({
|
|
hasServerComponents,
|
|
isDev,
|
|
pageExtensions,
|
|
pagePaths,
|
|
}: {
|
|
hasServerComponents: boolean
|
|
isDev: boolean
|
|
pageExtensions: string[]
|
|
pagePaths: string[]
|
|
}): { [page: string]: string } {
|
|
const previousPages: { [key: string]: string } = {}
|
|
const pages = pagePaths.reduce<{ [key: string]: string }>(
|
|
(result, pagePath) => {
|
|
// Do not process .d.ts files inside the `pages` folder
|
|
if (pagePath.endsWith('.d.ts') && pageExtensions.includes('ts')) {
|
|
return result
|
|
}
|
|
|
|
const pageKey = getPageFromPath(pagePath, pageExtensions)
|
|
|
|
// Assume that if there's a Client Component, that there is
|
|
// a matching Server Component that will map to the page.
|
|
// so we will not process it
|
|
if (hasServerComponents && /\.client$/.test(pageKey)) {
|
|
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] = normalizePathSep(join(PAGES_DIR_ALIAS, pagePath))
|
|
return result
|
|
},
|
|
{}
|
|
)
|
|
|
|
// In development we always alias these to allow Webpack to fallback to
|
|
// the correct source file so that HMR can work properly when a file is
|
|
// added or removed.
|
|
if (isDev) {
|
|
delete pages['/_app']
|
|
delete pages['/_app.server']
|
|
delete pages['/_error']
|
|
delete pages['/_document']
|
|
}
|
|
|
|
const root = isDev ? PAGES_DIR_ALIAS : 'next/dist/pages'
|
|
return {
|
|
'/_app': `${root}/_app`,
|
|
'/_error': `${root}/_error`,
|
|
'/_document': `${root}/_document`,
|
|
...(hasServerComponents ? { '/_app.server': `${root}/_app.server` } : {}),
|
|
...pages,
|
|
}
|
|
}
|
|
|
|
const cachedPageRuntimeConfig = new Map<string, [number, PageRuntime]>()
|
|
|
|
// @TODO: We should limit the maximum concurrency of this function as there
|
|
// could be thousands of pages existing.
|
|
export async function getPageRuntime(
|
|
pageFilePath: string,
|
|
nextConfig: Partial<NextConfig>,
|
|
isDev?: boolean
|
|
): Promise<PageRuntime> {
|
|
if (!nextConfig.experimental?.reactRoot) return undefined
|
|
|
|
const globalRuntime = nextConfig.experimental?.runtime
|
|
const cached = cachedPageRuntimeConfig.get(pageFilePath)
|
|
if (cached) {
|
|
return cached[1]
|
|
}
|
|
|
|
let pageContent: string
|
|
try {
|
|
pageContent = await fs.promises.readFile(pageFilePath, {
|
|
encoding: 'utf8',
|
|
})
|
|
} catch (err) {
|
|
if (!isDev) throw 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: PageRuntime = 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: 'unknown',
|
|
})
|
|
|
|
for (const node of body) {
|
|
const { type, declaration } = node
|
|
if (type === 'ExportDeclaration') {
|
|
// Match `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') {
|
|
// Match `export function getStaticProps | getServerSideProps`
|
|
const identifier = declaration.identifier?.value
|
|
if (
|
|
identifier === 'getStaticProps' ||
|
|
identifier === 'getServerSideProps'
|
|
) {
|
|
isRuntimeRequired = true
|
|
}
|
|
}
|
|
} else if (type === 'ExportNamedDeclaration') {
|
|
// Match `export { getStaticProps | getServerSideProps } <from '../..'>`
|
|
const { specifiers } = node
|
|
for (const specifier of specifiers) {
|
|
const { orig } = specifier
|
|
const hasDataFetchingExports =
|
|
specifier.type === 'ExportSpecifier' &&
|
|
orig?.type === 'Identifier' &&
|
|
(orig?.value === 'getStaticProps' ||
|
|
orig?.value === 'getServerSideProps')
|
|
if (hasDataFetchingExports) {
|
|
isRuntimeRequired = true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} catch (err) {}
|
|
}
|
|
|
|
if (!pageRuntime) {
|
|
if (isRuntimeRequired) {
|
|
pageRuntime = globalRuntime
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
interface CreateEntrypointsParams {
|
|
buildId: string
|
|
config: NextConfigComplete
|
|
envFiles: LoadedEnvFiles
|
|
isDev?: boolean
|
|
pages: { [page: string]: string }
|
|
pagesDir: string
|
|
previewMode: __ApiPreviewProps
|
|
target: 'server' | 'serverless' | 'experimental-serverless-trace'
|
|
}
|
|
|
|
export function getEdgeServerEntry(opts: {
|
|
absolutePagePath: string
|
|
buildId: string
|
|
bundlePath: string
|
|
config: NextConfigComplete
|
|
isDev: boolean
|
|
page: string
|
|
pages: { [page: string]: string }
|
|
}): ObjectValue<webpack5.EntryObject> {
|
|
if (opts.page.match(MIDDLEWARE_ROUTE)) {
|
|
const loaderParams: MiddlewareLoaderOptions = {
|
|
absolutePagePath: opts.absolutePagePath,
|
|
page: opts.page,
|
|
}
|
|
|
|
return `next-middleware-loader?${stringify(loaderParams)}!`
|
|
}
|
|
|
|
const loaderParams: MiddlewareSSRLoaderQuery = {
|
|
absolute500Path: opts.pages['/500'] || '',
|
|
absoluteAppPath: opts.pages['/_app'],
|
|
absoluteAppServerPath: opts.pages['/_app.server'],
|
|
absoluteDocumentPath: opts.pages['/_document'],
|
|
absoluteErrorPath: opts.pages['/_error'],
|
|
absolutePagePath: opts.absolutePagePath,
|
|
buildId: opts.buildId,
|
|
dev: opts.isDev,
|
|
isServerComponent: isFlightPage(opts.config, opts.absolutePagePath),
|
|
page: opts.page,
|
|
stringifiedConfig: JSON.stringify(opts.config),
|
|
}
|
|
|
|
return `next-middleware-ssr-loader?${stringify(loaderParams)}!`
|
|
}
|
|
|
|
export function getServerlessEntry(opts: {
|
|
absolutePagePath: string
|
|
buildId: string
|
|
config: NextConfigComplete
|
|
envFiles: LoadedEnvFiles
|
|
page: string
|
|
previewMode: __ApiPreviewProps
|
|
pages: { [page: string]: string }
|
|
}): ObjectValue<webpack5.EntryObject> {
|
|
const loaderParams: ServerlessLoaderQuery = {
|
|
absolute404Path: opts.pages['/404'] || '',
|
|
absoluteAppPath: opts.pages['/_app'],
|
|
absoluteAppServerPath: opts.pages['/_app.server'],
|
|
absoluteDocumentPath: opts.pages['/_document'],
|
|
absoluteErrorPath: opts.pages['/_error'],
|
|
absolutePagePath: opts.absolutePagePath,
|
|
assetPrefix: opts.config.assetPrefix,
|
|
basePath: opts.config.basePath,
|
|
buildId: opts.buildId,
|
|
canonicalBase: opts.config.amp.canonicalBase || '',
|
|
distDir: DOT_NEXT_ALIAS,
|
|
generateEtags: opts.config.generateEtags ? 'true' : '',
|
|
i18n: opts.config.i18n ? JSON.stringify(opts.config.i18n) : '',
|
|
// base64 encode to make sure contents don't break webpack URL loading
|
|
loadedEnvFiles: Buffer.from(JSON.stringify(opts.envFiles)).toString(
|
|
'base64'
|
|
),
|
|
page: opts.page,
|
|
poweredByHeader: opts.config.poweredByHeader ? 'true' : '',
|
|
previewProps: JSON.stringify(opts.previewMode),
|
|
reactRoot: !!opts.config.experimental.reactRoot ? 'true' : '',
|
|
runtimeConfig:
|
|
Object.keys(opts.config.publicRuntimeConfig).length > 0 ||
|
|
Object.keys(opts.config.serverRuntimeConfig).length > 0
|
|
? JSON.stringify({
|
|
publicRuntimeConfig: opts.config.publicRuntimeConfig,
|
|
serverRuntimeConfig: opts.config.serverRuntimeConfig,
|
|
})
|
|
: '',
|
|
}
|
|
|
|
return `next-serverless-loader?${stringify(loaderParams)}!`
|
|
}
|
|
|
|
export function getClientEntry(opts: {
|
|
absolutePagePath: string
|
|
page: string
|
|
}) {
|
|
const loaderOptions: ClientPagesLoaderOptions = {
|
|
absolutePagePath: opts.absolutePagePath,
|
|
page: opts.page,
|
|
}
|
|
|
|
const pageLoader = `next-client-pages-loader?${stringify(loaderOptions)}!`
|
|
|
|
// 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
|
|
return opts.page === '/_app'
|
|
? [pageLoader, require.resolve('../client/router')]
|
|
: pageLoader
|
|
}
|
|
|
|
export async function createEntrypoints(params: CreateEntrypointsParams) {
|
|
const { config, pages, pagesDir, isDev, target } = params
|
|
const edgeServer: webpack5.EntryObject = {}
|
|
const server: webpack5.EntryObject = {}
|
|
const client: webpack5.EntryObject = {}
|
|
|
|
await Promise.all(
|
|
Object.keys(pages).map(async (page) => {
|
|
const bundleFile = normalizePagePath(page)
|
|
const clientBundlePath = posix.join('pages', bundleFile)
|
|
const serverBundlePath = posix.join('pages', bundleFile)
|
|
|
|
runDependingOnPageType({
|
|
page,
|
|
pageRuntime: await getPageRuntime(
|
|
!pages[page].startsWith(PAGES_DIR_ALIAS)
|
|
? require.resolve(pages[page])
|
|
: join(pagesDir, pages[page].replace(PAGES_DIR_ALIAS, '')),
|
|
config,
|
|
isDev
|
|
),
|
|
onClient: () => {
|
|
client[clientBundlePath] = getClientEntry({
|
|
absolutePagePath: pages[page],
|
|
page,
|
|
})
|
|
},
|
|
onServer: () => {
|
|
if (isTargetLikeServerless(target)) {
|
|
if (page !== '/_app' && page !== '/_document') {
|
|
server[serverBundlePath] = getServerlessEntry({
|
|
...params,
|
|
absolutePagePath: pages[page],
|
|
page,
|
|
})
|
|
}
|
|
} else {
|
|
server[serverBundlePath] = [pages[page]]
|
|
}
|
|
},
|
|
onEdgeServer: () => {
|
|
edgeServer[serverBundlePath] = getEdgeServerEntry({
|
|
...params,
|
|
absolutePagePath: pages[page],
|
|
bundlePath: clientBundlePath,
|
|
isDev: false,
|
|
page,
|
|
})
|
|
},
|
|
})
|
|
})
|
|
)
|
|
|
|
return {
|
|
client,
|
|
server,
|
|
edgeServer,
|
|
}
|
|
}
|
|
|
|
export function runDependingOnPageType<T>(params: {
|
|
onClient: () => T
|
|
onEdgeServer: () => T
|
|
onServer: () => T
|
|
page: string
|
|
pageRuntime: PageRuntime
|
|
}) {
|
|
if (params.page.match(MIDDLEWARE_ROUTE)) {
|
|
return [params.onEdgeServer()]
|
|
} else if (params.page.match(API_ROUTE)) {
|
|
return [params.onServer()]
|
|
} else if (params.page === '/_document') {
|
|
return [params.onServer()]
|
|
} else if (
|
|
params.page === '/_app' ||
|
|
params.page === '/_error' ||
|
|
params.page === '/404' ||
|
|
params.page === '/500'
|
|
) {
|
|
return [params.onClient(), params.onServer()]
|
|
} else {
|
|
return [
|
|
params.onClient(),
|
|
params.pageRuntime === 'edge' ? params.onEdgeServer() : params.onServer(),
|
|
]
|
|
}
|
|
}
|
|
|
|
export function finalizeEntrypoint({
|
|
name,
|
|
compilerType,
|
|
value,
|
|
}: {
|
|
compilerType?: 'client' | 'server' | 'edge-server'
|
|
name: string
|
|
value: ObjectValue<webpack5.EntryObject>
|
|
}): ObjectValue<webpack5.EntryObject> {
|
|
const entry =
|
|
typeof value !== 'object' || Array.isArray(value)
|
|
? { import: value }
|
|
: value
|
|
|
|
if (compilerType === 'server') {
|
|
const isApi = name.startsWith('pages/api/')
|
|
return {
|
|
publicPath: isApi ? '' : undefined,
|
|
runtime: isApi ? 'webpack-api-runtime' : 'webpack-runtime',
|
|
layer: isApi ? 'api' : undefined,
|
|
...entry,
|
|
}
|
|
}
|
|
|
|
if (compilerType === 'edge-server') {
|
|
return {
|
|
layer: MIDDLEWARE_ROUTE.test(name) ? 'middleware' : undefined,
|
|
library: { name: ['_ENTRIES', `middleware_[name]`], type: 'assign' },
|
|
runtime: EDGE_RUNTIME_WEBPACK,
|
|
asyncChunks: false,
|
|
...entry,
|
|
}
|
|
}
|
|
|
|
if (
|
|
// Client special cases
|
|
name !== 'polyfills' &&
|
|
name !== 'main' &&
|
|
name !== 'amp' &&
|
|
name !== 'react-refresh'
|
|
) {
|
|
return {
|
|
dependOn:
|
|
name.startsWith('pages/') && name !== 'pages/_app'
|
|
? 'pages/_app'
|
|
: 'main',
|
|
...entry,
|
|
}
|
|
}
|
|
|
|
return entry
|
|
}
|