rsnext/packages/next/build/webpack/plugins/flight-manifest-plugin.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

369 lines
12 KiB
TypeScript

/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import { webpack, sources } from 'next/dist/compiled/webpack/webpack'
import { FLIGHT_MANIFEST } from '../../../shared/lib/constants'
import { relative } from 'path'
import { isClientComponentModule } from '../loaders/utils'
// This is the module that will be used to anchor all client references to.
// I.e. it will have all the client files as async deps from this point on.
// We use the Flight client implementation because you can't get to these
// without the client runtime so it's the first time in the loading sequence
// you might want them.
// const clientFileName = require.resolve('../');
interface Options {
dev: boolean
}
/**
* Webpack module id
*/
// TODO-APP ensure `null` is included as it is used.
type ModuleId = string | number /*| null*/
export type ManifestChunks = Array<`${string}:${string}` | string>
interface ManifestNode {
[moduleExport: string]: {
/**
* Webpack module id
*/
id: ModuleId
/**
* Export name
*/
name: string
/**
* Chunks for the module. JS and CSS.
*/
chunks: ManifestChunks
/**
* If chunk contains async module
*/
async?: boolean
}
}
export type FlightManifest = {
__ssr_module_mapping__: {
[moduleId: string]: ManifestNode
}
__edge_ssr_module_mapping__: {
[moduleId: string]: ManifestNode
}
} & {
[modulePath: string]: ManifestNode
}
export type FlightCSSManifest = {
[modulePath: string]: string[]
}
const PLUGIN_NAME = 'FlightManifestPlugin'
// Collect modules from server/edge compiler in client layer,
// and detect if it's been used, and mark it as `async: true` for react.
// So that react could unwrap the async module from promise and render module itself.
export const ASYNC_CLIENT_MODULES = new Set<string>()
export function traverseModules(
compilation: webpack.Compilation,
callback: (
mod: any,
chunk: webpack.Chunk,
chunkGroup: typeof compilation.chunkGroups[0]
) => any
) {
compilation.chunkGroups.forEach((chunkGroup) => {
chunkGroup.chunks.forEach((chunk: webpack.Chunk) => {
const chunkModules = compilation.chunkGraph.getChunkModulesIterable(
chunk
// TODO: Update type so that it doesn't have to be cast.
) as Iterable<webpack.NormalModule>
for (const mod of chunkModules) {
callback(mod, chunk, chunkGroup)
const anyModule = mod as any
if (anyModule.modules) {
for (const subMod of anyModule.modules)
callback(subMod, chunk, chunkGroup)
}
}
})
})
}
export class FlightManifestPlugin {
dev: Options['dev'] = false
constructor(options: Options) {
this.dev = options.dev
}
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.make.tap(PLUGIN_NAME, (compilation) => {
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) => this.createAsset(assets, compilation, compiler.context)
)
})
}
createAsset(
assets: webpack.Compilation['assets'],
compilation: webpack.Compilation,
context: string
) {
const manifest: FlightManifest = {
__ssr_module_mapping__: {},
__edge_ssr_module_mapping__: {},
}
const dev = this.dev
const clientRequestsSet = new Set()
// Collect client requests
function collectClientRequest(mod: webpack.NormalModule) {
if (mod.resource === '' && mod.buildInfo.rsc) {
const { requests = [] } = mod.buildInfo.rsc
requests.forEach((r: string) => {
clientRequestsSet.add(r)
})
}
}
traverseModules(compilation, (mod) => collectClientRequest(mod))
compilation.chunkGroups.forEach((chunkGroup) => {
const cssResourcesInChunkGroup = new Set<string>()
let entryFilepath: string = ''
function recordModule(
chunk: webpack.Chunk,
id: ModuleId,
mod: webpack.NormalModule
) {
const isCSSModule =
mod.resource?.endsWith('.css') ||
mod.type === 'css/mini-extract' ||
(!!mod.loaders &&
(dev
? mod.loaders.some((item) =>
item.loader.includes('next-style-loader/index.js')
)
: mod.loaders.some((item) =>
item.loader.includes('mini-css-extract-plugin/loader.js')
)))
const resource =
mod.type === 'css/mini-extract'
? // @ts-expect-error TODO: use `identifier()` instead.
mod._identifier.slice(mod._identifier.lastIndexOf('!') + 1)
: mod.resource
if (!resource) {
return
}
const moduleExports = manifest[resource] || {}
const moduleIdMapping = manifest.__ssr_module_mapping__
const edgeModuleIdMapping = manifest.__edge_ssr_module_mapping__
// Note that this isn't that reliable as webpack is still possible to assign
// additional queries to make sure there's no conflict even using the `named`
// module ID strategy.
let ssrNamedModuleId = relative(
context,
mod.resourceResolveData?.path || resource
)
if (!ssrNamedModuleId.startsWith('.'))
// TODO use getModuleId instead
ssrNamedModuleId = `./${ssrNamedModuleId.replace(/\\/g, '/')}`
if (isCSSModule) {
if (!manifest[resource]) {
const chunks = [...chunk.files].filter((f) => f.endsWith('.css'))
manifest[resource] = {
default: {
id,
name: 'default',
chunks,
},
}
}
if (chunkGroup.name) {
cssResourcesInChunkGroup.add(resource)
}
return
}
// Only apply following logic to client module requests from client entry,
// or if the module is marked as client module.
if (!clientRequestsSet.has(resource) && !isClientComponentModule(mod)) {
return
}
if (/[\\/](page|layout)\.(ts|js)x?$/.test(resource)) {
entryFilepath = resource
}
const exportsInfo = compilation.moduleGraph.getExportsInfo(mod)
const isAsyncModule = ASYNC_CLIENT_MODULES.has(mod.resource)
const cjsExports = [
...new Set([
...mod.dependencies.map((dep) => {
// Match CommonJsSelfReferenceDependency
if (dep.type === 'cjs self exports reference') {
// @ts-expect-error: TODO: Fix Dependency type
if (dep.base === 'module.exports') {
return 'default'
}
// `exports.foo = ...`, `exports.default = ...`
// @ts-expect-error: TODO: Fix Dependency type
if (dep.base === 'exports') {
// @ts-expect-error: TODO: Fix Dependency type
return dep.names.filter((name: any) => name !== '__esModule')
}
}
return null
}),
]),
]
function getAppPathRequiredChunks() {
return chunkGroup.chunks.map((requiredChunk: webpack.Chunk) => {
return (
requiredChunk.id +
':' +
(requiredChunk.name || requiredChunk.id) +
(dev ? '' : '-' + requiredChunk.hash)
)
})
}
const moduleExportedKeys = ['', '*']
.concat(
[...exportsInfo.exports]
.filter((exportInfo) => exportInfo.provided)
.map((exportInfo) => exportInfo.name),
...cjsExports
)
.filter((name) => name !== null)
moduleExportedKeys.forEach((name) => {
// If the chunk is from `app/` chunkGroup, use it first.
// This make sure not to load the overlapped chunk from `pages/` chunkGroup
if (!moduleExports[name] || chunkGroup.name?.startsWith('app/')) {
const requiredChunks = getAppPathRequiredChunks()
moduleExports[name] = {
id,
name,
chunks: requiredChunks,
// E.g.
// page (server) -> local module (client) -> package (esm)
// The esm package will bubble up to make the entire chain till the client entry as async module.
async: isAsyncModule,
}
}
moduleIdMapping[id] = moduleIdMapping[id] || {}
moduleIdMapping[id][name] = {
...moduleExports[name],
id: ssrNamedModuleId,
}
edgeModuleIdMapping[id] = edgeModuleIdMapping[id] || {}
edgeModuleIdMapping[id][name] = {
...moduleExports[name],
id: ssrNamedModuleId.replace(/\/next\/dist\//, '/next/dist/esm/'),
}
})
manifest[resource] = moduleExports
// The client compiler will always use the CJS Next.js build, so here we
// also add the mapping for the ESM build (Edge runtime) to consume.
if (/\/next\/dist\//.test(resource)) {
manifest[resource.replace(/\/next\/dist\//, '/next/dist/esm/')] =
moduleExports
}
manifest.__ssr_module_mapping__ = moduleIdMapping
manifest.__edge_ssr_module_mapping__ = edgeModuleIdMapping
}
chunkGroup.chunks.forEach((chunk: webpack.Chunk) => {
const chunkModules = compilation.chunkGraph.getChunkModulesIterable(
chunk
// TODO: Update type so that it doesn't have to be cast.
) as Iterable<webpack.NormalModule>
for (const mod of chunkModules) {
const modId = compilation.chunkGraph.getModuleId(mod)
recordModule(chunk, modId, mod)
// If this is a concatenation, register each child to the parent ID.
// TODO: remove any
const anyModule = mod as any
if (anyModule.modules) {
anyModule.modules.forEach((concatenatedMod: any) => {
recordModule(chunk, modId, concatenatedMod)
})
}
}
})
const clientCSSManifest: any = manifest.__client_css_manifest__ || {}
if (entryFilepath) {
clientCSSManifest[entryFilepath] = Array.from(cssResourcesInChunkGroup)
}
manifest.__client_css_manifest__ = clientCSSManifest
})
const file = 'server/' + FLIGHT_MANIFEST
const json = JSON.stringify(manifest)
ASYNC_CLIENT_MODULES.clear()
assets[file + '.js'] = new sources.RawSource(
'self.__RSC_MANIFEST=' + json
// Work around webpack 4 type of RawSource being used
// TODO: use webpack 5 type by default
) as unknown as webpack.sources.RawSource
assets[file + '.json'] = new sources.RawSource(
json
// Work around webpack 4 type of RawSource being used
// TODO: use webpack 5 type by default
) as unknown as webpack.sources.RawSource
}
}