plugins_base.js

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