rsnext/packages/next/build/webpack/plugins/flight-client-entry-plugin.ts
Shu Ding 67a5076b9b
Use deterministic module IDs for server (#41066)
Currently, we use the `isClient ? 'deterministic' : 'named'` condition
for module IDs. We did that because in the context of server compiler,
the server graph (RSC) can directly know the module ID of the referenced
module in the client graph (SSR). The client module's ID _is_ the module
reference module's resource path. However, that makes the server bundle
and the manifest file larger because these module IDs cannot be
minified.

In this PR we are changing it to `deterministic`, with another mapping
for server SSR.

## Bug

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

## Feature

- [ ] 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 a 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/examples/adding-examples.md)
2022-09-30 11:32:32 -07:00

526 lines
16 KiB
TypeScript

import { stringify } from 'querystring'
import path from 'path'
import { relative } 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, WEBPACK_LAYERS } from '../../../lib/constants'
import {
COMPILER_NAMES,
EDGE_RUNTIME_WEBPACK,
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
fontLoaderTargets?: string[]
}
const PLUGIN_NAME = 'ClientEntryPlugin'
export const injectedClientEntries = new Map()
export const serverModuleIds = new Map<string, string | number>()
export const edgeServerModuleIds = new Map<string, string | number>()
// 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
fontLoaderTargets?: string[]
constructor(options: Options) {
this.dev = options.dev
this.isEdgeServer = options.isEdgeServer
this.fontLoaderTargets = options.fontLoaderTargets
}
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)
}
}
})
const recordModule = (id: number | string, mod: any) => {
const modResource = mod.resourceResolveData?.path || mod.resource
if (
mod.resourceResolveData?.context?.issuerLayer ===
WEBPACK_LAYERS.server
) {
return
}
if (typeof id !== 'undefined' && modResource) {
// 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(compiler.context, modResource)
if (!ssrNamedModuleId.startsWith('.')) {
// TODO use getModuleId instead
ssrNamedModuleId = `./${ssrNamedModuleId.replace(/\\/g, '/')}`
}
if (this.isEdgeServer) {
edgeServerModuleIds.set(
ssrNamedModuleId.replace(/\/next\/dist\/esm\//, '/next/dist/'),
id
)
} else {
serverModuleIds.set(ssrNamedModuleId, id)
}
}
}
compilation.chunkGroups.forEach((chunkGroup) => {
chunkGroup.chunks.forEach((chunk: webpack.Chunk) => {
const chunkModules = compilation.chunkGraph.getChunkModulesIterable(
chunk
) as Iterable<webpack.NormalModule>
for (const mod of chunkModules) {
const modId = compilation.chunkGraph.getModuleId(mod)
recordModule(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(modId, concatenatedMod)
})
}
}
})
})
})
}
async createClientEntries(compiler: any, compilation: any) {
const promises: Array<
ReturnType<typeof this.injectClientEntryAndSSRModules>
> = []
// Loop over all the entry modules.
function forEachEntryModule(
callback: ({
name,
entryModule,
}: {
name: string
entryModule: any
}) => void
) {
for (const [name, entry] of compilation.entries.entries()) {
// Skip for entries under pages/
if (name.startsWith('pages/')) continue
// 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)
}
})
}
callback({ name, entryModule })
}
}
// For each SC server compilation entry, we need to create its corresponding
// client component entry.
forEachEntryModule(({ name, entryModule }) => {
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] =
this.collectClientComponentsAndCSSForDependency({
layoutOrPageRequest,
compilation,
dependency: layoutOrPageDependency,
})
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',
})
)
})
// After optimizing all the modules, we collect the CSS that are still used.
compilation.hooks.afterOptimizeModules.tap(PLUGIN_NAME, () => {
forEachEntryModule(({ entryModule }) => {
for (const connection of compilation.moduleGraph.getOutgoingConnections(
entryModule
)) {
const layoutOrPageDependency = connection.dependency
const layoutOrPageRequest = connection.dependency.request
const [, cssImports] =
this.collectClientComponentsAndCSSForDependency({
layoutOrPageRequest,
compilation,
dependency: layoutOrPageDependency,
})
Object.assign(flightCSSManifest, cssImports)
}
})
})
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 isFontLoader = this.fontLoaderTargets?.some((fontLoaderTarget) =>
mod.userRequest.startsWith(`${fontLoaderTarget}?`)
)
const modRequest: string | undefined =
!rawRequest.endsWith('.css') &&
!rawRequest.startsWith('.') &&
!rawRequest.startsWith('/') &&
!rawRequest.startsWith(APP_DIR_ALIAS)
? isFontLoader
? mod.userRequest
: 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 = isFontLoader || regexCSS.test(modRequest)
const isClientComponent = isClientComponentModule(mod)
if (isCSS) {
const sideEffectFree =
mod.factoryMeta && (mod.factoryMeta as any).sideEffectFree
if (sideEffectFree) {
const unused = !compilation.moduleGraph
.getExportsInfo(mod)
.isModuleUsed(
this.isEdgeServer ? EDGE_RUNTIME_WEBPACK : 'webpack-runtime'
)
if (unused) {
return
}
}
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)
}
)
})
}
}