67a5076b9b
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)
526 lines
16 KiB
TypeScript
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)
|
|
}
|
|
)
|
|
})
|
|
}
|
|
}
|