core_module.js

/**
 * @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;