/**
* @file plugin/base.js
* @copyright 2025 SPMHOME, LLC
* @author Scott Meesseman @spmeesseman
*
* @description Base WpwPlugin abstract class inherited by all plugin-type modules
*
* When adding a new extending plugin:
*
* Follow naming convention. All plugins and schema/configuration properties must be named using the folowing format:
*
* class name : WpwPluginNamePlugin
* config property : WpwPluginConfigPluginName
*
* For example, a plugin named ReplaceStrings:
*
* class name : WpwReplaceStringsPlugin
* config property : WpwPluginConfigReplaceStrings
*
* Perform the following tasks:
*
* 1. Adjust the schema file by adding the plugin name to relevant areas, and adding a
* new config definition object if required / used, otherwise add the configuration
* property and reference `WpwPluginConfigBase` as the config
*
* file:///./../../schema/spmh.wpw.schema.json
*
* 2. Run the `generate-rc-types` script / npm task to rebuild rc.ts definition file
*
* file:///./../../script/generate-rc-types.js
*
* run: @te::task::npm:schema-generate-rc-types@
*
* 3. Add a module reference to plugin directory index file and add to it's module.exports
*
* file:///./index.js
*
*//** */
const WpwModule = require("../core/module");
const { wrapHookHandler } = require("../utils/hook");
const { apply, asArray, isString, capitalize } = require("@spmhome/type-utils");
const {
existsSync, forwardSlash, isAbsolutePath, dirname, extname, resolvePath, readFileBufAsync, sanitizeShellCmd
} = require("@spmhome/cmn-utils");
/**
* @abstract
* @augments WpwModule
*/
class WpwPlugin extends WpwModule
{
/**
* @readonly
* @type {string}
*/
initMsg;
/**
* @override
* @type {WpwModuleType}
*/
type = "plugin";
/**
* @param {WpwPluginOptions} options Plugin options to be applied
*/
constructor(options)
{
super({ ...options, type: "plugin" });
}
/**
* @protected
* @returns {string}
*/
get ctor() { return this.constructor.name; }
/**
* @protected
* @returns {WpwWebpackConfig}
*/
get wpc() { return this.build.wpc; }
/**
* Main webpack plugin initialization handler, called by webpack runtime to initialize this plugin.
*
* @param {WebpackCompiler} compiler
* @returns {void | SpmhError}
*/
apply(compiler)
{
this.compiler = this.build.compiler = compiler;
this.xLog.write(`configure plugin '${this.optionsGroup ? `${this.optionsGroup}::` : ""}${this.optionsKey}'`, 1);
//
// set up a hook so that the compilation instance can be stored before it actually begins,
// and the compilation dependencies can be logged if a high enough logging level is set
//
const pNameCap = capitalize(this.name);
compiler.hooks.compilation.tap("peekCompilationStart" + pNameCap, this.onCompilation.bind(this));
compiler.hooks.thisCompilation.tap("peekThisCompilationStart" + pNameCap, this.onCompilationThis.bind(this));
//
// if there's any wrapped vendor plugin(s) that specify the 'hookVendorPluginFirst' flag, create
// those hooks before the internal WpwPlugin hooks. After applying internal hooks, then apply any
// vendor plugins that do not specify the flag;
//
for (const p of asArray(this.getVendorPlugin(compiler, true)).filter((p) => !!p))
{
this.logger.write(` add run-first vendor support plugin '${p.name}'`, 1);
p.apply.call(p, compiler);
}
//
// Tap wp hooks configured by the plugin instance
//
const options = this.onApply(compiler);
if (options)
{
const optionsArray = Object.entries(options),
failedHk = Object.values(options).find((v) => v.hook === "failed"),
hasCompilationHook = optionsArray.find(([ _, tapOpts ]) => this.isCompilationHook(tapOpts.hook)) ||
optionsArray.every(([ _, tapOpts ]) => !!tapOpts.stage);
if (!failedHk) {
compiler.hooks.failed.tap("handleBuildFailed" + pNameCap, this.onHookFailed.bind(this));
}
else
{ const cb = !isString(failedHk.callback) ? failedHk.callback : this[failedHk.callback];
failedHk.callback = (...args) => { cb.call(this, ...args); this.onHookFailed(); };
}
if (hasCompilationHook)
{ const compilationHooks =/** @type {Array<WpwPluginCompilationTapOptionsPair<any, any, boolean>>} */(
optionsArray.filter(([ _, tapOpts ]) => this.isCompilationHook(tapOpts.hook))
);
this.tapCompilationHooks(compilationHooks);
}
for (const [ name, tapOpts ] of optionsArray.filter(([ _, to ]) => !this.isCompilationHook(to.hook)))
{
const hook = compiler.hooks[tapOpts.hook];
if (this.isSyncHook(hook) && tapOpts.async !== true) {
hook.tap(`${this.name}_${name}`, wrapHookHandler(name, false, this, tapOpts));
}
else if (this.isAsyncHook(hook) && tapOpts.async === true) {
hook.tapPromise(`${this.name}_${name}`, wrapHookHandler(name, true, this, tapOpts));
}
else {
return this.invalidHookConfig(
tapOpts.hook, "compiler", this.isSyncHook(hook) ? "invalid 'async' flag" : ""
);
}
}
}
//
// if there's any wrapped vendor plugin(s) that does not specify the 'hookVendorPluginFirst'
// flag, create those hooks now that the internal WpwPlugin hooks have been created.
//
for (const p of asArray(this.getVendorPlugin(compiler, false)).filter((p) => !!p))
{
this.xLog.write(` apply run-last vendor module '${p.constructor.name}' to webpack plugins`, 1);
p.apply.call(p, compiler);
}
this.xLog.write(`successfully configured plugin '${this.optionsKey}'`, 1);
}
/**
* @param {string} path path to source asset
* @param {string} tag assset stats tag
* @param {string} [emitPath] relative path emitted to dist, defaults to 'path' relative to ctx dir
* @param {boolean} [immutable]
* @param {boolean} [noStatCheck]
* @returns {Promise<string>}
* @throws {Error}
*/
async emitRawAsset(path, tag, emitPath, immutable, noStatCheck)
{
const absPath = !noStatCheck && !isAbsolutePath(path) ? resolvePath(this.build.getContextPath(), path) : path;
if (noStatCheck || existsSync(absPath))
{
const b = this.build,
sources = this.build.wp.sources,
isMedia = this._types_.isMediaExt(extname(path)),
immutableFile = immutable === true || isMedia,
filePathRel = forwardSlash(emitPath) || b.getEmitRelPath(path),
info = {
immutable: immutableFile, [tag]: true,
javascriptModule: !isMedia && b.isModule && this.isOutputEntryAsset(path), minimized: false
},
// asset = this.compilation.getAsset(filePathRel),
// cached = { source: null, up2date: false },
// cached = await this.build.ssCache.checkSnapshot(file, null, lPad + " ", transform),
// source = asset?.source || cached.source || new sources.RawSource(await readFile(files[i]));
source = new sources.RawSource(await readFileBufAsync(path));
b.compilation.emitAsset(filePathRel, source, info);
return filePathRel;
}
throw new Error(`failed to emit asset, specified path '${path}' does not exist`);
}
// /**
// * @protected
// * @template T
// * @template R
// * @template {boolean} [A=false]
// * @param {WebpackCompilerHookName} hook
// * @param {string | WpwPluginHookHandler<T,R,A>} callback
// * @param {A} [async]
// * @param {boolean} [forceRun]
// * @param {string} [statsProperty]
// * @returns {WpwPluginBaseTapOptions<any,any,boolean>} WpwPluginTapOptions
// */
// static compilerHookConfig(hook, callback, async, forceRun, statsProperty)
// {
// return { async: !!async, hook, callback, forceRun, statsProperty };
// }
/**
* @private
* @since 1.6.0
* @param {WpwPluginConfigScriptDef} script
* @param {string} lPad
* @returns {WpwSanitizedScriptDef}
* @throws {SpmhError}
*/
buildScriptCmd(script, lPad)
{
const cmd = (`${script.command} ` + this._arr_.asArray(script.args)
.map((a) => a.includes(" ") ? `"${a}"` : a).join(" ").trimEnd()).trimEnd();
return {
program: cmd.split(" ", 1)[0],
command: !script.skipSanityCheck ? sanitizeShellCmd(cmd, "string", this.build.logger, lPad) : cmd
};
};
/**
* All of the `exec` utility functions are executed in the context of a runtime build
* instance, i.e. 'this' is of type InstanceType<WpwBuild>
*
* @since 1.6.0
* @param {WpwPluginConfigRunScripts | WpwPluginConfigScript} defs
* @param {string} lPad
* @returns {Promise<{ result: WpwExecResult, results: WpwExecResult[] } | void>}
*/
async execScriptsAsync(defs, lPad)
{
const l = this.build.logger;
l.value("mode", defs.mode, 1, lPad);
// l.value("is immutable output", defs.outputImmutable, 2, lPad);
l.value("# of scripts", defs.items.length, 2, lPad);
defs.timeout = this._types_.isNumber(defs.timeout) ? defs.timeout : 30000;
const scripts = defs.items.map((s, i) =>
{
const cmd = this.buildScriptCmd(s, lPad);
defs.timeout += (this._types_.isNumber(s.timeout) ? s.timeout : 0);
l.write(` [${i + 1}] ${cmd.command}`, 2, lPad);
return cmd;
});
l.value("accumulated timeout period", `${defs.timeout} seconds`, 2, lPad);
return this.buildOptions.mode === "parallel" ?
this.execScriptsAsyncParallel.call(this, scripts, defs.timeout, defs.stdout, lPad) :
this.exeScriptsAsyncInline.call(this, scripts, defs.timeout, defs.stdout, lPad);
}
/**
* All of the `exec` utility functions are executed in the context of a runtime plugin
* instance, i.e. 'this' is of type InstanceType<WpwPlugin|WpwTaskPlugin>
*
* @private
* @since 1.6.0
* @param {WpwSanitizedScriptDef[]} scripts
* @param {number} timeout
* @param {boolean} [stdinOn]
* @param {string} [lPad]
* @returns {Promise<{ result: WpwExecResult, results: WpwExecResult[] } | void>}
*/
exeScriptsAsyncInline(scripts, timeout, stdinOn, lPad = " ")
{
return new Promise((ok, fail) =>
{
let sNum = 0;
const results = [];
const _exec = (/** @type {{ command: string, program: string }} */ cmd) =>
{
this.exec(cmd.command, cmd.program, true, { logPad: lPad, stdinOn, timeout })
.then((result) =>
{
if (result.code === 0)
{
results.push(result);
return ++sNum < scripts.length ? _exec(scripts[sNum]) : void ok({ result, results });
}
else { fail(result); }
})
.catch((e) =>
{ fail(this.addMessage(
{ exception: e, message: e.message, lPad,
detail: `command: ${cmd.command}`, code: this.MsgCode.ERROR_SCRIPT_FAILED
}));
});
};
_exec(scripts[sNum]);
});
}
/**
* All of the `exec` utility functions are executed in the context of a runtime plugin
* instance, i.e. 'this' is of type InstanceType<WpwPlugin|WpwTaskPlugin>
*
* @private
* @since 1.6.0
* @param {WpwSanitizedScriptDef[]} scripts
* @param {number} timeout
* @param {boolean} [stdinOn]
* @param {string} [lPad]
* @returns {Promise<{ result: WpwExecResult, results: WpwExecResult[] } | void>}
*/
async execScriptsAsyncParallel(scripts, timeout, stdinOn, lPad = " ")
{
let rs;
const gEmitter = this.global.globalEvent,
lTag = (pfx = "script") => `[${pfx}:async_parallel]`,
/** @type {WpwModuleExecOptions} */
wpwExecOpts = { logPad: lPad, interrupts: false, stdinOn, timeout },
promises = scripts.map((s) => this.exec(s.command, s.program, true, wpwExecOpts));
try {
rs = await this._pms_.promiseRaceEvent(promises, gEmitter, timeout);
}
catch(e) { rs = e; }
return this.execUtils.handlePromiseEventResult(this.logger, rs, lTag(), lPad);
}
/**
* All of the `exec` utility functions are executed in the context of a runtime plugin
* instance, i.e. 'this' is of type InstanceType<WpwPlugin|WpwTaskPlugin>
*
* @since 1.6.0
* @param {WpwPluginConfigRunScripts} scriptDef
* @param {string} lPad
* @returns {{ result: WpwExecResult, results: WpwExecResult[] } | void}
*/
execScriptsSync(scriptDef, lPad = " ")
{
const results = [],
l = this.build.logger,
lTag = (pfx = "script") => `[${pfx}:sync_inline]`;
if (scriptDef.mode === "parallel")
{
scriptDef.mode = "inline";
l.warning(` parallel mode unsupported with synchronous execution, use '${scriptDef.mode}' mode`);
}
for (let idx = 0; idx < scriptDef.items.length; idx++)
{ let shell;
try
{ shell = this.buildScriptCmd(scriptDef.items[idx], lPad);
const execRc = this.exec(shell.command, `${lTag}[${shell.program}]`, false, { logPad: lPad });
if (execRc.code !== 0) {
return { result: execRc, results };
}
results.push(execRc);
}
catch(e)
{ return void this.addMessage({
exception: e, lPad,
code: this.MsgCode.ERROR_SCRIPT_FAILED,
detail: `command: ${shell?.command || "unable to detect"}`,
message: `script command '${shell?.program || "unable to detect"}' returned non-zero exit code ${lTag()}`
}, true);
}
}
return { result: results[results.length - 1], results };
}
/**
* @param {string} file
* @param {boolean} [rmvExt] remove file extension
* @returns {string}
*/
fileNameStrip(file, rmvExt)
{
const newFile = file.replace(new RegExp(`\\.[a-f0-9]{${this.hashLength()}}`), "");
return rmvExt !== true ? newFile : newFile.replace(/\..+?(?:\.map)?$/, "");
}
/**
* @override
* @param {string} [message]
* @param {SpmhMessageCode | null} [code]
* @param {SpmhError | WebpackError | Error | undefined} [exception]
* @param {string} [hookName]
* @returns {WpwBaseMessageInfo}
*/
getErrorDefaultCfg(message, code, exception, hookName)
{
return apply(super.getErrorDefaultCfg(message, code, exception),
{
code: code || this.MsgCode.ERROR_PLUGIN_FAILED, detailX: this,
message: message || this.hookMessages[hookName]?.fail || exception?.message || "unknown error"
});
};
/**
* @abstract
* @protected
* @param {WebpackCompiler} _compiler
* @param {boolean} [_applyFirst]
* @returns {WebpackPluginInstance | WebpackPluginInstanceOrUndef[]}
*/
getVendorPlugin(_compiler, _applyFirst) { return undefined; }
/**
* @protected
* @param {string} name
* @param {boolean} [isEntry]
* @param {boolean} [chkDbg]
* @param {boolean} [chkExt] include file extension in comparisons, defaults to `false`
* @param {boolean} [chkHashed]
* @param {boolean} [noCacheGrp]
* @returns {boolean} boolean
*/
isOutputAsset(name, isEntry, chkDbg, chkExt, chkHashed, noCacheGrp)
{
return (!chkHashed && !isEntry && name.endsWith(".LICENSE")) ||
(isEntry ? this.outputAssetRegex(chkDbg, chkExt, chkHashed).test(name) :
this.isOutputEntryAsset(name, this._types_.isBoolean(chkExt) ? chkExt : true, noCacheGrp));
}
/**
* @private
* @param {string} name
* @param {boolean} [chkExt] include file extension in comparisons, defaults to `true`
* @param {boolean} [noCacheGrp]
* @returns {boolean}
*/
isOutputEntryAsset(name, chkExt, noCacheGrp)
{
const build = this.build,
compilation = build.compilation,
nakedAsset = this.fileNameStrip(name, true),
nakedAssetExt = this.fileNameStrip(name, false);
// return build.isTranspiled && (!chkExt || new RegExp(`${this.outputExt}$`).test(name)) &&
return build.isTranspiled && (!chkExt || /\.[cm]?[jt]sx?$/.test(name)) &&
(noCacheGrp ? !(new RegExp(this.outputCacheGroups(chkExt).join("|"))).test(name) : true) &&
(!!compilation.getAssets().find((a) => a.name === name || a.name === nakedAssetExt) ||
build.options.output.name === nakedAsset || Object.keys(build.wpc.entry).includes(nakedAsset) ||
Object.keys(build.wpc.entry).includes(nakedAsset) ||
Object.keys(this._obj_.asObject(build.wpc.entry.import)).includes(nakedAsset) ||
(build.isTypes && nakedAssetExt.endsWith(".d.ts") &&
(this._path_.basename(build.tsc.compilerOptions.outFile).includes(nakedAsset) ||
this._path_.basename(build.tsc.compilerOptions.declarationDir).includes(nakedAsset))));
}
/**
* May be implemented by extending WpwPlugin if internal hooks are to be set up.
* All plugins either (1) have internal hooks, (2) wrap a vendor plugin, or (3) both.
*
* @protected
* @abstract
* @param {WebpackCompiler} _compiler
* @returns {WpwPluginTapOptions<any, any, boolean> | undefined | void}
*/
onApply(_compiler) { return undefined; }
/**
* @private
* @param {WebpackCompilation} compilation
*/
onCompilation(compilation) { this.compilation = this.build.compilation = compilation; }
/**
* @private
* @param {WebpackCompilation} compilation
*/
onCompilationThis(compilation) { this.compilationThis = this.build.compilationThis = compilation; }
/**
* @private
*/
onHookFailed()
{
this.build.state = "failed";
// this.global.globalEvent.emitter.emit(
// GLOBAL_EVENT_BUILD_ERROR_ARGS[0], GLOBAL_EVENT_BUILD_ERROR_ARGS[1],
// `${this.build.name}|${this.optionsKey}|${this.hookCurrent}`
// );
}
/**
* @param {boolean} [chkDbg]
* @param {boolean} [chkExt]
* @param {boolean} [chkHashed]
* @returns {RegExp}
*/
outputAssetRegex(chkDbg, chkExt, chkHashed)
{
const b = this.build,
splitChunks = `${this.outputCacheGroups(!chkExt).join("|")}`,
entryPoints = isString(this.wpc.entry) ? [ this.wpc.entry ] : Object.keys(this.wpc.entry);
return new RegExp(
!b.isTypes ?
`(?:${entryPoints.reduce((e, c) => `${e ? e + "|" : ""}${c}`, "")}|` +
`${this.outputChunk}${splitChunks.length > 0 ? `|${splitChunks}` : ""})` +
`${!chkDbg ? "" : "\\.debug"}${!chkHashed ? "" : "\\.[a-f0-9]{12,32}"}${!chkExt ? "" : "\\.[mc]?js$"}` :
`|(?:${b.tsc.compilerOptions.outFile}${!chkExt ? "" : "\\.d.ts$"}|` +
// eslint-disable-next-line stylistic/max-len
`${this._path_.basename(b.options.types?.bundle?.name || b.options.output?.name || "types")}${!chkExt ? "" : "\\.d.ts$"}|` +
`${this._path_.basename(b.tsc.compilerOptions.declarationDir)}[\\\\/].+?${!chkExt ? "" : "\\.d.ts"}$)`
);
}
/**
* @private
* @param {boolean} [noExt]
* @returns {string[]}
*/
outputCacheGroups(noExt)
{
const cacheGroups = [];
if (this.build.isAnyAppOrLib)
{ const ext = !noExt ? this.outputExt : "",
oOpt =this.build.options.optimization || { enabled: this.build.isProdMode };
if (!this.build.isLib) {
cacheGroups.push(`runtime${ext}`);
}
if (oOpt.eslintCacheGroup) {
cacheGroups.push(`${oOpt.eslintCacheGroup.name || "eslint"}${ext}`);
}
if (oOpt.reactCacheGroup) {
cacheGroups.push(`${oOpt.reactCacheGroup.name || "react"}${ext}`);
}
if (oOpt.spmhCacheGroup) {
cacheGroups.push(`${oOpt.spmhCacheGroup.name || "spmhc"}${ext}`);
}
if (oOpt.spmhCacheGroupApp) {
cacheGroups.push(`${oOpt.spmhCacheGroupApp.name || "spmha"}${ext}`);
}
if (oOpt.spmhCacheGroupLib) {
cacheGroups.push(`${oOpt.spmhCacheGroupLib.name || "spmhl"}${ext}`);
}
if (oOpt.vendorCacheGroup) {
cacheGroups.push(`${oOpt.vendorCacheGroup.name || "vendor"}${ext}`);
}
if (oOpt.customCacheGroup) {
cacheGroups.push(...this._arr_.asArray(oOpt.customCacheGroup).map((g) => `${g.name}${ext}`));
}
}
return cacheGroups;
}
/**
* Prints an array of paths as they are processed, including only a specific # of lines
* as determined by the log level verbosity, e.g. log level 2 will print only the first 8
* assets fo,lowed by a generalized message "+ ... additional assets"
*
* @param {number | null} i 0-based index of current asset in the array parameter 'paths'
* @param {string} pPfx asset path type / value prefix
* @param {string[]} paths array of abs paths indexed by parameter 'i'
* @param {string} lPad
* @returns {string}
*/
printAssetList(i, pPfx, paths, lPad = " ")
{
const l = this.build.logger,
filePathRel = this.build.getEmitRelPath(paths[i]);
if (i === null) {
l.list(paths, pPfx, `${pPfx} files @ directory '${dirname(filePathRel)}'`, 2, lPad);
}
else
{ if (i === 0) {
l.bullet(`${pPfx} files @ directory '${dirname(filePathRel)}'`, 2, lPad);
}
l.listitem(i, paths, pPfx, 2, lPad);
}
return filePathRel;
}
}
module.exports = WpwPlugin;