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
JavaScript
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);
},
});
}
}