UNPKG

webpack-assets-manifest

Version:

This Webpack plugin will generate a JSON file that matches the original filename with the hashed version.

518 lines (517 loc) 22.2 kB
import { mkdir, readFile, writeFile } from 'node:fs/promises'; import { basename, dirname, extname, isAbsolute, join, normalize, relative, resolve } from 'node:path'; import { lock } from 'proper-lockfile'; import { validate } from 'schema-utils'; import { AsyncSeriesHook, SyncHook, SyncWaterfallHook } from 'tapable'; import { asArray, findMapKeysByValue, getSortedObject, getSRIHash, group } from './helpers.js'; import { optionsSchema } from './options-schema.js'; import { isKeyValuePair, isObject } from './type-predicate.js'; const PLUGIN_NAME = 'WebpackAssetsManifest'; export class WebpackAssetsManifest { options; assets; assetNames = new Map(); compiler; currentAsset; #isMerging = false; hooks = Object.freeze({ apply: new SyncHook(['manifest']), customize: new SyncWaterfallHook(['entry', 'original', 'manifest', 'asset']), transform: new SyncWaterfallHook(['assets', 'manifest']), done: new AsyncSeriesHook(['manifest', 'stats']), options: new SyncWaterfallHook(['options']), afterOptions: new SyncHook(['options', 'manifest']), }); constructor(options = {}) { this.hooks.transform.tap(PLUGIN_NAME, (assets) => { const { sortManifest } = this.options; return sortManifest ? getSortedObject(assets, typeof sortManifest === 'function' ? sortManifest.bind(this) : undefined) : assets; }); this.hooks.afterOptions.tap(PLUGIN_NAME, (options, manifest) => { manifest.options = Object.assign(manifest.defaultOptions, options); validate(optionsSchema, manifest.options, { name: PLUGIN_NAME }); manifest.options.output = normalize(manifest.options.output); manifest.assets = Object.assign(manifest.options.assets, manifest.assets, manifest.options.assets); manifest.options.apply && manifest.hooks.apply.tap(PLUGIN_NAME, manifest.options.apply); manifest.options.customize && manifest.hooks.customize.tap(PLUGIN_NAME, manifest.options.customize); manifest.options.transform && manifest.hooks.transform.tap(PLUGIN_NAME, manifest.options.transform); manifest.options.done && manifest.hooks.done.tapPromise(PLUGIN_NAME, manifest.options.done); }); this.options = Object.assign(this.defaultOptions, options); this.assets = this.options.assets; } apply(compiler) { this.compiler = compiler; this.options = this.hooks.options.call(this.options); this.hooks.afterOptions.call(this.options, this); if (!this.options.enabled) { return; } compiler.hooks.watchRun.tap(PLUGIN_NAME, this.handleWatchRun.bind(this)); compiler.hooks.compilation.tap(PLUGIN_NAME, this.handleCompilation.bind(this)); compiler.hooks.thisCompilation.tap(PLUGIN_NAME, this.handleThisCompilation.bind(this)); compiler.hooks.afterEmit.tapPromise(PLUGIN_NAME, this.handleAfterEmit.bind(this)); compiler.hooks.done.tapPromise(PLUGIN_NAME, async (stats) => await this.hooks.done.promise(this, stats)); this.hooks.apply.call(this); } get utils() { return { isKeyValuePair, isObject, getSRIHash, }; } get defaultOptions() { return { enabled: true, assets: Object.create(null), output: 'assets-manifest.json', replacer: null, space: 2, writeToDisk: 'auto', fileExtRegex: /\.\w{2,4}\.(?:map|gz|br)$|\.\w+$/i, sortManifest: true, merge: false, publicPath: undefined, contextRelativeKeys: false, apply: undefined, customize: undefined, transform: undefined, done: undefined, entrypoints: false, entrypointsKey: 'entrypoints', entrypointsUseAssets: false, integrity: false, integrityHashes: ['sha256', 'sha384', 'sha512'], integrityPropertyName: 'integrity', extra: Object.create(null), }; } get isMerging() { return this.#isMerging; } getExtension(filename) { if (!filename || typeof filename !== 'string') { return ''; } filename = filename.split(/[?#]/)[0]; if (this.options.fileExtRegex instanceof RegExp) { const ext = filename.match(this.options.fileExtRegex); return ext && ext.length ? ext[0] : ''; } return extname(filename); } fixKey(key) { return typeof key === 'string' ? key.replace(/\\/g, '/') : key; } setRaw(key, value) { this.assets[key] = value; return this; } set(key, value) { if (this.isMerging && this.options.merge !== 'customize') { return this.setRaw(key, value); } const fixedKey = this.fixKey(key); const publicPath = typeof value === 'string' ? this.getPublicPath(value) : value; const entry = this.hooks.customize.call({ key: fixedKey, value: publicPath, }, { key, value, }, this, this.currentAsset); if (entry === false) { return this; } if (isKeyValuePair(entry)) { let { key = fixedKey, value = publicPath } = entry; if (value === publicPath && this.options.integrity) { value = { src: value, integrity: this.currentAsset?.info[this.options.integrityPropertyName] ?? '', }; } return this.setRaw(key, value); } return this.setRaw(fixedKey, publicPath); } has(key) { return Object.hasOwn(this.assets, key) || Object.hasOwn(this.assets, this.fixKey(key)); } get(key, defaultValue) { return this.assets[key] || this.assets[this.fixKey(key)] || defaultValue; } delete(key) { if (Object.hasOwn(this.assets, key)) { return delete this.assets[key]; } key = this.fixKey(key); if (Object.hasOwn(this.assets, key)) { return delete this.assets[key]; } return false; } processAssetsByChunkName(assets, hmrFiles) { if (assets) { Object.keys(assets).forEach((chunkName) => { asArray(assets[chunkName]) .filter((filename) => typeof filename === 'string' && !hmrFiles.has(filename)) .forEach((filename) => { this.assetNames.set(chunkName + this.getExtension(filename), filename); }); }); } } toJSON() { return this.hooks.transform.call(this.assets, this); } toString() { return ((typeof this.options.replacer === 'function' ? JSON.stringify(this, this.options.replacer, this.options.space) : JSON.stringify(this, this.options.replacer, this.options.space)) || '{}'); } async maybeMerge() { if (this.options.merge) { try { const deepmerge = (await import('deepmerge')).default; this.#isMerging = true; const content = await readFile(this.getOutputPath(), { encoding: 'utf8' }); const data = JSON.parse(content); const arrayMerge = (_destArray, srcArray) => srcArray; for (const [key, oldValue] of Object.entries(data)) { if (this.has(key)) { const currentValue = this.get(key); if (isObject(oldValue) && isObject(currentValue)) { const newValue = deepmerge(oldValue, currentValue, { arrayMerge }); this.set(key, newValue); } } else { this.set(key, oldValue); } } } catch (error) { if (error instanceof Error && 'code' in error && error.code === 'ENOENT') { return; } throw error; } finally { this.#isMerging = false; } } } async emitAssetsManifest(compilation) { const outputPath = this.getOutputPath(); const output = this.getManifestPath(compilation, this.inDevServer() ? basename(this.options.output) : relative(compilation.compiler.outputPath, outputPath)); let release; try { if (this.options.merge) { const outputDir = dirname(outputPath); await mkdir(outputDir, { recursive: true }); release = await lock(outputDir, { lockfilePath: join(outputDir, `${PLUGIN_NAME}.lock`) }); } await this.maybeMerge(); compilation.emitAsset(output, new compilation.compiler.webpack.sources.RawSource(this.toString(), false), { assetsManifest: true, generated: true, generatedBy: [PLUGIN_NAME], }); } finally { await release?.(); } } handleProcessAssetsAnalyse(compilation) { const { contextRelativeKeys } = this.options; const { assetsInfo, chunkGraph, chunks, compiler, codeGenerationResults } = compilation; for (const chunk of chunks) { const modules = chunkGraph.getChunkModulesIterableBySourceType(chunk, 'asset'); if (modules) { const { NormalModule } = compilation.compiler.webpack; const infraLogger = compilation.compiler.getInfrastructureLogger(PLUGIN_NAME); for (const module of modules) { if (module instanceof NormalModule) { const codeGenData = codeGenerationResults?.get(module, chunk.runtime).data; const filename = module.buildInfo?.['filename'] ?? codeGenData?.get('filename'); if (!filename) { infraLogger.warn(`Unable to get filename from module: "${module.rawRequest}"`); continue; } const assetInfo = module.buildInfo?.['assetInfo'] ?? codeGenData?.get('assetInfo'); const info = { rawRequest: module.rawRequest, sourceFilename: relative(compiler.context, module.userRequest), ...assetInfo, }; assetsInfo.set(filename, info); this.assetNames.set(contextRelativeKeys ? info.sourceFilename : join(dirname(filename), basename(module.userRequest)), filename); } else { infraLogger.warn(`Unhandled module: ${module.constructor.name}`); } } } } } processStatsAssets(assets) { const { contextRelativeKeys } = this.options; assets?.forEach((asset) => { if (asset.name && asset.info.sourceFilename) { this.assetNames.set(contextRelativeKeys ? asset.info.sourceFilename : join(dirname(asset.name), basename(asset.info.sourceFilename)), asset.name); } }); } getCompilationAssets(compilation) { const hmrFiles = new Set(); const assets = compilation.getAssets().filter((asset) => { if (asset.info.hotModuleReplacement) { hmrFiles.add(asset.name); return false; } return !asset.info['assetsManifest']; }); return { assets, hmrFiles, }; } async handleProcessAssetsReport(compilation) { const stats = compilation.getStats().toJson({ all: false, assets: true, cachedAssets: true, cachedModules: true, chunkGroups: this.options.entrypoints, chunkGroupChildren: this.options.entrypoints, }); const { assets, hmrFiles } = this.getCompilationAssets(compilation); this.processStatsAssets(stats.assets); this.processAssetsByChunkName(stats.assetsByChunkName, hmrFiles); const findAssetKeys = findMapKeysByValue(this.assetNames); const { contextRelativeKeys } = this.options; for (const asset of assets) { const sourceFilenames = findAssetKeys(asset.name); if (!sourceFilenames.length) { const { sourceFilename } = asset.info; const name = sourceFilename ? (contextRelativeKeys ? sourceFilename : basename(sourceFilename)) : asset.name; sourceFilenames.push(name); } sourceFilenames.forEach((key) => { this.currentAsset = asset; this.set(key, asset.name); this.currentAsset = undefined; }); } if (this.options.entrypoints) { const removeHMR = (file) => !hmrFiles.has(file); const getExtensionGroup = (file) => this.getExtension(file).substring(1).toLowerCase(); const getAssetOrFilename = (file) => { let asset; if (this.options.entrypointsUseAssets) { const firstAssetKey = findAssetKeys(file).pop(); asset = firstAssetKey ? this.assets[firstAssetKey] || this.assets[file] : this.assets[file]; } return asset ? asset : this.getPublicPath(file); }; const entrypoints = Object.fromEntries(Array.from(compilation.entrypoints, ([name, entrypoint]) => { const value = { assets: group(entrypoint.getFiles().filter(removeHMR), getExtensionGroup, getAssetOrFilename), }; const childAssets = stats.namedChunkGroups?.[name]?.childAssets; if (childAssets) { for (const [property, assets] of Object.entries(childAssets)) { value[property] = group(assets.filter(removeHMR), getExtensionGroup, getAssetOrFilename); } } return [name, value]; })); if (this.options.entrypointsKey === false) { for (const key in entrypoints) { this.setRaw(key, entrypoints[key]); } } else { this.setRaw(this.options.entrypointsKey, { ...this.get(this.options.entrypointsKey), ...entrypoints, }); } } await this.emitAssetsManifest(compilation); } getManifestPath(compilation, filename) { return compilation.getPath(filename, { chunk: { name: 'assets-manifest', id: '', hash: '', }, filename: 'assets-manifest.json', }); } async writeTo(destination) { const destinationDir = dirname(destination); let release; try { await mkdir(destinationDir, { recursive: true }); release = await lock(destinationDir, { lockfilePath: join(destinationDir, `${PLUGIN_NAME}.lock`) }); await writeFile(destination, this.toString()); } finally { await release?.(); } } clear() { Object.keys(this.assets).forEach((key) => { delete this.assets[key]; }); } handleWatchRun() { this.clear(); } shouldWriteToDisk(compilation) { if (this.options.writeToDisk === 'auto') { if (this.inDevServer()) { const wdsWriteToDisk = compilation.options.devServer ? (compilation.options.devServer['devMiddleware']?.writeToDisk ?? compilation.options.devServer['writeToDisk']) : undefined; if (wdsWriteToDisk === true) { return false; } const manifestPath = this.getManifestPath(compilation, this.getOutputPath()); if (typeof wdsWriteToDisk === 'function' && wdsWriteToDisk(manifestPath) === true) { return false; } if (this.compiler?.outputPath) { return relative(this.compiler.outputPath, manifestPath).startsWith('..'); } } return false; } return this.options.writeToDisk; } async handleAfterEmit(compilation) { if (this.shouldWriteToDisk(compilation)) { await this.writeTo(this.getManifestPath(compilation, this.getOutputPath())); } } handleNormalModuleLoader(compilation, loaderContext, module) { const emitFile = loaderContext.emitFile.bind(module); const { contextRelativeKeys } = this.options; loaderContext.emitFile = (name, content, sourceMap, assetInfo) => { const info = Object.assign({ rawRequest: module.rawRequest, sourceFilename: relative(compilation.compiler.context, module.userRequest), }, assetInfo); this.assetNames.set(contextRelativeKeys ? info.sourceFilename : join(dirname(name), basename(module.userRequest)), name); emitFile(name, content, sourceMap, info); }; } recordSubresourceIntegrity(compilation) { const { integrityHashes, integrityPropertyName } = this.options; for (const asset of compilation.getAssets()) { if (!asset.info[integrityPropertyName]) { const sriHashes = new Map(integrityHashes.map((algorithm) => [algorithm, undefined])); if (asset.info.contenthash) { asArray(asset.info.contenthash) .flatMap((contentHash) => contentHash.split(' ')) .filter((contentHash) => integrityHashes.some((algorithm) => contentHash.startsWith(`${algorithm}-`))) .forEach((sriHash) => sriHashes.set(sriHash.substring(0, sriHash.indexOf('-')), sriHash)); } const assetContent = asset.source.source(); sriHashes.forEach((value, key, map) => { if (typeof value === 'undefined') { map.set(key, getSRIHash(key, assetContent)); } }); asset.info[integrityPropertyName] = Array.from(sriHashes.values()).join(' '); compilation.assetsInfo.set(asset.name, asset.info); } } } handleCompilation(compilation) { compilation.compiler.webpack.NormalModule.getCompilationHooks(compilation).loader.tap(PLUGIN_NAME, this.handleNormalModuleLoader.bind(this, compilation)); compilation.hooks.processAssets.tap({ name: PLUGIN_NAME, stage: compilation.compiler.webpack.Compilation.PROCESS_ASSETS_STAGE_ANALYSE, }, this.handleProcessAssetsAnalyse.bind(this, compilation)); } handleThisCompilation(compilation) { if (this.options.integrity) { compilation.hooks.processAssets.tap({ name: PLUGIN_NAME, stage: compilation.compiler.webpack.Compilation.PROCESS_ASSETS_STAGE_ANALYSE, }, this.recordSubresourceIntegrity.bind(this, compilation)); } compilation.hooks.processAssets.tapPromise({ name: PLUGIN_NAME, stage: compilation.compiler.webpack.Compilation.PROCESS_ASSETS_STAGE_REPORT, }, this.handleProcessAssetsReport.bind(this, compilation)); } inDevServer() { const [, webpackPath, serve] = process.argv; if ((serve === 'serve' && webpackPath && basename(webpackPath) === 'webpack') || process.argv.some((arg) => arg.includes('webpack-dev-server'))) { return true; } return (isObject(this.compiler?.outputFileSystem) && ('__vol' in this.compiler.outputFileSystem || !Object.is(this.compiler.outputFileSystem, this.compiler.intermediateFileSystem))); } getOutputPath() { return isAbsolute(this.options.output) ? this.options.output : this.compiler ? resolve(this.compiler.outputPath, this.options.output) : ''; } getPublicPath(filename) { const { publicPath } = this.options; if (typeof publicPath === 'function') { return publicPath(filename, this); } if (publicPath) { const resolvePath = (filename, base) => { try { return new URL(filename, base).toString(); } catch { return base + filename; } }; if (typeof publicPath === 'string') { return resolvePath(filename, publicPath); } const compilerPublicPath = this.compiler?.options.output.publicPath; if (typeof compilerPublicPath === 'string' && compilerPublicPath !== 'auto') { return resolvePath(filename, compilerPublicPath); } } return filename; } getProxy(raw = false) { const setMethod = raw ? 'setRaw' : 'set'; return new Proxy(this, { has(target, property) { return target.has(property); }, get(target, property) { return target.get(property); }, set(target, property, value) { return target[setMethod](property, value).has(property); }, deleteProperty(target, property) { return target.delete(property); }, }); } }