rsnext/packages/next/build/entries.ts
Jimmy Lai 11deaaa82b
edge-ssr: bundle next/dist as ESM for better tree-shaking (#40251)
# Context

Edge SSR'd routes cold boot performances are proportional to the
executed code size.

In order to improve it, we are trying to optimize for the bundle size of
a packed Edge SSR route.

This PR adds ESM compilation targets for all Next.js dist packages and
use them to bundle Edge SSR'd route.

This allows us to leverage the better tree shaking/DCE for ESM modules
in webpack in order to decrease the overall bundle size.

This PR also enables minifying Edge SSR routes. Since we don't control
which minifier might be used later (if any), it's best if we provide an
already optimised bundle.

<img width="903" alt="image"
src="https://user-images.githubusercontent.com/11064311/190005211-b7cb2c58-a56a-44b0-8ee4-fd3f603e41bd.png">

This is a 10ms cold boot win per my benchmarking script, which I'll put
in a subsequent PR.

Not done yet:
- ~~swap exported requires in `next/link` (and others) etc to point them
to the esm modules version~~

<!--
Thanks for opening a PR! Your contribution is much appreciated.
In order to make sure your PR is handled as smoothly as possible we
request that you follow the checklist sections below.
Choose the right checklist for the change that you're making:
-->

## Bug

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

## Feature

- [x] 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 `pnpm lint`
- [ ] The examples guidelines are followed from [our contributing
doc](https://github.com/vercel/next.js/blob/canary/contributing.md#adding-examples)

Co-authored-by: JJ Kasper <jj@jjsweb.site>
Co-authored-by: Shu Ding <g@shud.in>
2022-09-26 16:56:16 -07:00

598 lines
17 KiB
TypeScript

import type { ClientPagesLoaderOptions } from './webpack/loaders/next-client-pages-loader'
import type { MiddlewareLoaderOptions } from './webpack/loaders/next-middleware-loader'
import type { EdgeSSRLoaderQuery } from './webpack/loaders/next-edge-ssr-loader'
import type { NextConfigComplete } from '../server/config-shared'
import type { ServerlessLoaderQuery } from './webpack/loaders/next-serverless-loader'
import type { webpack } from 'next/dist/compiled/webpack/webpack'
import type {
MiddlewareConfig,
MiddlewareMatcher,
} from './analysis/get-page-static-info'
import type { LoadedEnvFiles } from '@next/env'
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,
ROOT_DIR_ALIAS,
APP_DIR_ALIAS,
SERVER_RUNTIME,
WEBPACK_LAYERS,
} from '../lib/constants'
import { RSC_MODULE_TYPES } from '../shared/lib/constants'
import {
CLIENT_STATIC_FILES_RUNTIME_AMP,
CLIENT_STATIC_FILES_RUNTIME_MAIN,
CLIENT_STATIC_FILES_RUNTIME_MAIN_APP,
CLIENT_STATIC_FILES_RUNTIME_POLYFILLS,
CLIENT_STATIC_FILES_RUNTIME_REACT_REFRESH,
CompilerNameValues,
COMPILER_NAMES,
EDGE_RUNTIME_WEBPACK,
} from '../shared/lib/constants'
import { __ApiPreviewProps } from '../server/api-utils'
import { isTargetLikeServerless } from '../server/utils'
import { warn } from './output/log'
import {
isMiddlewareFile,
isMiddlewareFilename,
NestedMiddlewareError,
MiddlewareInServerlessTargetError,
} from './utils'
import { getPageStaticInfo } from './analysis/get-page-static-info'
import { normalizePathSep } from '../shared/lib/page-path/normalize-path-sep'
import { normalizePagePath } from '../shared/lib/page-path/normalize-page-path'
import { ServerRuntime } from '../types'
import { normalizeAppPath } from '../shared/lib/router/utils/app-paths'
import { encodeMatchers } from './webpack/loaders/next-middleware-loader'
import { EdgeFunctionLoaderOptions } from './webpack/loaders/next-edge-function-loader'
type ObjectValue<T> = T extends { [key: string]: infer V } ? V : never
/**
* For a given page path removes the provided extensions.
*/
export function getPageFromPath(pagePath: string, pageExtensions: string[]) {
let page = normalizePathSep(
pagePath.replace(new RegExp(`\\.+(${pageExtensions.join('|')})$`), '')
)
page = page.replace(/\/index$/, '')
return page === '' ? '/' : page
}
export function createPagesMapping({
isDev,
pageExtensions,
pagePaths,
pagesType,
pagesDir,
}: {
isDev: boolean
pageExtensions: string[]
pagePaths: string[]
pagesType: 'pages' | 'root' | 'app'
pagesDir: string | undefined
}): { [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)
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 === 'app'
? APP_DIR_ALIAS
: ROOT_DIR_ALIAS,
pagePath
)
)
return result
},
{}
)
if (pagesType !== 'pages') {
return pages
}
if (isDev) {
delete pages['/_app']
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 && pagesDir ? PAGES_DIR_ALIAS : 'next/dist/pages'
return {
'/_app': `${root}/_app`,
'/_error': `${root}/_error`,
'/_document': `${root}/_document`,
...pages,
}
}
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'
appDir?: string
appPaths?: Record<string, string>
pageExtensions: string[]
}
export function getEdgeServerEntry(opts: {
rootDir: string
absolutePagePath: string
buildId: string
bundlePath: string
config: NextConfigComplete
isDev: boolean
isServerComponent: boolean
page: string
pages: { [page: string]: string }
middleware?: Partial<MiddlewareConfig>
pagesType?: 'app' | 'pages' | 'root'
appDirLoader?: string
}) {
if (isMiddlewareFile(opts.page)) {
const loaderParams: MiddlewareLoaderOptions = {
absolutePagePath: opts.absolutePagePath,
page: opts.page,
rootDir: opts.rootDir,
matchers: opts.middleware?.matchers
? encodeMatchers(opts.middleware.matchers)
: '',
}
return `next-middleware-loader?${stringify(loaderParams)}!`
}
if (opts.page.startsWith('/api/') || opts.page === '/api') {
const loaderParams: EdgeFunctionLoaderOptions = {
absolutePagePath: opts.absolutePagePath,
page: opts.page,
rootDir: opts.rootDir,
}
return `next-edge-function-loader?${stringify(loaderParams)}!`
}
const loaderParams: EdgeSSRLoaderQuery = {
absolute500Path: opts.pages['/500'] || '',
absoluteAppPath: opts.pages['/_app'],
absoluteDocumentPath: opts.pages['/_document'],
absoluteErrorPath: opts.pages['/_error'],
absolutePagePath: opts.absolutePagePath,
buildId: opts.buildId,
dev: opts.isDev,
isServerComponent: opts.isServerComponent,
page: opts.page,
stringifiedConfig: JSON.stringify(opts.config),
pagesType: opts.pagesType,
appDirLoader: Buffer.from(opts.appDirLoader || '').toString('base64'),
sriEnabled: !opts.isDev && !!opts.config.experimental.sri?.algorithm,
hasFontLoaders: !!opts.config.experimental.fontLoaders,
}
return {
import: `next-edge-ssr-loader?${stringify(loaderParams)}!`,
layer: opts.isServerComponent ? WEBPACK_LAYERS.server : undefined,
}
}
export function getAppEntry(opts: {
name: string
pagePath: string
appDir: string
appPaths: string[] | null
pageExtensions: string[]
nextRuntime: string
}) {
return {
import: `next-app-loader?${stringify(opts)}!`,
layer: WEBPACK_LAYERS.server,
}
}
export function getServerlessEntry(opts: {
absolutePagePath: string
buildId: string
config: NextConfigComplete
envFiles: LoadedEnvFiles
page: string
previewMode: __ApiPreviewProps
pages: { [page: string]: string }
}): ObjectValue<webpack.EntryObject> {
const loaderParams: ServerlessLoaderQuery = {
absolute404Path: opts.pages['/404'] || '',
absoluteAppPath: opts.pages['/_app'],
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),
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 runDependingOnPageType<T>(params: {
onClient: () => T
onEdgeServer: () => T
onServer: () => T
page: string
pageRuntime: ServerRuntime
}): Promise<void> {
if (isMiddlewareFile(params.page)) {
await params.onEdgeServer()
return
}
if (params.page.match(API_ROUTE)) {
if (params.pageRuntime === SERVER_RUNTIME.edge) {
await params.onEdgeServer()
return
}
await params.onServer()
return
}
if (params.page === '/_document') {
await params.onServer()
return
}
if (
params.page === '/_app' ||
params.page === '/_error' ||
params.page === '/404' ||
params.page === '/500'
) {
await Promise.all([params.onClient(), params.onServer()])
return
}
if (params.pageRuntime === SERVER_RUNTIME.edge) {
await Promise.all([params.onClient(), params.onEdgeServer()])
return
}
await Promise.all([params.onClient(), params.onServer()])
return
}
export async function createEntrypoints(params: CreateEntrypointsParams) {
const {
config,
pages,
pagesDir,
isDev,
rootDir,
rootPaths,
target,
appDir,
appPaths,
pageExtensions,
} = params
const edgeServer: webpack.EntryObject = {}
const server: webpack.EntryObject = {}
const client: webpack.EntryObject = {}
const nestedMiddleware: string[] = []
let middlewareMatchers: MiddlewareMatcher[] | undefined = undefined
let appPathsPerRoute: Record<string, string[]> = {}
if (appDir && appPaths) {
for (const pathname in appPaths) {
const normalizedPath = normalizeAppPath(pathname) || '/'
if (!appPathsPerRoute[normalizedPath]) {
appPathsPerRoute[normalizedPath] = []
}
appPathsPerRoute[normalizedPath].push(pathname)
}
// Make sure to sort parallel routes to make the result deterministic.
appPathsPerRoute = Object.fromEntries(
Object.entries(appPathsPerRoute).map(([k, v]) => [k, v.sort()])
)
}
const getEntryHandler =
(mappings: Record<string, string>, pagesType: 'app' | 'pages' | 'root') =>
async (page: string) => {
const bundleFile = normalizePagePath(page)
const clientBundlePath = posix.join(pagesType, bundleFile)
const serverBundlePath =
pagesType === 'pages'
? posix.join('pages', bundleFile)
: pagesType === 'app'
? posix.join('app', bundleFile)
: bundleFile.slice(1)
const absolutePagePath = mappings[page]
// Handle paths that have aliases
const pageFilePath = (() => {
if (absolutePagePath.startsWith(PAGES_DIR_ALIAS) && pagesDir) {
return absolutePagePath.replace(PAGES_DIR_ALIAS, pagesDir)
}
if (absolutePagePath.startsWith(APP_DIR_ALIAS) && appDir) {
return absolutePagePath.replace(APP_DIR_ALIAS, appDir)
}
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)
) {
nestedMiddleware.push(page)
}
const isInsideAppDir =
!!appDir &&
(absolutePagePath.startsWith(APP_DIR_ALIAS) ||
absolutePagePath.startsWith(appDir))
const staticInfo = await getPageStaticInfo({
nextConfig: config,
pageFilePath,
isDev,
page,
})
const isServerComponent =
isInsideAppDir && staticInfo.rsc !== RSC_MODULE_TYPES.client
if (isMiddlewareFile(page)) {
middlewareMatchers = staticInfo.middleware?.matchers ?? [
{ regexp: '.*' },
]
if (target === 'serverless') {
throw new MiddlewareInServerlessTargetError()
}
}
await runDependingOnPageType({
page,
pageRuntime: staticInfo.runtime,
onClient: () => {
if (isServerComponent || isInsideAppDir) {
// 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 === 'app' && appDir) {
const matchedAppPaths =
appPathsPerRoute[normalizeAppPath(page) || '/']
server[serverBundlePath] = getAppEntry({
name: serverBundlePath,
pagePath: mappings[page],
appDir,
appPaths: matchedAppPaths,
pageExtensions,
nextRuntime: 'nodejs',
})
} else if (isTargetLikeServerless(target)) {
if (page !== '/_app' && page !== '/_document') {
server[serverBundlePath] = getServerlessEntry({
...params,
absolutePagePath: mappings[page],
page,
})
}
} else {
server[serverBundlePath] = [mappings[page]]
}
},
onEdgeServer: () => {
let appDirLoader: string = ''
if (pagesType === 'app') {
const matchedAppPaths =
appPathsPerRoute[normalizeAppPath(page) || '/']
appDirLoader = getAppEntry({
name: serverBundlePath,
pagePath: mappings[page],
appDir: appDir!,
appPaths: matchedAppPaths,
pageExtensions,
nextRuntime: 'edge',
}).import
}
edgeServer[serverBundlePath] = getEdgeServerEntry({
...params,
rootDir,
absolutePagePath: mappings[page],
bundlePath: clientBundlePath,
isDev: false,
isServerComponent,
page,
middleware: staticInfo?.middleware,
pagesType,
appDirLoader,
})
},
})
}
if (appDir && appPaths) {
const entryHandler = getEntryHandler(appPaths, 'app')
await Promise.all(Object.keys(appPaths).map(entryHandler))
}
if (rootPaths) {
await Promise.all(
Object.keys(rootPaths).map(getEntryHandler(rootPaths, 'root'))
)
}
await Promise.all(Object.keys(pages).map(getEntryHandler(pages, 'pages')))
if (nestedMiddleware.length > 0) {
throw new NestedMiddlewareError(nestedMiddleware, rootDir, pagesDir!)
}
return {
client,
server,
edgeServer,
middlewareMatchers,
}
}
export function finalizeEntrypoint({
name,
compilerType,
value,
isServerComponent,
appDir,
}: {
compilerType?: CompilerNameValues
name: string
value: ObjectValue<webpack.EntryObject>
isServerComponent?: boolean
appDir?: boolean
}): ObjectValue<webpack.EntryObject> {
const entry =
typeof value !== 'object' || Array.isArray(value)
? { import: value }
: value
const isApi = name.startsWith('pages/api/')
if (compilerType === COMPILER_NAMES.server) {
return {
publicPath: isApi ? '' : undefined,
runtime: isApi ? 'webpack-api-runtime' : 'webpack-runtime',
layer: isApi
? WEBPACK_LAYERS.api
: isServerComponent
? WEBPACK_LAYERS.server
: undefined,
...entry,
}
}
if (compilerType === COMPILER_NAMES.edgeServer) {
return {
layer:
isMiddlewareFilename(name) || isApi
? WEBPACK_LAYERS.middleware
: undefined,
library: { name: ['_ENTRIES', `middleware_[name]`], type: 'assign' },
runtime: EDGE_RUNTIME_WEBPACK,
asyncChunks: false,
...entry,
}
}
if (
// Client special cases
name !== CLIENT_STATIC_FILES_RUNTIME_POLYFILLS &&
name !== CLIENT_STATIC_FILES_RUNTIME_MAIN &&
name !== CLIENT_STATIC_FILES_RUNTIME_MAIN_APP &&
name !== CLIENT_STATIC_FILES_RUNTIME_AMP &&
name !== CLIENT_STATIC_FILES_RUNTIME_REACT_REFRESH
) {
// TODO-APP: this is a temporary fix. @shuding is going to change the handling of server components
if (appDir && entry.import.includes('flight')) {
return {
dependOn: CLIENT_STATIC_FILES_RUNTIME_MAIN_APP,
...entry,
}
}
return {
dependOn:
name.startsWith('pages/') && name !== 'pages/_app'
? 'pages/_app'
: CLIENT_STATIC_FILES_RUNTIME_MAIN,
...entry,
}
}
return entry
}