exports_externals.js

/**
 * @file exports/externals.js
 * @copyright @spmhome @_2025
 * @author Scott Meesseman @spmeesseman
 *
 * @description @see {@link [externals](https://webpack.js.org/configuration/externals/)}
 *//** */

const { readdirSync } = require("fs");
const WpwWebpackExport = require("./base");
const isBuiltinNodeJsModule = require("node:module").isBuiltin;
const { isWebpackExternalsType } = require("../types/constants");


class WpwExternalsExport extends WpwWebpackExport
{
    /**
     * @private
     * @static
     */
    static regex = {
        dotPath: /(?:^|[\\/])\.[\w\-_]+$/,
        nodeModulePath: /[\\/]node_modules/,
        nodeModulesPathBaseAbs: /^.+?[/\\]node_modules/,
        nodeModulesPathBaseAbsTrSep: /^.*?[\\/]node_modules[\\/]/,
        relativeImportPath: /^\.\.?\//,
        relativeSassCssPath: /\/(?:sa|c)ss-loader\//,
        relativeImportTsConfigPath: /^[:#][\w-]+/,
        scopedPackage: /^@.+?\//,
        scopedPackageBaseDir: /^@[\w\-_]+$/,
        spmhUtilsPackage: /@spmhome\/[a-z]+-utils/
    };
    /**
     * @private
     */
    _importStats = { bundled: 0, external: 0, relative: 0, relativeVendor: 0, vendor: 0 };
    /**
     * @private
     * @type {string[]}
     */
    installedModules = [];
    /**
     * @private
     * @type {RegExp}
     */
    externalRgx;
    /**
     * @private
     * @type {RegExp | undefined}
     */
    bundledRgx;
    /**
     * @private
     * @type {RegExp | undefined}
     */
    ignoredRgx;
    /**
     * @private
     * @type {string[]}
     */
    rtExternals = [
        "append-transform", "chokidar", "esbuild", "eslint", "node-windows", "nyc", "schema-utils", "typescript", "webpack"
    ];
    /**
     * @private
     * @type {string[]}
     */
    alwaysExternal = [                                                                                   // "^(?:fs)?events$",
        "^append-transform$", "^caniuse.*$", "^chokidar$", "^esbuild$", "^eslint(?:\\/.+)?$", "^fsevents$",
        "humanfs", "^jiti.*$", "^karma$", "^.+-loader$", "^mini-css", "^mysql2?.*?$", "^node-windows$", "^nyc$", "^rollup.*$",
        "^sass$", "^schema-utils$", "^ts-node", "^typescript$", "^vscode$", "(?:^|-|\\/)webpack(?:$|\\/|-)"
    ];
    // /**
    //  * @private
    //  */
    // _externalInnerRgx = /node_modules[\\/]((?:(?:@.+?)[\\/])?([^ \\]+(?:$|[^\\/])|[^ \\/]+$|[^ \\/]))/;
    /**
     * @type {number | NodeJS.Timeout}
     */
    timer;


    /**
     * @param {WpwExportOptions} options Plugin options to be applied
     */
    constructor(options)
    {
        super({ ...options, hasPluginHooks: true });
        this.buildOptions = /** @type {WpwBuildOptionsExportConfig<"externals">} */(this.buildOptions);
    }

    /**
     * @override
     */
    static create = WpwExternalsExport.wrap.bind(this);


    /**
     * @override
     * @param {WpwBuild} build
     */
    app(build)
    {
        if (build.pkgJson.scopedName.scope === "@spmhome")
        {
            if (this.buildOptions.bundled) {
                this.buildOptions.bundled.push("^@spmhome\\/.+?-utils$");
            }
            else {
                this.buildOptions.bundled = [ "^@spmhome\\/.+?-utils$" ];
            }
        }
    }


    /**
     * @override
     * @param {WpwBuild} build
     */
    base(build)
    {
        const bo = this.buildOptions;
        this.setExternalPresets();
        build.logger.value("   externals presets", build.wpc.externalsPresets, 1);
        build.wpc.externalsType = isWebpackExternalsType(bo.type) ? bo.type : this.getExternalsType();
        build.logger.value("   default import type", build.wpc.externalsType, 2);
    }


    /**
     * @override
     * @param {WpwBuild} build
     */
    baseDone(build)
    {
        const l = build.logger, bo = this.buildOptions,
              ignRgxStr = bo.ignored && bo.ignored.length > 0 ? bo.ignored.join("|") : null,
              notExt = this._arr_.uniq([ ...(bo.bundled || []), ...(bo.noImportBundled || []) ]),
              bndRgxStr = this._arr_.uniq([ ...(bo.bundled || []), ...(bo.noImportBundled || []) ]).join("|") || null,
              extRgxStr = this._arr_.uniq([ ...this.alwaysExternal, ...(bo.external || []), ...(bo.ignored || []) ]).join("|"),
              allIncRgxStr = bo.all ? "^.+$" : (notExt.length > 0  ? `(?!(?:${notExt.join("|")}))` : "") + `(?:${extRgxStr})`;

        this.externalRgx = new RegExp(extRgxStr);
        build.externalsRgx = new RegExp(allIncRgxStr);
        this.ignoredRgx = ignRgxStr ? new RegExp(ignRgxStr) : undefined;
        this.bundledRgx = bndRgxStr ? new RegExp(bndRgxStr) : undefined;
        l.values([
            [ "all external", !!bo.all ], [ "ignored regex", ignRgxStr ], [ "bundled regex", bndRgxStr ],
            [ "external regex", extRgxStr ], [ "all inclusive regex", this.build.externalsRgx ], [ "raw", !!bo.raw ]
        ], 1, "", false, "configured externals regex patterns");

        this.getInstalledModules();

        if (!this._types_.isObject(bo.raw, true))
        {
            build.logger.write("add externals inline runtime processor", 3);
            build.wpc.externals = [ this.processModule.bind(this) ];
        }
        else
        {
            if(build.logger.level <= 2) {
                build.logger.write("add externals configured 'raw' value", 1);
            }
            else {
                build.logger.value("add externals configured 'raw' value", bo.raw, 3);
            }
            build.wpc.externals = this._arr_.asArray(bo.raw);
        }
    }


    /**
     * @override
     */
    doxygen() {}


    /**
     * @override
     */
    extjsdoc() {}


    /**
     * @private
     * @param {string} [moduleName]
     * @param {boolean} [isBuiltin]
     * @param {string} [_ctxDepType] i.e. data.dependencyType
     * @returns {WebpackExternalsType}
     */
    getExternalsType(moduleName, isBuiltin, _ctxDepType)
    {
        const build = this.build;
        let /** @type {WebpackExternalsType} */type;
        if (isWebpackExternalsType(this.buildOptions.type))
        {
            type = this.buildOptions.type;
        }
        // else if (moduleName === "vscode")
        // {
        // 	type = "commonjs";
        // }
        else if (build.isWeb)
        {
            type = !build.isModule ? "commonjs" : (isBuiltin ? "import" : "module-import");
        }
        else {
            type = !build.isModule ? "commonjs" : (isBuiltin ? "node-commonjs" : "import");
        }
        return type;
    }


    /**
     * @private
     * @throws {SpmhError}
     */
    getInstalledModules()
    {
        const b = this.build, l = this.logger, p = b.pkgJson, path = this._path_,
            store = this.store.get("node_modules", /** @type {Record<string, any>} */({})),
            lastHash = store.hash, lastNodeModulesCount = store.nodeModulesCount || 0,
            hash = this._crypto_.getMd5(this._json_.safeStringify(this._obj_.pickBy(p, (d) => /dependencies/.test(d))), "hex"),
            nodeModules = [ ...Object.keys(p.dependencies || []), ...Object.keys(p.devDependencies || []),
                            ...Object.keys(p.optionalDependencies || []), ...Object.keys(p.peerDependencies || []) ],
            nodeModulesCount = nodeModules.length;

        l.write("   examine project node_modules folder", 1);
        l.value("      previous package.json level1 dependencies count", lastNodeModulesCount || "n/a", 1);
        l.value("      current package.json level1 dependencies count", nodeModulesCount, 1);

        if (store.nodeModules && lastNodeModulesCount > 0 && lastNodeModulesCount === nodeModulesCount && hash === lastHash)
        {
            this.installedModules = store.nodeModules;
            l.write(`      use cached node_modules list [total::${lastNodeModulesCount}]`, 1);
        }
        else
        {   const _examinePackage = /* async */ (/** @type {string} */pkg) =>
            {   if (this._path_.isAbsPath(pkg))
                {   this.installedModules.push(
                        ...readdirSync(pkg).map((mod) =>
                        {   if (WpwExternalsExport.regex.scopedPackageBaseDir.test(mod))
                            {   try {
                                    return readdirSync(path.resolvePath(pkg, mod)).map((m) => `${mod}/${m}`);
                                } catch { return [ mod ]; }
                            }
                            else if (WpwExternalsExport.regex.scopedPackage.test(mod))
                            {   try {
                                    return readdirSync(path.resolvePath(pkg, mod)).map((m) => path.joinPath(mod, m));
                                }
                                catch { return [ mod ]; }
                            }
                            return [ mod ];
                        }).reduce((prev, next) => this._arr_.pushUniq(prev, ...next), [])
                    );
                } else { if (b.pkgJson.engines?.[pkg] || this._fs_.existsSync(pkg)) this.installedModules.push(pkg); }
            };
            for (const m of b.nodeModulesPaths.all) { _examinePackage(m); }
            this.store.set({ node_modules: { nodeModulesCount, hash, nodeModules: this.installedModules }});
        }

        if (l.level === 3 || l.level === 4)
        {
            l.value("      package.json node_modules", this.installedModules.join(" | "),3);
            if (l.level === 4) {
                l.value("      installed node_modules", this.installedModules.join(" | "), 4);
            }
        }
        else if (l.level === 5) {
            l.value("      installed node_modules", this.installedModules, 5);
        }
        l.write(`   processed ${this.installedModules.length} installed node_modules [direct::${nodeModulesCount}]`, 1);

    }


    /**
     * @private
     * @param {string} request
     * @returns {string}
     */
    getModuleName(request)
    {
        let mn =  request.replace(WpwExternalsExport.regex.nodeModulesPathBaseAbsTrSep, "");
        const aliasCfg = this.build.wpc.resolve.alias;
        if (this._types_.isObject(aliasCfg) && mn.startsWith(":"))
        {   const alias = Object.entries(aliasCfg).find(([ a ]) => mn.startsWith(a));
            for (const path of alias[1].map(this._path_.normalizePath))
            {   const mna = this._path_.relativePathEx(
                    this.build.getSrcPath(), this._path_.normalizePath(mn.replace(alias[0], path)), { psx: true, dot: true }
                ); if (mna) { mn = mna; break; }
        }   }
        return mn;
    }


    /**
     * @private
     * @param {string} ctx
     * @returns {string}
     */
    getModuleNameFromCtx(ctx)
    {
        if (WpwExternalsExport.regex.nodeModulePath.test(ctx))
        {
            const name = ctx.replace(WpwExternalsExport.regex.nodeModulesPathBaseAbsTrSep, "")
                            .replace(WpwExternalsExport.regex.scopedPackage, "");
            return !name.includes("/") ? name : name.split("/").slice(0, 2).join("/");
        }
        return "___invalid___";
    }


    /**
     * @private
     * @param {string} moduleName module / package name, scope inclusive
     * @param {boolean} builtIn
     * @param {string} dataReq
     * @param {string} dataCtx
     * @param {boolean} [vEntry]
     * @returns {boolean}
     */
    isExternalModule(moduleName, builtIn, dataReq, dataCtx, vEntry)
    {
        let isExt = false;
        const isRelImport = this.isRelativeImport(dataReq),
              isSassCssImport = this.isSassCssImport(dataReq),
              bundleBuiltIn = this.buildOptions.bundleBuiltIn,
              bundleRuntime = this.buildOptions.bundleRuntime,
              issuerModuleName = this.getModuleNameFromCtx(dataCtx),
              isInstalled = !vEntry && (this.installedModules.includes(moduleName) ||
                            (!isRelImport && this.installedModules.includes(issuerModuleName)));

        this.logger.values([
            [ "requested", dataReq ], [ "is relative", isRelImport ], [ "is built-in", builtIn ], [ "is v-entry", !!vEntry ],
            [ "is installed", isInstalled ], [ "requested by", issuerModuleName ], [ "request context", dataCtx ]
        ], 4, "", false, `determine if imported module '${moduleName}' will be bundled or is externalzzzzzz`);

        if (isSassCssImport || (builtIn && bundleBuiltIn !== true))
        {
            isExt = !isSassCssImport;
        }
        else if (isInstalled)
        {
            const cfgIgnored= !!this.ignoredRgx?.test(moduleName),
                  cfgBundled = !!this.bundledRgx?.test(moduleName),
                  cfgExternal = this.externalRgx.test(moduleName),
                  cfgBundleRt = bundleRuntime === false,
                  parentIsEx =  isRelImport && !!this.rtExternals.find((r) => r === issuerModuleName) && !cfgBundleRt;
            this.logger.values([
                [ "ignore", cfgIgnored ], [ "bundle", cfgBundled ], [ "external", cfgExternal ],
                [ "request issuer module is external", parentIsEx ]
            ], 5, "   ", false, "rc configured externals regexes");
            isExt = this.buildOptions.all || cfgIgnored || (!cfgBundled && (cfgExternal || parentIsEx));
            if (isExt && !parentIsEx && !isRelImport && WpwExternalsExport.regex.nodeModulePath.test(dataCtx))
            {
                this.logger.write("   module identified as 'runtime external'", 4);
                this.rtExternals.push(moduleName);
            }
        }
        else if (!isRelImport)
        {
            isExt = !!vEntry || !!builtIn || !!(this.build.pkgJson.engines?.[moduleName]) || bundleRuntime === false ||
                    (!this.bundledRgx?.test(moduleName) && this.externalRgx.test(moduleName));
        }

        this.logger.write(`module '${moduleName}' is ${!isExt ? "not" : (builtIn ? "built-in |" : "")} external`, 4);
        return isExt;
    }


    /**
     * @param {string} moduleName
     * @returns {boolean}
     */
    isRelativeImport(moduleName) { return WpwExternalsExport.regex.relativeImportPath.test(moduleName); }


    /**
     * @param {string} moduleName
     * @returns {boolean}
     */
    isSassCssImport(moduleName)
    {
        return this.build.isWebApp && WpwExternalsExport.regex.relativeSassCssPath.test(moduleName);
    }


    /**
     * @override
     */
    jsdoc() {}


    /**
     * @override
     * @param {WpwBuild} build
     */
    lib(build)
    {
        const rgx = WpwExternalsExport.regex.spmhUtilsPackage;
        this._arr_.pushUniq(this.alwaysExternal,
            ...Object.keys(this._obj_.asObject(build.pkgJson.dependencies)).filter((p) => rgx.test(p)).map((p) => `^${p}$`),
            ...Object.keys(this._obj_.asObject(build.pkgJson.peerDependencies)).map((p) => `^${p}$`),
            ...Object.keys(this._obj_.asObject(build.pkgJson.optionalDependencies)).map((p) => `^${p}$`)
        );
    }


    /**
     * @override
     * @param {WpwBuild} build
     */
    plugin(build) { this.app(build); }


    // /**
    //  * @private
    //  */
    // printResults()
    // {
    // 	const logger = this.xLogger,
    // 			icon = withColor(logger.icons.star, logger.color);
    // 	logger.write(`${icon}${icon} externals totals ${icon}${icon}`, 1);
    // 	logger.value("   # of bundled node_modules", this._importStats.bundled, 1);
    // 	logger.value("   # of external node_modules", this._importStats.external, 1);
    // 	logger.value("   # of internal relative path imports", this._importStats.relative, 1);
    // 	logger.value("   # of vendor relative path imports", this._importStats.relativeVendor, 1);
    // }


    /**
     * @private
     * @param {IWpwRuntimeExternal} nm
     * @param {Readonly<WebpackExternalItemFunctionData>} data
     */
    processFirstImport(nm, data)
    {
        const b = this.build,
            logger = this.logger,
            ctx = nm.ctx.replace(WpwExternalsExport.regex.nodeModulesPathBaseAbs, "node_modules")
                        .replace(new RegExp(`^${this._rgx_.escapeRegExp(b.getBasePath())}[/\\\\]`), "")
        if (nm.external)
        {
            ++this._importStats.external;
            this.xLogger.write(
                `[italic(external)] ${nm.builtin ? "built-in module" : "vendor package"} '${nm.name}' @ '${ctx}'`, 2
            );
        }
        else if (this.isSassCssImport(nm.name))
        {
            ++this._importStats.bundled;
            if (!nm.ctx.includes("node_modules"))
            {   this.xLogger.write(
                    `[italic(bundle)] stylesheet '${nm.name.substring(nm.name.lastIndexOf("/") + 1)}' @ '${ctx}'`, 2
                );
            }
        }
        else if (this.isRelativeImport(nm.name))
        {
            ++this._importStats.bundled;
            if (!nm.ctx.includes("node_modules")) {
                this.xLogger.write(`[italic(bundle)] module '${nm.name}' @ '${ctx}'`, 2);
            }
            else {
                ++this._importStats.relativeVendor;
                this.xLogger.write(`[italic(bundle)] vendor module '${nm.name}' @ '${ctx}'`, 4);
            }
        }
        else
        {   ++this._importStats.vendor;
            ++this._importStats.bundled;
            if (nm.name.startsWith("@spmhome")) {
                this.xLogger.write(`[italic(bundle)] spmhome package '${nm.name}' @ '${ctx}'`, 2);
            }
            else if (nm.name.startsWith("react")) {
                this.xLogger.write(`[italic(bundle)] react package '${nm.name}' @ '${ctx}'`, 2);
            }
            else {
                this.xLogger.write(`[italic(bundle)] vendor package '${nm.name}' @ '${ctx}'`, 2);
            }
        }

        if (logger.level >= 2)
        {
            logger.values([
                [ "request", nm.req ], [ "context", nm.ctx ], [ "dependency type", data.dependencyType ]
            ], 3, "   ", 0, 0, 0, [ "externals" ]);
            if (data.contextInfo && logger.level >= 4)
            {
                logger.values([
                    [ "issuer", data.contextInfo.issuer ], [ "issuer layer", data.contextInfo.issuerLayer ]
                ], 4, "   ", 0, 0, 0, [ "externals" ]);
            }
        }

        if (this.timer) {
            clearTimeout(this.timer);
        }
        this.timer = setTimeout(() => { this.store.set({ node_modules_imported: this.build.nodeModules }); }, 250);
    }


    // param {(e?: Error | null, result?: string, type?: WebpackExternalsType) => void} cb
    /**
     * @private
     * @param {Readonly<WebpackExternalItemFunctionData>} data
     * @param {any} cb
     * @returns {any}
     */
    processModule(data, cb)
    {
        if (data?.request && !this.build.hasGlobalError)
        {   const b = this.build,
                  req = this._path_.fwdSlash(data.request),
                  ctx = this._path_.fwdSlash(data.context),
                  name = this.getModuleName(req),
                  builtin = b.wpc.resolve.fallback?.[name.replace(/\.\w$/, "")] ? false : isBuiltinNodeJsModule(name),
                  vEntry = !!name.match(new RegExp(`${name}\\.(?:${this.outputExt.substring(1)}|${b.source.ext})$`)),
                  external = this.isExternalModule(name, builtin, req, ctx, vEntry);
            if (!vEntry && !b.nodeModules.find((e) => e.name === name))
            {
                const rt = external && (this.rtExternals.includes(name) ||
                    this.rtExternals.find((r) => ctx.includes(`/${r}/`) && !(b.nodeModules.find((e) => e.name === r)?.external)));
                const rtExternal = { name, builtin, external, ctx, req, rt: !!rt };
                this.processFirstImport(this._arr_.pushReturnOne(b.nodeModules, rtExternal), data);
            }
            if (external) {
                return cb(null, `${this.getExternalsType(name, builtin, data.dependencyType)} ${name}`);
            }
        }
        return cb();
    }


    /**
     * @override
     */
    resource() { this.buildOptions.all = true; }


    /**
     * @override
     */
    schema() { this.buildOptions.all = true; }


    /**
     * @override
     */
    script() { this.buildOptions.all = true; }


    /**
     * @private
     */
    setExternalPresets()
    {
        const bo = this.buildOptions;
        if (bo.presets?.length)
        {
            this.build.logger.write(`   set externals presets '${bo.presets.join(" | ")}'`, 1);
            this.build.wpc.externalsPresets = bo.presets.reduce((p, c) => this._obj_.apply(p, { [c]: true }), {});
        }
        else if (!this.build.isWeb)
        {
            this.build.wpc.externalsPresets = { node: true };
        }
        else {
            this.build.wpc.externalsPresets = { web: true };
        }
    }


    /**
     * @override
     */
    tests() { this.buildOptions.all = true; }


    /**
     * @override
     */
    types() { this.buildOptions.all = true; }


    /**
     * @override
     * @param {WpwBuild} build
     */
    webapp(build) { this.app(build); }
}


module.exports = WpwExternalsExport.create;