/**
* @file core/module.js
* @copyright @spmhome @_2025
* @author Scott Meesseman @spmeesseman
*//** */
const WpwBase = require("./base");
const WpwLogger = require("../log/log");
const WpwCache = require("../services/cache");
const { breakProp } = require("../utils/utils");
const { SpmhExec } = require("@spmhome/exec-utils");
const { wrapHookHandler } = require("../utils/hook");
const { SpmhMessageUtils } = require("@spmhome/log-utils");
const { GLOBAL_EVENT_BUILD_ERROR } = require("../utils/constants");
const { capitalize, isDefined, isError } = require("@spmhome/type-utils");
const {
isWpwBuildOptionsExportKey, isWpwBuildOptionsGroupKey, isWpwBuildOptionsPluginKey, WpwBuildOptionsGroupKeys
} = require("../types/constants");
const { existsSync } = require("@spmhome/cmn-utils");
/**
* @abstract
* @augments WpwBase
*/
class WpwModule extends WpwBase
{
/**
* @type {Record<WpwBuildOptionsKey, WpwCache>}
*/
static cacheMap;
/**
* @type {WpwBuild}
*/
build;
/**
* @type {WpwBuildOptionsConfig<any> | WpwBuildOptionsExportConfig<any>}
*/
buildOptions;
/**
* @type {WebpackCompiler}
*/
compiler;
/**
* @type {WebpackCompilation}
*/
compilation;
/**
* @protected
* @type {WpwCache}
*/
store;
/**
* @type {string | undefined}
*/
hookCurrent;
/**
* @type {Record<string, { start: string, done: string, fail: string }>}
*/
hookMessages = {};
/**
* @type {WpwModuleType}
*/
type = "module";
/**
* @type {WpwModuleSubType}
*/
subtype;
/**
* @private
* @type {WpwLogger}
*/
_logger;
/**
* @private
* @type {WpwBuildOptionsGroupKey | undefined | null}
*/
_optionsGroup = null;
/**
* @private
* @type {WpwBuildOptionsKey | WpwBuildOptionsGroupedKey | undefined | null}
*/
_optionsKey = null;
/**
* @private
* @type {SpmhExec}
*/
_spmhExec;
/**
* @private
* @since 1.12.0
*/
_xLogger;
/**
* @param {WpwModuleOptions} config
*/
constructor(config)
{
super(config);
this.type = config.type;
this.build = config.build;
this._logger = this.build.logger;
this._optionsKey = config.optionsKey || this.getOptionsKey();
this._optionsGroup = config.optionsGroup || this.getOptionsGroup();
if (config.log && !this._logger) {
this._logger = /** @type {WpwLogger} */(WpwLogger.getLoggerInst(config.log));
}
this._xLogger = this.createXLogger();
this.slug = config.slug || this._optionsKey;
this._spmhExec = new SpmhExec(GLOBAL_EVENT_BUILD_ERROR, this.logger, this.addMessage);
this.store = WpwModule[this._optionsKey] = new WpwCache({
logger: this.logger, slug: this.slug, dir: this.build.cacheDir, crypto: this.build.crypto
});
// this._obj_.apply(this, this._obj_.pickBy(config, (v) => (/^is[A-Z][a-z]+$/).test(v)));
if (!config.buildOptionsNone && (this.isPlugin || this.isExport))
{
const opts = config.buildOptions, key = this._optionsKey, group = this._optionsGroup;
this.buildOptions = this._types_.isObject(opts, false) && !this._types_.isObjectEmpty(opts) ? opts :
(this._obj_.clone((!group ? this.build.config.options[key] : this.build.config.options[group][key]) || {}));
this.validateOptionsKey(config);
}
else {
this.buildOptions = /** @type {WpwBuildOptionsConfig<any>} */({});
}
}
get execUtils() { return this._spmhExec; }
get isExport() { return this.type === "export"; };
get isModule() { return this.type === "module"; };
get isPlugin() { return this.type === "plugin"; };
get isService() { return this.type === "service"; };
get isTaskPlugin() { return this.isPlugin && this.subtype === "taskplugin"; };
get isScriptService() { return this.isService && this.subtype === "scriptservice"; };
get logger() { return this._logger; }
get optionsKey() { return this._optionsKey; }
get optionsGroup() { return this._optionsGroup; }
get outputChunk() { return this.build.options.output.name || this.build.name; }
get outputExt() {
const b = this.build;
return this.build.options.output.ext ||
(b.isAnyAppOrLib ? (b.isAnyApp || b.pkgJson.type ? ".js" : (b.isModule ? ".mjs" : ".cjs")) :
(b.isTypes ? ".d.ts" : b.source.dotext));
}
get xLogger() { return this._xLogger; }
get /** alias */xLog() { return this._xLogger; }
/**
* Static constructor function which must be overridden and exported by the plugin implementation layer
*
* @abstract
* @param {any[]} _args
* @returns {WpwWebpackExport | WpwPlugin | undefined}
* @throws {SpmhError}
*/
static create(..._args)
{
throw this.addMessage.call(this, {
code: SpmhMessageUtils.Code.ERROR_ABSTRACT_FUNCTION, message: `[${this.name}[static_create]`
});
};
/**
* @abstract
* @protected
* @param {WpwBuild} _build
* @returns {WpwWebpackExport | WpwPlugin | undefined | void}
* @throws {SpmhError}
*/
create(_build)
{
if (this.isExport)
{ throw this.build.addMessage({
code: this.MsgCode.ERROR_ABSTRACT_FUNCTION, message: `name[${this.name}][instance_create]`
});
}
}
/**
* @private
* @returns {object}
*/
createXLogger()
{
const m = this, l = m._logger, xTag = [ `${m.type}::${m._optionsKey}` ];
return {
/**
* @template {Error | string} E
* @param {E} e
* @param {string | number} [pad]
* @returns {SpmhLogErrorFnResult<E>}
*/
error(e, pad) { return l.error(e, pad, null, null, xTag); },
/**
* @template {any} [T=undefined]
* @since 1.11.0
* @param {string} [msg]
* @param {T} [rtn]
* @returns {T}
*/
hookdone(msg, rtn) { return m.hookdone(msg, rtn); },
/**
* @param {string} [msg]
* @returns {WpwLogger}
*/
hookstart(msg) { return m.hookstart(msg); },
/**
* @param {string} msg
* @param {WpwLoggerLevel} [lvl]
* @param {string | number} [pad]
* @returns {WpwLogger}
*/
write(msg, lvl, pad) { l.write(msg, lvl, pad, 0, 0, 0, 0, xTag); return m.xLog; },
/**
* @param {string} msg
* @param {any} val
* @param {WpwLoggerLevel} [lvl]
* @param {string | number} [pad]
* @returns {void}
*/
value(msg, val, lvl, pad) { l.value(msg, val, lvl, pad, 0, 0, xTag); },
/**
* @param {SpmhLogValuesParam} values message/value pairs
* @param {SpmhLogLevel} [lvl] logging level
* @param {string | number} [pad] message pre-padding
* @param {boolean | null | 0} [blank]
* @param {string | null | 0 | false} [hdr] header / title / 1st line text
*/
values(values, lvl, pad, blank, hdr) { l.values(values, lvl, pad, blank, hdr, 0, xTag); }
};
}
/**
* @type {WpwScopedFn}
* @param {Partial<SpmhMessageInfo> | SpmhError} info
* @param {boolean|undefined} [ifFirst]
* @returns {SpmhError}
*/
static addMessage(info, ifFirst) { return this.addMessage(info, ifFirst); }
/**
* @param {Partial<SpmhMessageInfo> | SpmhError} info
* @param {boolean|undefined} [ifFirst]
* @returns {SpmhError}
*/
addMessage(info, ifFirst)
{
const isAnyErr = isError(info), // bound fn, don't use 'this._types_.isError'
isWpwMsgInfo = SpmhMessageUtils.isMessageInfo(info) && !isAnyErr,
isWpwMsg = !isWpwMsgInfo && SpmhMessageUtils.isSpmh(info),
isJsErr = !isWpwMsgInfo && !isWpwMsg && isAnyErr;
if (isWpwMsgInfo)
{
return this.build.addMessage(
this._obj_.apply(
{},
info, this.getErrorMessageCfg("")
), ifFirst
);
}
else if (isJsErr)
{
return this.build.addMessage(
this._obj_.apply({ capture: this.addMessage }, this.getErrorMessageCfg("", null, info)), false
);
}
return this.build.addMessage(info, ifFirst);
}
/**
* @template {boolean} [P=true]
* @param {string} command
* @param {string} program
* @param {P} [async]
* @param {WpwModuleExecOptions} [options]
* @returns {WpwExecResultKind<P>}
*/
exec(command, program, async, options)
{
return this.exec2(
async, this._obj_.apply(
{ ...options }, { command, program },
{ cliArgs: this.build.cli,
errMsgCfg: this.getErrorMessageCfg(""),
logStdio: !!this.build.cli.logexec || this.logger.level >= 3
})
);
}
/**
* @template {boolean} P
* @param {P} async
* @param {WpwExecOptions} options
* @returns {WpwExecResultKind<P>}
*/
exec2(async, options)
{
return new SpmhExec(GLOBAL_EVENT_BUILD_ERROR, this.logger, this.addMessage.bind(this)).exec(
async, this._obj_.apply(
{}, options,
{ cliArgs: this.build.cli,
errMsgCfg: this.getErrorMessageCfg(""),
logStdio: !!this.build.cli.logexec || this.logger.level >= 3
})
);
}
/**
* @abstract
* @protected
* @param {string} _message
* @param {SpmhMessageCode | null} [_code]
* @param {SpmhError | WebpackError | Error | undefined} [_exception]
* @returns {Partial<SpmhMessageInfo>}
*/
getErrorDefaultCfg(_message, _code, _exception) { return /** @type {SpmhMessageInfo} */({}); }
/**
* @protected
* @param {string} message
* @param {SpmhMessageCode | null} [code]
* @param {SpmhError | WebpackError | Error | undefined} [exception]
* @returns {WpwBaseMessageInfo}
*/
getErrorMessageCfg(message, code, exception)
{
return this._obj_.apply(
{ exception,
build: this.build,
ctor: this.constructor.name,
code: code || this.MsgCode.ERROR_GENERAL,
export: this.isExport ? this.optionsKey : undefined,
module: this.isModule ? this.optionsKey : undefined,
plugin: this.isPlugin ? this.optionsKey : undefined,
message: message || exception?.message || "general unknown error"
}, this.getErrorDefaultCfg(message, code, exception));
}
/**
* @param {WpwBuildOptionsKey} [moduleKey]
* @returns {WpwCache}
*/
getStore(moduleKey)
{
let store = !moduleKey ? this.store : WpwModule[moduleKey];
if (!store)
{
const storeFile = this._path_.joinPath(this.build.virtualEntry.dirStore, `.${moduleKey}`);
if (existsSync(storeFile)){
store = this._fs_.readJsonSync(storeFile);
}
}
return store || {};
}
/**
* @private
* @returns {WpwBuildOptionsGroupKey | undefined}
*/
getOptionsGroup()
{
this._optionsGroup ||= WpwModule.getOptionsGroup(this.constructor.name);
return this._optionsGroup;
}
/**
* @private
* @returns {WpwBuildOptionsKey | WpwBuildOptionsGroupedKey}
*/
getOptionsKey()
{
this._optionsKey ||= WpwModule.getOptionsKey(this.constructor.name);
return this._optionsKey;
}
/**
* @param {string} name
* @returns {WpwBuildOptionsGroupKey | undefined}
*/
static getOptionsGroup(name)
{
let match;
const rgx = new RegExp(`(${WpwBuildOptionsGroupKeys.map(capitalize).join("|")})`);
if ((match = name.match(rgx)) !== null) {
return /** @type {WpwBuildOptionsGroupKey} */(match[1].toLowerCase());
}
}
/**
* @param {string} name
* @returns {WpwBuildOptionsKey | WpwBuildOptionsGroupedKey}
*/
static getOptionsKey(name)
{
const rgx = new RegExp(WpwBuildOptionsGroupKeys.map(capitalize).join("Plugin|") + "Plugin");
return /** @type {WpwBuildOptionsKey} */(
name.replace(rgx, "").replace(/^Wpw|Plugin$|Module$|Script$|Service$|(?:Webpack)?Export$/g, "").toLowerCase()
);
}
/**
* @since 1.10.1
* @returns {number}
*/
hashLength()
{
return this.build.compilation?.outputOptions.hashDigestLength || this.build.wpc.output.hashDigestLength || 16;
}
/**
* @template {any} [T=undefined]
* @since 1.11.0
* @param {string | number} [msg]
* @param {T} [rtn]
* @returns {T}
*/
hookdone(msg, rtn)
{
const l = this.logger,
lvl = /** @type {SpmhLogLevel} */(this._types_.isNumber(msg) ? msg : 1);
if (l.isLevelLogged(lvl))
{ const tag = l.tag(this.hookCurrent, null, null, false, true);
msg = this._types_.isString(msg, true) ? msg : this.hookMessages[this.hookCurrent].done;
return l.hookdone(`${tag} ${msg}`, lvl, this.build.errorCount > 0, rtn);
}
return rtn;
}
/**
* @since 1.11.0
* @param {string | number} [msg]
* @param {boolean} [writePluginOpts]
* @returns {WpwLogger}
*/
hookstart(msg, writePluginOpts)
{
const l = this.logger,
lvl = /** @type {SpmhLogLevel} */(l.isSpmhLogLevel(msg) ? msg : 1);
if (l.isLevelLogged(lvl))
{ const tag = l.tag(this.hookCurrent, null, null, false, true);
msg = this._types_.isString(msg, true) ? msg : this.hookMessages[this.hookCurrent].start;
l.hookstart(`${tag} ${msg}`, lvl);
if (writePluginOpts === true || l.isLevelLogged(4)) // &&(.hookCurrent==="compilation"||.hookCurrent==="procAssets"))
{ l.write("plugin options and relevant data:", lvl)
Object.keys(this.buildOptions) // .filter((k) => this._types_.isPrimitive(this.buildOptions[k]))
.sort().forEach((k) => l.value(k, this.buildOptions[k], lvl, " "));
}
}
return l;
}
/**
* @protected
* @param {string} hookName
* @param {"compiler" | "compilation"} hookType
* @param {string} configDsc
* @returns {SpmhMessage}
*/
invalidHookConfig(hookName, hookType = "compilation", configDsc = "async/sync")
{
return this.build.addMessage(
this.getErrorMessageCfg(
`invalid configuration for '${hookName}' ${hookType} hook - ${configDsc} [${this.optionsKey}]`
)
);
}
/**
* @type {SpmhAsyncHookValidatorFn<any, void>}
* @param {any} hook
*/
isAsyncHook(hook)
{
if (this._types_.isString(hook)) {
return !!this.compiler.hooks[hook]?.tapPromise || !!this.compilation.hooks[hook]?.tapPromise;
} return !!hook?.tapPromise;
}
/**
* @protected
* @type {SpmhSyncHookValidatorFn<any, void>}
* @param {any} hook
* @param {boolean} [strict]
*/
isSyncHook(hook, strict)
{
if (strict) {
return !!hook?.call;
} return !!hook?.tap;
}
/**
* @protected
* @param {any} hook
* @type {SpmhCompilationHookValidatorFn}
*/
isCompilationHook(hook)
{
if (!hook || this._types_.isString(hook)) {
return hook === "compilation" || hook === "thisTempCompilation";
}
return hook.name === "compilation" || hook.name=== "thisTempCompilation";
}
/**
* @protected
* @param {WpwWebpackCompilerHook | WebpackCompilationHookName} hook
* @returns {boolean} hook is WebpackHook
*/
isTapableHook(hook) { return !!this.compiler.hooks[hook]?.tap || !!this.compiler.hooks[hook]?.tapPromise ||
!!this.compilation.hooks[hook]?.tap|| !!this.compilation.hooks[hook]?.tapPromise; }
/**
* @protected
* @param {Array<WpwPluginCompilationTapOptionsPair<any,any,boolean>>} optionsArray
*/
tapCompilationHooks(optionsArray)
{
this.compiler.hooks.compilation.tap(this.name, (compilation) =>
{
this.compilation = this.build.compilation = this.build.compilation = compilation;
optionsArray.forEach(([ name, tapOpts ]) =>
{ if (!tapOpts.hookCompilation)
{ if (tapOpts.stage) {
tapOpts.hookCompilation = "processAssets";
}
else
{ return this.addMessage({
message: "invalid hook parameters: stage and hookCompilation not specified"
});
}
}
else if (tapOpts.hookCompilation === "processAssets" && !tapOpts.stage)
{ return this.addMessage({
message: "invalid hook parameters: stage not specified for processAssets"
});
}
this.tapCompilationStage(name, compilation, tapOpts);
});
});
}
/**
* @private
* @template T
* @template R
* @param {string} optionName
* @param {WebpackCompilation} compilation
* @param {WpwPluginCompilationTapOptions<T, R, boolean>} config
* @returns {void | SpmhError}
*/
tapCompilationStage(optionName, compilation, config)
{
const stageEnum = config.stage ? this.build.wp.Compilation[`PROCESS_ASSETS_STAGE_${config.stage}`] : null,
name = `${this.name}_${config.stage}`,
hook = compilation.hooks[config.hookCompilation];
if (!hook) {
return this.invalidHookConfig(config.hookCompilation, "compilation", "does not exist");
}
if (!this.isTapableHook(config.hookCompilation)) {
return this.invalidHookConfig(config.hookCompilation, "compilation", "not tapable");
}
if (stageEnum && config.hookCompilation === "processAssets")
{ if (this.isSyncHook(hook) && config.async !== true) {
hook.tap({ name, stage: stageEnum }, wrapHookHandler(optionName, false, this, config));
}
else if (this.isAsyncHook(hook) && config.async === true) {
hook.tapPromise({ name, stage: stageEnum }, wrapHookHandler(optionName, true, this, config));
}
else
{ return this.invalidHookConfig(
config.hookCompilation, "compilation", this.isSyncHook(hook) ? "invalid 'async' flag" : ""
);
}
}
else
{ if (this.isSyncHook(hook) && config.async !== true) {
hook.tap(name, wrapHookHandler(optionName, false, this, config));
}
else if (this.isAsyncHook(hook) && config.async === true) {
hook.tapPromise(name, wrapHookHandler(optionName, true, this, config));
}
else { return this.invalidHookConfig(config.hook); }
}
this.tapStatsPrinter(name, config);
}
/**
* @private
* @param {string} name
* @param {WpwPluginBaseTapOptions<any, any, boolean>} config
*/
tapStatsPrinter(name, config)
{
const p = this, l = this.build.logger, bClr = l.color || "spmh_blue",
property = config.statsProperty || this.optionsKey, tag = l.tag(breakProp(property, p), bClr, "default", true);
this.compilation.hooks.statsPrinter.tap(name, (stats) =>
{ const printFn = (prop, _ctx) => (prop ? tag : "");
stats.hooks.print.for(`asset.info.${property}`).tap(name, printFn);
});
}
/**
* @abstract
* @protected
* @param {WpwBuildOptionsConfig<WpwBuildOptionsKey>} _config
* @param {WpwBuild} _build
* @returns {boolean}
*/
static validate(_config, _build) { return true; }
/**
* @private
* @param {WpwModuleOptions} config
* @throws {SpmhError}
*/
validateOptionsKey(config)
{
const group = this.optionsGroup, key = this.optionsKey,
validKey = !group ? isWpwBuildOptionsPluginKey(key) || isWpwBuildOptionsExportKey(key) :
isWpwBuildOptionsGroupKey(group) && config.build.options[group][key];
if (this.isPlugin && !validKey) {
throw new Error(`build config key does not exist [${group ? `${group}.` : ""}${key}]`);
}
}
/**
* Callback function for plugin class instantitation, called from {@link WpwModule.create create()}
*
* @protected
* @static
* @param {WpwBuild} build current build wrapper
* @param {Array<boolean | string | undefined>} [xEnabledFlags]
* @returns {WpwModule | undefined}
*/
static wrap(build, ...xEnabledFlags)
{
let wpwModule;
const optionsKey = this.getOptionsKey(this.name), optionsGroup = this.getOptionsGroup(this.name),
isExport = isWpwBuildOptionsExportKey(optionsKey) || xEnabledFlags[0] === "isExport",
isPlugin = isWpwBuildOptionsPluginKey(optionsKey) || xEnabledFlags[0] === "isPlugin",
o = !optionsGroup ? build.options[optionsKey] : build.options[optionsGroup]?.[optionsKey],
enabled = !!(isExport || (
(!xEnabledFlags || xEnabledFlags.every((enabled) => !!enabled)) &&
(o && (o.enabled === true || (!isDefined(o.enabled) && o.disabled !== false)) && this.validate(o, build))
));
if (enabled)
{ const /** @type {WpwModuleOptions} */options = {
build, buildOptions: o, slug: optionsKey, type: isExport ? "export" : (isPlugin ? "plugin" : "service")
};
wpwModule = new this(options);
if (isExport) { wpwModule.create(build); }
}
return wpwModule;
}
}
module.exports = WpwModule;