import devalue from 'next/dist/compiled/devalue' import { webpack, sources } from 'next/dist/compiled/webpack/webpack' import { BUILD_MANIFEST, MIDDLEWARE_BUILD_MANIFEST, CLIENT_STATIC_FILES_PATH, CLIENT_STATIC_FILES_RUNTIME_MAIN, CLIENT_STATIC_FILES_RUNTIME_POLYFILLS_SYMBOL, CLIENT_STATIC_FILES_RUNTIME_REACT_REFRESH, CLIENT_STATIC_FILES_RUNTIME_AMP, } from '../../../shared/lib/constants' import { BuildManifest } from '../../../server/get-page-files' import getRouteFromEntrypoint from '../../../server/get-route-from-entrypoint' import { ampFirstEntryNamesMap } from './next-drop-client-page-plugin' import { Rewrite } from '../../../lib/load-custom-routes' import { getSortedRoutes } from '../../../shared/lib/router/utils' import { spans } from './profiling-plugin' import { CustomRoutes } from '../../../lib/load-custom-routes' type DeepMutable = { -readonly [P in keyof T]: DeepMutable } export type ClientBuildManifest = Record // This function takes the asset map generated in BuildManifestPlugin and creates a // reduced version to send to the client. function generateClientManifest( compiler: any, compilation: any, assetMap: BuildManifest, rewrites: CustomRoutes['rewrites'] ): string { const compilationSpan = spans.get(compilation) || spans.get(compiler) const genClientManifestSpan = compilationSpan?.traceChild( 'NextJsBuildManifest-generateClientManifest' ) return genClientManifestSpan?.traceFn(() => { const clientManifest: ClientBuildManifest = { // TODO: update manifest type to include rewrites __rewrites: rewrites as any, } const appDependencies = new Set(assetMap.pages['/_app']) const sortedPageKeys = getSortedRoutes(Object.keys(assetMap.pages)) sortedPageKeys.forEach((page) => { const dependencies = assetMap.pages[page] if (page === '/_app') return // Filter out dependencies in the _app entry, because those will have already // been loaded by the client prior to a navigation event const filteredDeps = dependencies.filter( (dep) => !appDependencies.has(dep) ) // The manifest can omit the page if it has no requirements if (filteredDeps.length) { clientManifest[page] = filteredDeps } }) // provide the sorted pages as an array so we don't rely on the object's keys // being in order and we don't slow down look-up time for page assets clientManifest.sortedPages = sortedPageKeys return devalue(clientManifest) }) } function getEntrypointFiles(entrypoint: any): string[] { return ( entrypoint ?.getFiles() .filter((file: string) => { // We don't want to include `.hot-update.js` files into the initial page return /(? file.replace(/\\/g, '/')) ?? [] ) } const processRoute = (r: Rewrite) => { const rewrite = { ...r } // omit external rewrite destinations since these aren't // handled client-side if (!rewrite.destination.startsWith('/')) { delete (rewrite as any).destination } return rewrite } // This plugin creates a build-manifest.json for all assets that are being output // It has a mapping of "entry" filename to real filename. Because the real filename can be hashed in production export default class BuildManifestPlugin { private buildId: string private rewrites: CustomRoutes['rewrites'] private isDevFallback: boolean private exportRuntime: boolean constructor(options: { buildId: string rewrites: CustomRoutes['rewrites'] isDevFallback?: boolean exportRuntime?: boolean }) { this.buildId = options.buildId this.isDevFallback = !!options.isDevFallback this.rewrites = { beforeFiles: [], afterFiles: [], fallback: [], } this.rewrites.beforeFiles = options.rewrites.beforeFiles.map(processRoute) this.rewrites.afterFiles = options.rewrites.afterFiles.map(processRoute) this.rewrites.fallback = options.rewrites.fallback.map(processRoute) this.exportRuntime = !!options.exportRuntime } createAssets(compiler: any, compilation: any, assets: any) { const compilationSpan = spans.get(compilation) || spans.get(compiler) const createAssetsSpan = compilationSpan?.traceChild( 'NextJsBuildManifest-createassets' ) return createAssetsSpan?.traceFn(() => { const entrypoints: Map = compilation.entrypoints const assetMap: DeepMutable = { polyfillFiles: [], devFiles: [], ampDevFiles: [], lowPriorityFiles: [], pages: { '/_app': [] }, ampFirstPages: [], } const ampFirstEntryNames = ampFirstEntryNamesMap.get(compilation) if (ampFirstEntryNames) { for (const entryName of ampFirstEntryNames) { const pagePath = getRouteFromEntrypoint(entryName) if (!pagePath) { continue } assetMap.ampFirstPages.push(pagePath) } } const mainFiles = new Set( getEntrypointFiles(entrypoints.get(CLIENT_STATIC_FILES_RUNTIME_MAIN)) ) const compilationAssets: { name: string source: typeof sources.RawSource info: object }[] = compilation.getAssets() assetMap.polyfillFiles = compilationAssets .filter((p) => { // Ensure only .js files are passed through if (!p.name.endsWith('.js')) { return false } return ( p.info && CLIENT_STATIC_FILES_RUNTIME_POLYFILLS_SYMBOL in p.info ) }) .map((v) => v.name) assetMap.devFiles = getEntrypointFiles( entrypoints.get(CLIENT_STATIC_FILES_RUNTIME_REACT_REFRESH) ).filter((file) => !mainFiles.has(file)) assetMap.ampDevFiles = getEntrypointFiles( entrypoints.get(CLIENT_STATIC_FILES_RUNTIME_AMP) ) const systemEntrypoints = new Set([ CLIENT_STATIC_FILES_RUNTIME_MAIN, CLIENT_STATIC_FILES_RUNTIME_REACT_REFRESH, CLIENT_STATIC_FILES_RUNTIME_AMP, ]) for (const entrypoint of compilation.entrypoints.values()) { if (systemEntrypoints.has(entrypoint.name)) continue const pagePath = getRouteFromEntrypoint(entrypoint.name) if (!pagePath) { continue } const filesForPage = getEntrypointFiles(entrypoint) assetMap.pages[pagePath] = [...new Set([...mainFiles, ...filesForPage])] } if (!this.isDevFallback) { // Add the runtime build manifest file (generated later in this file) // as a dependency for the app. If the flag is false, the file won't be // downloaded by the client. assetMap.lowPriorityFiles.push( `${CLIENT_STATIC_FILES_PATH}/${this.buildId}/_buildManifest.js` ) // Add the runtime ssg manifest file as a lazy-loaded file dependency. // We also stub this file out for development mode (when it is not // generated). const srcEmptySsgManifest = `self.__SSG_MANIFEST=new Set;self.__SSG_MANIFEST_CB&&self.__SSG_MANIFEST_CB()` const ssgManifestPath = `${CLIENT_STATIC_FILES_PATH}/${this.buildId}/_ssgManifest.js` assetMap.lowPriorityFiles.push(ssgManifestPath) assets[ssgManifestPath] = new sources.RawSource(srcEmptySsgManifest) const srcEmptyMiddlewareManifest = `self.__MIDDLEWARE_MANIFEST=[];self.__MIDDLEWARE_MANIFEST_CB&&self.__MIDDLEWARE_MANIFEST_CB()` const middlewareManifestPath = `${CLIENT_STATIC_FILES_PATH}/${this.buildId}/_middlewareManifest.js` assetMap.lowPriorityFiles.push(middlewareManifestPath) assets[middlewareManifestPath] = new sources.RawSource( srcEmptyMiddlewareManifest ) } assetMap.pages = Object.keys(assetMap.pages) .sort() // eslint-disable-next-line .reduce((a, c) => ((a[c] = assetMap.pages[c]), a), {} as any) let buildManifestName = BUILD_MANIFEST if (this.isDevFallback) { buildManifestName = `fallback-${BUILD_MANIFEST}` } assets[buildManifestName] = new sources.RawSource( JSON.stringify(assetMap, null, 2) ) if (this.exportRuntime) { assets[`server/${MIDDLEWARE_BUILD_MANIFEST}.js`] = new sources.RawSource( `self.__BUILD_MANIFEST=${JSON.stringify(assetMap)}` ) } if (!this.isDevFallback) { const clientManifestPath = `${CLIENT_STATIC_FILES_PATH}/${this.buildId}/_buildManifest.js` assets[clientManifestPath] = new sources.RawSource( `self.__BUILD_MANIFEST = ${generateClientManifest( compiler, compilation, assetMap, this.rewrites )};self.__BUILD_MANIFEST_CB && self.__BUILD_MANIFEST_CB()` ) } return assets }) } apply(compiler: webpack.Compiler) { compiler.hooks.make.tap('NextJsBuildManifest', (compilation) => { // @ts-ignore TODO: Remove ignore when webpack 5 is stable compilation.hooks.processAssets.tap( { name: 'NextJsBuildManifest', // @ts-ignore TODO: Remove ignore when webpack 5 is stable stage: webpack.Compilation.PROCESS_ASSETS_STAGE_ADDITIONS, }, (assets: any) => { this.createAssets(compiler, compilation, assets) } ) }) return } }