11deaaa82b
# 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>
405 lines
12 KiB
TypeScript
405 lines
12 KiB
TypeScript
import { stringify } from 'querystring'
|
|
import path from 'path'
|
|
import { webpack, sources } from 'next/dist/compiled/webpack/webpack'
|
|
import {
|
|
getInvalidator,
|
|
entries,
|
|
EntryTypes,
|
|
} from '../../../server/dev/on-demand-entry-handler'
|
|
import type {
|
|
CssImports,
|
|
ClientComponentImports,
|
|
NextFlightClientEntryLoaderOptions,
|
|
} from '../loaders/next-flight-client-entry-loader'
|
|
import { APP_DIR_ALIAS } from '../../../lib/constants'
|
|
import {
|
|
COMPILER_NAMES,
|
|
FLIGHT_SERVER_CSS_MANIFEST,
|
|
} from '../../../shared/lib/constants'
|
|
import { FlightCSSManifest, traverseModules } from './flight-manifest-plugin'
|
|
import { ASYNC_CLIENT_MODULES } from './flight-manifest-plugin'
|
|
import { isClientComponentModule } from '../loaders/utils'
|
|
|
|
interface Options {
|
|
dev: boolean
|
|
isEdgeServer: boolean
|
|
}
|
|
|
|
const PLUGIN_NAME = 'ClientEntryPlugin'
|
|
|
|
export const injectedClientEntries = new Map()
|
|
|
|
// TODO-APP: ensure .scss / .sass also works.
|
|
const regexCSS = /\.css$/
|
|
|
|
// TODO-APP: move CSS manifest generation to the flight manifest plugin.
|
|
const flightCSSManifest: FlightCSSManifest = {}
|
|
|
|
export class FlightClientEntryPlugin {
|
|
dev: boolean
|
|
isEdgeServer: boolean
|
|
|
|
constructor(options: Options) {
|
|
this.dev = options.dev
|
|
this.isEdgeServer = options.isEdgeServer
|
|
}
|
|
|
|
apply(compiler: webpack.Compiler) {
|
|
compiler.hooks.compilation.tap(
|
|
PLUGIN_NAME,
|
|
(compilation, { normalModuleFactory }) => {
|
|
compilation.dependencyFactories.set(
|
|
(webpack as any).dependencies.ModuleDependency,
|
|
normalModuleFactory
|
|
)
|
|
compilation.dependencyTemplates.set(
|
|
(webpack as any).dependencies.ModuleDependency,
|
|
new (webpack as any).dependencies.NullDependency.Template()
|
|
)
|
|
}
|
|
)
|
|
|
|
compiler.hooks.finishMake.tapPromise(PLUGIN_NAME, (compilation) => {
|
|
return this.createClientEntries(compiler, compilation)
|
|
})
|
|
|
|
compiler.hooks.afterCompile.tap(PLUGIN_NAME, (compilation) => {
|
|
traverseModules(compilation, (mod) => {
|
|
// The module must has request, and resource so it's not a new entry created with loader.
|
|
// Using the client layer module, which doesn't have `rsc` tag in buildInfo.
|
|
if (mod.request && mod.resource && !mod.buildInfo.rsc) {
|
|
if (compilation.moduleGraph.isAsync(mod)) {
|
|
ASYNC_CLIENT_MODULES.add(mod.resource)
|
|
}
|
|
}
|
|
})
|
|
})
|
|
}
|
|
|
|
async createClientEntries(compiler: any, compilation: any) {
|
|
const promises: Array<
|
|
ReturnType<typeof this.injectClientEntryAndSSRModules>
|
|
> = []
|
|
|
|
// For each SC server compilation entry, we need to create its corresponding
|
|
// client component entry.
|
|
for (const [name, entry] of compilation.entries.entries()) {
|
|
// Check if the page entry is a server component or not.
|
|
const entryDependency: webpack.NormalModule | undefined =
|
|
entry.dependencies?.[0]
|
|
// Ensure only next-app-loader entries are handled.
|
|
if (!entryDependency || !entryDependency.request) continue
|
|
|
|
const request = entryDependency.request
|
|
|
|
if (
|
|
!request.startsWith('next-edge-ssr-loader?') &&
|
|
!request.startsWith('next-app-loader?')
|
|
)
|
|
continue
|
|
|
|
let entryModule: webpack.NormalModule =
|
|
compilation.moduleGraph.getResolvedModule(entryDependency)
|
|
|
|
if (request.startsWith('next-edge-ssr-loader?')) {
|
|
entryModule.dependencies.forEach((dependency) => {
|
|
const modRequest: string | undefined = (dependency as any).request
|
|
if (modRequest?.includes('next-app-loader')) {
|
|
entryModule = compilation.moduleGraph.getResolvedModule(dependency)
|
|
}
|
|
})
|
|
}
|
|
|
|
const internalClientComponentEntryImports = new Set<
|
|
ClientComponentImports[0]
|
|
>()
|
|
|
|
for (const connection of compilation.moduleGraph.getOutgoingConnections(
|
|
entryModule
|
|
)) {
|
|
const layoutOrPageDependency = connection.dependency
|
|
const layoutOrPageRequest = connection.dependency.request
|
|
|
|
const [clientComponentImports, cssImports] =
|
|
this.collectClientComponentsAndCSSForDependency({
|
|
layoutOrPageRequest,
|
|
compilation,
|
|
dependency: layoutOrPageDependency,
|
|
})
|
|
|
|
Object.assign(flightCSSManifest, cssImports)
|
|
|
|
const isAbsoluteRequest = layoutOrPageRequest[0] === '/'
|
|
|
|
// Next.js internals are put into a separate entry.
|
|
if (!isAbsoluteRequest) {
|
|
clientComponentImports.forEach((value) =>
|
|
internalClientComponentEntryImports.add(value)
|
|
)
|
|
continue
|
|
}
|
|
|
|
const relativeRequest = isAbsoluteRequest
|
|
? path.relative(compilation.options.context, layoutOrPageRequest)
|
|
: layoutOrPageRequest
|
|
|
|
// Replace file suffix as `.js` will be added.
|
|
const bundlePath = relativeRequest.replace(/\.(js|ts)x?$/, '')
|
|
|
|
promises.push(
|
|
this.injectClientEntryAndSSRModules({
|
|
compiler,
|
|
compilation,
|
|
entryName: name,
|
|
clientComponentImports,
|
|
bundlePath,
|
|
})
|
|
)
|
|
}
|
|
|
|
// Create internal app
|
|
promises.push(
|
|
this.injectClientEntryAndSSRModules({
|
|
compiler,
|
|
compilation,
|
|
entryName: name,
|
|
clientComponentImports: [...internalClientComponentEntryImports],
|
|
bundlePath: 'app-internals',
|
|
})
|
|
)
|
|
}
|
|
|
|
compilation.hooks.processAssets.tap(
|
|
{
|
|
name: PLUGIN_NAME,
|
|
// Have to be in the optimize stage to run after updating the CSS
|
|
// asset hash via extract mini css plugin.
|
|
stage: webpack.Compilation.PROCESS_ASSETS_STAGE_OPTIMIZE_HASH,
|
|
},
|
|
(assets: webpack.Compilation['assets']) => {
|
|
const manifest = JSON.stringify(flightCSSManifest)
|
|
assets[FLIGHT_SERVER_CSS_MANIFEST + '.json'] = new sources.RawSource(
|
|
manifest
|
|
) as unknown as webpack.sources.RawSource
|
|
assets[FLIGHT_SERVER_CSS_MANIFEST + '.js'] = new sources.RawSource(
|
|
'self.__RSC_CSS_MANIFEST=' + manifest
|
|
) as unknown as webpack.sources.RawSource
|
|
}
|
|
)
|
|
|
|
const res = await Promise.all(promises)
|
|
|
|
// Invalidate in development to trigger recompilation
|
|
const invalidator = getInvalidator()
|
|
// Check if any of the entry injections need an invalidation
|
|
if (invalidator && res.includes(true)) {
|
|
invalidator.invalidate([COMPILER_NAMES.client])
|
|
}
|
|
}
|
|
|
|
collectClientComponentsAndCSSForDependency({
|
|
layoutOrPageRequest,
|
|
compilation,
|
|
dependency,
|
|
}: {
|
|
layoutOrPageRequest: string
|
|
compilation: any
|
|
dependency: any /* Dependency */
|
|
}): [ClientComponentImports, CssImports] {
|
|
/**
|
|
* Keep track of checked modules to avoid infinite loops with recursive imports.
|
|
*/
|
|
const visitedBySegment: { [segment: string]: Set<string> } = {}
|
|
const clientComponentImports: ClientComponentImports = []
|
|
const serverCSSImports: CssImports = {}
|
|
|
|
const filterClientComponents = (
|
|
dependencyToFilter: any,
|
|
inClientComponentBoundary: boolean
|
|
): void => {
|
|
const mod: webpack.NormalModule =
|
|
compilation.moduleGraph.getResolvedModule(dependencyToFilter)
|
|
if (!mod) return
|
|
|
|
// Keep client imports as simple
|
|
// native or installed js module: -> raw request, e.g. next/head
|
|
// client js or css: -> user request
|
|
const rawRequest = mod.rawRequest
|
|
|
|
// Request could be undefined or ''
|
|
if (!rawRequest) return
|
|
|
|
const modRequest: string | undefined =
|
|
!rawRequest.endsWith('.css') &&
|
|
!rawRequest.startsWith('.') &&
|
|
!rawRequest.startsWith('/') &&
|
|
!rawRequest.startsWith(APP_DIR_ALIAS)
|
|
? rawRequest
|
|
: mod.resourceResolveData?.path
|
|
|
|
// Ensure module is not walked again if it's already been visited
|
|
if (!visitedBySegment[layoutOrPageRequest]) {
|
|
visitedBySegment[layoutOrPageRequest] = new Set()
|
|
}
|
|
if (
|
|
!modRequest ||
|
|
visitedBySegment[layoutOrPageRequest].has(modRequest)
|
|
) {
|
|
return
|
|
}
|
|
visitedBySegment[layoutOrPageRequest].add(modRequest)
|
|
|
|
const isCSS = regexCSS.test(modRequest)
|
|
const isClientComponent = isClientComponentModule(mod)
|
|
|
|
if (isCSS) {
|
|
serverCSSImports[layoutOrPageRequest] =
|
|
serverCSSImports[layoutOrPageRequest] || []
|
|
serverCSSImports[layoutOrPageRequest].push(modRequest)
|
|
}
|
|
|
|
// Check if request is for css file.
|
|
if ((!inClientComponentBoundary && isClientComponent) || isCSS) {
|
|
clientComponentImports.push(modRequest)
|
|
return
|
|
}
|
|
|
|
compilation.moduleGraph
|
|
.getOutgoingConnections(mod)
|
|
.forEach((connection: any) => {
|
|
filterClientComponents(
|
|
connection.dependency,
|
|
inClientComponentBoundary || isClientComponent
|
|
)
|
|
})
|
|
}
|
|
|
|
// Traverse the module graph to find all client components.
|
|
filterClientComponents(dependency, false)
|
|
|
|
return [clientComponentImports, serverCSSImports]
|
|
}
|
|
|
|
async injectClientEntryAndSSRModules({
|
|
compiler,
|
|
compilation,
|
|
entryName,
|
|
clientComponentImports,
|
|
bundlePath,
|
|
}: {
|
|
compiler: any
|
|
compilation: any
|
|
entryName: string
|
|
clientComponentImports: ClientComponentImports
|
|
bundlePath: string
|
|
}): Promise<boolean> {
|
|
let shouldInvalidate = false
|
|
|
|
const loaderOptions: NextFlightClientEntryLoaderOptions = {
|
|
modules: clientComponentImports,
|
|
server: false,
|
|
}
|
|
|
|
// For the client entry, we always use the CJS build of Next.js. If the
|
|
// server is using the ESM build (when using the Edge runtime), we need to
|
|
// replace them.
|
|
const clientLoader = `next-flight-client-entry-loader?${stringify({
|
|
modules: this.isEdgeServer
|
|
? clientComponentImports.map((importPath) =>
|
|
importPath.replace('next/dist/esm/', 'next/dist/')
|
|
)
|
|
: clientComponentImports,
|
|
server: false,
|
|
})}!`
|
|
|
|
const clientSSRLoader = `next-flight-client-entry-loader?${stringify({
|
|
...loaderOptions,
|
|
server: true,
|
|
})}!`
|
|
|
|
// Add for the client compilation
|
|
// Inject the entry to the client compiler.
|
|
if (this.dev) {
|
|
const pageKey = COMPILER_NAMES.client + bundlePath
|
|
if (!entries[pageKey]) {
|
|
entries[pageKey] = {
|
|
type: EntryTypes.CHILD_ENTRY,
|
|
parentEntries: new Set([entryName]),
|
|
bundlePath,
|
|
request: clientLoader,
|
|
dispose: false,
|
|
lastActiveTime: Date.now(),
|
|
}
|
|
shouldInvalidate = true
|
|
} else {
|
|
const entryData = entries[pageKey]
|
|
// New version of the client loader
|
|
if (entryData.request !== clientLoader) {
|
|
entryData.request = clientLoader
|
|
shouldInvalidate = true
|
|
}
|
|
if (entryData.type === EntryTypes.CHILD_ENTRY) {
|
|
entryData.parentEntries.add(entryName)
|
|
}
|
|
}
|
|
} else {
|
|
injectedClientEntries.set(bundlePath, clientLoader)
|
|
}
|
|
|
|
// Inject the entry to the server compiler (__sc_client__).
|
|
const clientComponentEntryDep = (
|
|
webpack as any
|
|
).EntryPlugin.createDependency(clientSSRLoader, {
|
|
name: bundlePath,
|
|
})
|
|
|
|
// Add the dependency to the server compiler.
|
|
await this.addEntry(
|
|
compilation,
|
|
// Reuse compilation context.
|
|
compiler.context,
|
|
clientComponentEntryDep,
|
|
{
|
|
// By using the same entry name
|
|
name: entryName,
|
|
// Layer should be undefined for the SSR modules
|
|
// This ensures the client components are
|
|
layer: undefined,
|
|
}
|
|
)
|
|
|
|
return shouldInvalidate
|
|
}
|
|
|
|
// TODO-APP: make sure dependsOn is added for layouts/pages
|
|
addEntry(
|
|
compilation: any,
|
|
context: string,
|
|
entry: any /* Dependency */,
|
|
options: {
|
|
name: string
|
|
layer: string | undefined
|
|
} /* EntryOptions */
|
|
): Promise<any> /* Promise<module> */ {
|
|
return new Promise((resolve, reject) => {
|
|
compilation.entries.get(options.name).includeDependencies.push(entry)
|
|
compilation.hooks.addEntry.call(entry, options)
|
|
compilation.addModuleTree(
|
|
{
|
|
context,
|
|
dependency: entry,
|
|
contextInfo: { issuerLayer: options.layer },
|
|
},
|
|
(err: Error | undefined, module: any) => {
|
|
if (err) {
|
|
compilation.hooks.failedEntry.call(entry, options, err)
|
|
return reject(err)
|
|
}
|
|
|
|
compilation.hooks.succeedEntry.call(entry, options, module)
|
|
return resolve(module)
|
|
}
|
|
)
|
|
})
|
|
}
|
|
}
|