/**
* @file plugins/hash.js
* @copyright @spmhome @_2025
* @author Scott Meesseman @spmeesseman\
*//** */
const WpwPlugin = require("./base");
const { dirname, resolve, basename } = require("path");
const { copyFileAsync, createDirAsync } = require("@spmhome/cmn-utils");
const { isString, isObjectEmpty, merge, escapeRegExp } = require("@spmhome/type-utils");
/**
* @augments WpwPlugin
*/
class WpwHashPlugin extends WpwPlugin
{
/**
* @param {WpwPluginOptions} options Plugin options to be applied
*/
constructor(options)
{
super(options);
this.buildOptions = /** @type {WpwBuildOptionsPluginConfig<"hash">} */(this.buildOptions);
if (this.buildOptions.emitNoHash) {
this.buildOptions.emitAppNoHash = false;
}
}
/**
* @override
* @param {WpwBuild} build
*/
// static create = WpwHashPlugin.wrap.bind(this);
static create = (build) =>
WpwHashPlugin.wrap.call(this, build, !!(build.options.hash.emitNoHash || build.options.hash.emitAppNoHash));
/**
* @override
* @returns {WpwPluginTapOptions<any, any, boolean>}
*/
onApply()
{
return {
copyModulesWithoutFilenameHash:
{
async: true,
hook: "afterEmit",
callback: this.copyEntryModulesNoHash.bind(this)
},
emitModulesWithoutFilenameHash:
{
hook: "compilation",
statsProperty: "copied",
hookCompilation: "afterProcessAssets",
callback: this.emitModulesWithoutFilenameHash.bind(this)
},
readPreviousContenthashState:
{
async: false,
hook: "beforeCompile",
callback: this.readAssetState.bind(this)
},
saveContenthashState:
{
async: true,
hook: "done",
callback: this.saveAssetHash.bind(this)
},
updateAssetContenthash:
{
logLevel: 3,
async: false,
// hook: "afterEmit",
hook: "assetEmitted",
callback: this.updateAssetContentHash.bind(this)
},
verifyPreviousContenthashState:
{
async: false,
hook: "finishMake",
callback: this.verifyPreviousContentHashState.bind(this)
}
};
}
/**
* @private
* @param {WebpackCompilation} compilation
* param {WebpackAssetEmittedInfo} assetInfo
*/
// async copyEntryModulesNoHash(file, _assetInfo)
async copyEntryModulesNoHash(compilation)
{
const build = this.build,
logger = this.hookstart(),
distPath = build.getDistPath(),
buildDir = build.virtualEntry.dirBuild, // this.compilation.options.output.path && !!
currentAssets = Object.keys(compilation.getAssets()).filter(((f) => this.isOutputAsset(f)));
logger.value(" distribution directory", distPath, 3);
logger.value(" total # of output assets", currentAssets.length, 3);
// const asset = this.compilation.getAsset(file);
// if (asset?.info.sourceFilename && this.isOutputAsset(file, false, false, false, true))
// { const hkMsg = `copy immutable asset italic(${file}) to dist w/o filename hash`,
// build = this.build, logger = this.hookstart(hkMsg),
// distPath = build.getDistPath(), destFile = resolve(distPath, dirname(file) || ".");
// logger.write(` ${asset.name} -> italic(${file})`, 1);
// await copyFileAsync(file, destFile);
// this.hookstart(hkMsg);
// }
for (const asset of compilation.getAssets().filter((a) => this._isOutputAsset(a.name) || a.name.startsWith("LICENSE.")))
{
const ccFile = this.fileNameStrip(asset.name);
if (this.compilation.getAsset(ccFile))
{ const file = resolve(buildDir, ccFile);
let destDir = resolve(distPath, dirname(ccFile) || ".");
if (build.isMultiTarget && ccFile.endsWith(".LICENSE") && destDir.endsWith(`${build.target}`)) {
destDir = resolve(destDir, "..");
}
logger.write(` immutable asset '${asset.name}' copied -> ${ccFile}`, 2);
await createDirAsync(destDir);
await copyFileAsync(file, resolve(destDir, basename(ccFile) || "."));
}
}
this.hookdone();
}
/**
* @private
* @param {WebpackCompilationAssets} assets
*/
emitModulesWithoutFilenameHash(assets)
{
const l = this.build.logger,
assetKeys = Object.keys(assets);
l.hookstart("emit entry assets without filename hash", 1);
l.value(" # of current entry assets processed", assetKeys.length, 2);
for (const file of assetKeys.filter((a) => this._isOutputAsset(a)))
{
const ccFile = this.fileNameStrip(file),
dstAsset = this.compilation.getAsset(ccFile);
if (!dstAsset)
{
const srcAsset = this.compilation.getAsset(file);
if (srcAsset)
{
const currentState = this.store.data.current,
chunkName = this.fileNameStrip(file, true),
previousHash = currentState[chunkName],
currentHashSrc = srcAsset.info.contenthash,
// currentHashDst = dstAsset.info.contenthash,
unchanged = currentHashSrc === previousHash;
l.write(` compare current vs. previous contenthash for asset '${file}'`, 1);
l.values([
[ "previous contenthash (state)", previousHash ], [ "current contenthash (src)", currentHashSrc ]
// [ "current contenthash (dst)", currentHashDst ]
], 1, " ");
l.write(` content italic(${unchanged ? "un" : ""}changed) since previous build`, 1);
if (!unchanged)
{
l.write(` save new contenthash ${currentHashSrc} to state store`, 2);
// srcAsset.info.related = { related: ccFile };
currentState[chunkName] = currentHashSrc;
this.store.save();
}
// const newInfo = apply({},
// { sourceFilename: srcAsset.name,
// copied: true, immutable: false, // unchanged,
// javascriptModule: this.build.isModule,
// contenthash: unchanged ? currentHash : undefined
// }, srcAsset.info);
const newInfo = {
sourceFilename: srcAsset.name,
copied: true, immutable: unchanged,
minimized: srcAsset.info.minimized,
javascriptModule: this.build.isModule,
contenthash: unchanged ? srcAsset.info.contenthash : undefined
};
if (l.level >= 3)
{ l.values([
[ "asset info", JSON.stringify(newInfo) ],
[ "immutable src asset info", JSON.stringify(srcAsset.info), 4 ]
], 3, " ");
}
let src = srcAsset.source.source().toString();
for (const asset of this.compilation.getAssets().filter((a) => this._isOutputAsset(a.name)))
{
const assetNm = asset.name;
src = src.replace(
new RegExp(escapeRegExp(assetNm), "g"), this.fileNameStrip(assetNm)
);
const aFileParts = assetNm.split("."); // optimization.chunking = 'named'
if (aFileParts.length > 2)
{
aFileParts.shift();
src = src.replace(
new RegExp(escapeRegExp(`.${aFileParts.join(".")}`), "g"),
`.${aFileParts[aFileParts.length - 1]}`
);
}
}
l.value(" emit copied asset", `${file} -> ${ccFile}`, 1);
const ccSource = new this.build.wp.sources.RawSource(src);
// const ccHash = this.build.ssCache.getContentHash(ccSource.buffer());
this.compilation.emitAsset(ccFile, ccSource, newInfo);
}
}
}
l.hookdone("completed emit of entry assets without filename hash", 1, this.build.errorCount > 0);
}
/**
* @private
* @param {string} name
* @returns {boolean}
*/
_isOutputAsset(name) { return this.isOutputAsset(name, this.buildOptions.emitAppNoHash, false, false, true); }
/**
* @private
* @param {Record<string, string>} prv
* @param {Record<string, string>} cur
* @param {string} lblPrv
* @param {string} lblCur
* @param {boolean} rot
* @param {string} lPad
*/
_logAssetInfo(prv, cur, lblPrv, lblCur, rot, lPad)
{
const l = this.build.logger.write(`${lblCur}:`, 2, lPad);
if (!isObjectEmpty(cur))
{
Object.keys(cur).forEach((k) => l.writeMsgTag(` ${k}`, cur[k], 2, lPad));
}
else if (prv && !isObjectEmpty(prv) && rot === true)
{
l.write(` content hash values cleared and copied to '${lblPrv}'`, 2, lPad);
}
else {
l.write(" 0 stored content hash values", 2, lPad);
}
}
/**
* @private
* @param {boolean} rotated `true` indicates that values were read and rotated
* i.e. `next` values were moved to `current`, and `next` is now blank
* @param {string} lPad
*/
logAssetInfo(rotated, lPad)
{
const store = this.store.data;
this._logAssetInfo(null, store.previous, "", "previous", rotated, lPad);
this._logAssetInfo(store.previous, store.current, "previous", "current", rotated, lPad);
this._logAssetInfo(store.current, store.next, "current", "next", rotated, lPad);
}
/**
* Reads stored / cached content hashes from file
*
* @private
* @param {WebpackCompilationParams} _params
*/
readAssetState(_params)
{
const store = this.store.data;
this.hookstart();
if (!isObjectEmpty(store))
{
merge(store.previous, store.current);
merge(store.current, store.next);
}
else
{ store.current = {};
store.previous = {};
}
store.next = {};
this.logAssetInfo(true, " ");
this.hookdone();
};
/**
* Writes / caches asset content hashes to disk
*
* @private
* @member saveAssetState
*/
async saveAssetHash()
{
this.hookstart();
Object.keys(this.store.data.current).filter((h) => !this.store.data.next[h]).forEach((h) => {
this.store.data.next[h] = this.store.data.current[h];
});
await this.store.saveAsync();
this.logAssetInfo(false, " ");
this.hookdone();
}
/**
* @private
* @param {string} file
* @param {WebpackAssetEmittedInfo} assetInfo
* @throws {Error}
*/
updateAssetContentHash = (file, assetInfo) =>
{
const l = this.hookstart(),
asset = this.compilation.getAsset(file),
chunkName = this.fileNameStrip(asset.name, true);
if (this._isOutputAsset(asset.name))
{
l.values([
[ "emitted asset", asset.name ], [ "asset file", asset.name, 2 ], [ "chunk name", chunkName, 2 ],
[ "contenthash", asset.info.contenthash ], [ "target path", assetInfo.targetPath, 2 ]
], 1, " ");
if (asset.info.contenthash)
{
if (asset.info.contenthash !== this.store.data.current[chunkName])
{
l.write(` update contenthash for asset '${chunkName}'`, 1);
l.value(" new", asset.info.contenthash, 2);
l.value(" previous", this.store.data.current[chunkName] || "n/a", 2);
this.store.data.next[chunkName] = asset.info.contenthash;
}
else {
l.write(` contenthash of asset '${chunkName}' italic(unchanged) since previous build`, 1);
}
}
else {
l.write(` ${asset.name} is not hashed, nothing to do`, 1);
}
}
this.hookdone();
};
/**
* Verifies stored / cached content hashes before new compilation
*
* @private
* @param {WebpackCompilation} compilation
*/
verifyPreviousContentHashState(compilation)
{
const storeCurrent = this.store.data.current,
l = this.hookstart();
compilation.getAssets().filter((asset) => this._isOutputAsset(asset.name)).forEach((asset) =>
{
const chunkName = this.fileNameStrip(asset.name, true);
l.write(` check asset ${asset.name} [chunk(${chunkName}]`, 2);
if (isString(asset.info.contenthash))
{ if (!storeCurrent[chunkName] || storeCurrent[chunkName] !== asset.info.contenthash)
{
storeCurrent[chunkName] = asset.info.contenthash;
l.write(` updated ${storeCurrent[chunkName] ? "stale" : ""} contenthash for italic(${asset.name})`, 2);
l.value(" previous", storeCurrent[chunkName] || "n/a", 3);
l.value(" new", asset.info.contenthash, 3);
} }
else
{ this.addMessage({
plugin: "hash::verify",
code: this.MsgCode.WARNING_GENERAL,
message: "non-string content hash not supported yet: " + asset.name
});
}
});
this.logAssetInfo(false, " ");
this.hookdone();
};
}
module.exports = WpwHashPlugin.create;