rsnext/packages/next/build/entries.ts
Javi Velasco f354f46b3f
Deprecate nested Middleware in favor of root middleware (#36772)
This PR deprecates declaring a middleware under `pages` in favour of the project root naming it after `middleware` instead of `_middleware`. This is in the context of having a simpler execution model for middleware and also ships some refactor work. There is a ton of a code to be simplified after this deprecation but I think it is best to do it progressively.

With this PR, when in development, we will **fail** whenever we find a nested middleware but we do **not** include it in the compiler so if the project is using it, it will no longer work. For production we will **fail** too so it will not be possible to build and deploy a deprecated middleware. The error points to a page that should also be reviewed as part of **documentation**.

Aside from the deprecation, this migrates all middleware tests to work with a single middleware. It also splits tests into multiple folders to make them easier to isolate and work with. Finally it ships some small code refactor and simplifications.
2022-05-19 15:46:21 +00:00

613 lines
18 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,
MIDDLEWARE_FILE,
MIDDLEWARE_FILENAME,
PAGES_DIR_ALIAS,
ROOT_DIR_ALIAS,
VIEWS_DIR_ALIAS,
} from '../lib/constants'
import {
CLIENT_STATIC_FILES_RUNTIME_AMP,
CLIENT_STATIC_FILES_RUNTIME_MAIN,
CLIENT_STATIC_FILES_RUNTIME_MAIN_ROOT,
CLIENT_STATIC_FILES_RUNTIME_REACT_REFRESH,
EDGE_RUNTIME_WEBPACK,
} from '../shared/lib/constants'
import { __ApiPreviewProps } from '../server/api-utils'
import { isTargetLikeServerless } from '../server/utils'
import { warn } from './output/log'
import { parse } from '../build/swc'
import { isServerComponentPage, withoutRSCExtensions } from './utils'
import { normalizePathSep } from '../shared/lib/page-path/normalize-path-sep'
import { normalizePagePath } from '../shared/lib/page-path/normalize-page-path'
import { serverComponentRegex } from './webpack/loaders/utils'
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
let page = normalizePathSep(
pagePath.replace(new RegExp(`\\.+(${extensions.join('|')})$`), '')
)
page = page.replace(/\/index$/, '')
return page === '' ? '/' : page
}
export function createPagesMapping({
hasServerComponents,
isDev,
pageExtensions,
pagePaths,
pagesType,
}: {
hasServerComponents: boolean
isDev: boolean
pageExtensions: string[]
pagePaths: string[]
pagesType: 'pages' | 'root' | 'views'
}): { [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(
pagesType === 'pages'
? PAGES_DIR_ALIAS
: pagesType === 'views'
? VIEWS_DIR_ALIAS
: ROOT_DIR_ALIAS,
pagePath
)
)
return result
},
{}
)
if (pagesType !== 'pages') {
return pages
}
if (isDev) {
delete pages['/_app']
delete pages['/_app.server']
delete pages['/_error']
delete pages['/_document']
}
// 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.
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,
}
}
type PageStaticInfo = { runtime?: PageRuntime; ssr?: boolean; ssg?: boolean }
const cachedPageStaticInfo = new Map<string, [number, PageStaticInfo]>()
// @TODO: We should limit the maximum concurrency of this function as there
// could be thousands of pages existing.
export async function getPageStaticInfo(
pageFilePath: string,
nextConfig: Partial<NextConfig>,
isDev?: boolean
): Promise<PageStaticInfo> {
const globalRuntime = nextConfig.experimental?.runtime
const cached = cachedPageStaticInfo.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 {}
}
// 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
let ssr = false
let ssg = false
// 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
ssg = identifier === 'getStaticProps'
ssr = identifier === 'getServerSideProps'
}
}
} 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
ssg = orig.value === 'getStaticProps'
ssr = orig.value === 'getServerSideProps'
break
}
}
}
}
} catch (err) {}
}
if (!pageRuntime) {
if (isRuntimeRequired) {
pageRuntime = globalRuntime
}
} else {
// For Node.js runtime, we do static optimization.
if (!isRuntimeRequired && pageRuntime === 'nodejs') {
pageRuntime = undefined
}
}
const info = {
runtime: pageRuntime,
ssr,
ssg,
}
cachedPageStaticInfo.set(pageFilePath, [Date.now(), info])
return info
}
export function invalidatePageRuntimeCache(
pageFilePath: string,
safeTime: number
) {
const cached = cachedPageStaticInfo.get(pageFilePath)
if (cached && cached[0] < safeTime) {
cachedPageStaticInfo.delete(pageFilePath)
}
}
interface CreateEntrypointsParams {
buildId: string
config: NextConfigComplete
envFiles: LoadedEnvFiles
isDev?: boolean
pages: { [page: string]: string }
pagesDir: string
previewMode: __ApiPreviewProps
rootDir: string
rootPaths?: Record<string, string>
target: 'server' | 'serverless' | 'experimental-serverless-trace'
viewsDir?: string
viewPaths?: Record<string, string>
pageExtensions: string[]
}
export function getEdgeServerEntry(opts: {
absolutePagePath: string
buildId: string
bundlePath: string
config: NextConfigComplete
isDev: boolean
isServerComponent: boolean
page: string
pages: { [page: string]: string }
}) {
if (opts.page === MIDDLEWARE_FILE) {
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: isServerComponentPage(
opts.config,
opts.absolutePagePath
),
page: opts.page,
stringifiedConfig: JSON.stringify(opts.config),
}
return {
import: `next-middleware-ssr-loader?${stringify(loaderParams)}!`,
layer: opts.isServerComponent ? 'sc_server' : undefined,
}
}
export function getViewsEntry(opts: {
name: string
pagePath: string
viewsDir: string
pageExtensions: string[]
}) {
return {
import: `next-view-loader?${stringify(opts)}!`,
layer: 'sc_server',
}
}
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,
rootDir,
rootPaths,
target,
viewsDir,
viewPaths,
pageExtensions,
} = params
const edgeServer: webpack5.EntryObject = {}
const server: webpack5.EntryObject = {}
const client: webpack5.EntryObject = {}
const getEntryHandler =
(mappings: Record<string, string>, pagesType: 'views' | 'pages' | 'root') =>
async (page: string) => {
const bundleFile = normalizePagePath(page)
const clientBundlePath = posix.join('pages', bundleFile)
const serverBundlePath =
pagesType === 'pages'
? posix.join('pages', bundleFile)
: pagesType === 'views'
? posix.join('views', bundleFile)
: bundleFile.slice(1)
const absolutePagePath = mappings[page]
// Handle paths that have aliases
const pageFilePath = (() => {
if (absolutePagePath.startsWith(PAGES_DIR_ALIAS)) {
return absolutePagePath.replace(PAGES_DIR_ALIAS, pagesDir)
}
if (absolutePagePath.startsWith(VIEWS_DIR_ALIAS) && viewsDir) {
return absolutePagePath.replace(VIEWS_DIR_ALIAS, viewsDir)
}
if (absolutePagePath.startsWith(ROOT_DIR_ALIAS)) {
return absolutePagePath.replace(ROOT_DIR_ALIAS, rootDir)
}
return require.resolve(absolutePagePath)
})()
/**
* When we find a middleware file that is not in the ROOT_DIR we fail.
* There is no need to check on `dev` as this should only happen when
* building for production.
*/
if (
!absolutePagePath.startsWith(ROOT_DIR_ALIAS) &&
/[\\\\/]_middleware$/.test(page)
) {
throw new Error(
`nested Middleware is not allowed (found pages${page}) - https://nextjs.org/docs/messages/nested-middleware`
)
}
const isServerComponent = serverComponentRegex.test(absolutePagePath)
runDependingOnPageType({
page,
pageRuntime: (await getPageStaticInfo(pageFilePath, config, isDev))
.runtime,
onClient: () => {
if (isServerComponent) {
// We skip the initial entries for server component pages and let the
// server compiler inject them instead.
} else {
client[clientBundlePath] = getClientEntry({
absolutePagePath: mappings[page],
page,
})
}
},
onServer: () => {
if (pagesType === 'views' && viewsDir) {
server[serverBundlePath] = getViewsEntry({
name: serverBundlePath,
pagePath: mappings[page],
viewsDir,
pageExtensions,
})
} else if (isTargetLikeServerless(target)) {
if (page !== '/_app' && page !== '/_document') {
server[serverBundlePath] = getServerlessEntry({
...params,
absolutePagePath: mappings[page],
page,
})
}
} else {
server[serverBundlePath] = isServerComponent
? {
import: mappings[page],
layer: 'sc_server',
}
: [mappings[page]]
}
},
onEdgeServer: () => {
edgeServer[serverBundlePath] = getEdgeServerEntry({
...params,
absolutePagePath: mappings[page],
bundlePath: clientBundlePath,
isDev: false,
isServerComponent,
page,
})
},
})
}
if (viewsDir && viewPaths) {
const entryHandler = getEntryHandler(viewPaths, 'views')
await Promise.all(Object.keys(viewPaths).map(entryHandler))
}
if (rootPaths) {
await Promise.all(
Object.keys(rootPaths).map(getEntryHandler(rootPaths, 'root'))
)
}
await Promise.all(Object.keys(pages).map(getEntryHandler(pages, 'pages')))
return {
client,
server,
edgeServer,
}
}
export function runDependingOnPageType<T>(params: {
onClient: () => T
onEdgeServer: () => T
onServer: () => T
page: string
pageRuntime: PageRuntime
}) {
if (params.page === MIDDLEWARE_FILE) {
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,
isServerComponent,
}: {
compilerType?: 'client' | 'server' | 'edge-server'
name: string
value: ObjectValue<webpack5.EntryObject>
isServerComponent?: boolean
}): 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' : isServerComponent ? 'sc_server' : undefined,
...entry,
}
}
if (compilerType === 'edge-server') {
return {
layer: name === MIDDLEWARE_FILENAME ? 'middleware' : undefined,
library: { name: ['_ENTRIES', `middleware_[name]`], type: 'assign' },
runtime: EDGE_RUNTIME_WEBPACK,
asyncChunks: false,
...entry,
}
}
if (
// Client special cases
name !== 'polyfills' &&
name !== CLIENT_STATIC_FILES_RUNTIME_MAIN &&
name !== CLIENT_STATIC_FILES_RUNTIME_MAIN_ROOT &&
name !== CLIENT_STATIC_FILES_RUNTIME_AMP &&
name !== CLIENT_STATIC_FILES_RUNTIME_REACT_REFRESH
) {
return {
dependOn:
name.startsWith('pages/') && name !== 'pages/_app'
? 'pages/_app'
: 'main',
...entry,
}
}
return entry
}