plugins_shebang.js

// @ts-check

/**
 * @file src/plugins/shebang.js
 * @version 0.0.1
 * @copyright Scott P Meesseman 2024
 * @author Scott Meesseman @spmeesseman
 *//** */

const WpwPlugin = require("./base");
const { resolve } = require("path");
const { readFileSync, existsSync, chmodSync } = require("fs");


/**
 * @augments WpwPlugin
 */
class WpwShebangPlugin extends WpwPlugin
{
    static ranOnce = false;

    chmod = 0o755;
    entries;
    shebanged;
    shebangRegExp = /[\s\n\r]*(#!.*)[\s\n\r]*/gm;


    /**
     * @param {WpwPluginOptions} options Plugin options to be applied
     */
    constructor(options)
    {
        super(options);
        this.entries = {};
        this.shebanged = {};
        this.buildOptions = /** @type {WpwBuildOptionsPluginConfig<"shebang">} */(this.buildOptions);
    }


	/**
     * @override
     * @param {WpwBuild} b
     */
	static create = (b) => WpwShebangPlugin.wrap.call(this, b, b.type === "app");


    /**
     * @override
     * @returns {WpwPluginTapOptions<any, void, false> | undefined}
     */
    onApply()
    {
        /** @type {WpwPluginTapOptions<any, void, false>} */
        const config =
        {
            mapChunkToExecutableAsset:
            {
                logLevel: 3,
                hook: "compilation",
                hookCompilation: "chunkAsset",
                callback: this.mapChunkToExecutableAsset.bind(this)
            },
            findExecutableAssets:
            {
                hook: "entryOption",
                callback: this.findExecutableAssets.bind(this)
            },
            addShebangToExecutableAsset:
            {
                hook: "compilation",          // note minimizer opts must have { parser: { shebang: true } or
                stage: "ADDITIONS",           // it will remove shebang as it runs in the optimization stages
                statsProperty: "shebanged",   // of the compilation
                hookCompilation: "processAssets",
                callback: this.addShebangToExecutableAsset.bind(this)
            }
        };

        if (process.platform !== "win32")
        {
            config.chmodShebangedExecutableAsset = {
                hook: "assetEmitted",
                callback: this.chmodExecutableAsset.bind(this)
            };
        }

        return config;
    }


	/**
	 * @private
	 * @param {WebpackCompilationAssets} assets
	 */
    addShebangToExecutableAsset = (assets) =>
    {
        const l = this.hookstart(),
              info = (/** @type {WebpackAssetInfo} */ info) => this._obj_.apply({ ...(info || {}) }, { shebanged: true });
        Object.keys(assets).map((file) => [ file, this.fileNameStrip(file) ])
        .filter(([ file, fileStripped ]) => this.isOutputAsset(file) && !!this.entries[fileStripped])
        .forEach(([ file, fileStripped ]) =>
		{
            const { shebang } = this.entries[fileStripped];
            l.value(`   add shebang to '${file}'`, shebang, 1);
            this.compilation.updateAsset(file, (source) => this.addShebangToSource(file, shebang, source), info.bind(this));
            this.shebanged[file] = shebang;
        });
        this.hookdone();
    };


    /**
     * @private
     * @param {string} file
     * @param {string} shebang
     * @param {WebpackSource} sourceInfo
     * @returns {WebpackSource}
     */
    addShebangToSource(file, shebang, sourceInfo)
    {
        const { source, map } = sourceInfo.sourceAndMap(),
              rplRgx = new RegExp(`^.*?${this._rgx_.escapeRegExp(shebang)}.*?[\r\n]`),
              srcCode = `${shebang}\n` + source.toString().trimStart().replace(rplRgx, "");
        return (map && this.build.options.devtool?.enabled) ?
               new this.build.wp.sources.SourceMapSource(srcCode, file, map, source) :
               new this.build.wp.sources.RawSource(srcCode);
    }


    /**
     * Hook handler registered on non-windows platforms only
     *
     * @private
     * @param {string} file
     * @param {WebpackAssetEmittedInfo} assetInfo
     */
    chmodExecutableAsset = (file, assetInfo) =>
    {
        let target = assetInfo.targetPath;
        const hkMsg = `process emitted asset '${target}'`;
        const l = this.hookstart(hkMsg);
        if (!target && this.compiler.outputPath) {
            target = resolve(this.compiler.outputPath, file);
        }
        if (!target) {
            this.addMessage({ message: "could not find output" });
        }
        else if (file in this.shebanged)
        {
            l.value("chmod emitted asset", this.chmod, 1);
            chmodSync(target, this.chmod);
        }
        this.hookdone(hkMsg);
    };


    /**
     * @private
     * @param {WebpackChunk} chunk
     * @param {string} filename
     */
    mapChunkToExecutableAsset = (chunk, filename) =>
    {
        const name = chunk.name,
              hkMsg = `map shebang-check for asset '${name}' [${filename}]`,
              l = this.hookstart(hkMsg);
        if (name && name in this.entries)
        {
            l.value("   mapped asset filename", filename, 2);
            this.entries[filename] = this.entries[name];
            if (this.isOutputAsset(filename, false, false, true, true))
            {
                const nakedName = this.fileNameStrip(filename);
                l.value("   re-map to un-hashed asset name", nakedName, 2);
                this.entries[nakedName] = this.entries[name];
            }
        }
        else {
            l.write(`   ${filename} is not shebanged, nothing to do`, 1);
        }
        this.hookdone(hkMsg);
    };


    /**
     * @private
     * @param {string} context
     * @param {WebpackEntryNormalized} entries
     */
    findExecutableAssets = (context, entries) =>
    {
        const l = this.hookstart();
        for (const [ name, entry ] of Object.entries(entries))
        {
            let first = "";
            if (Array.isArray(entry)) {
                first = entry[0];
            }
            else if (Array.isArray(entry.import)) {
                first = entry.import[0];
            }
            else if (this._types_.isString(entry)) {
                first = entry;
            }
            if (!first) {
                this.addMessage({ message: "failed to find entry config [webpack@>=4.0.0 is required]" });
            }
            else
            {   const file = resolve(context, first);
                if (existsSync(file))
                {
                    const content = readFileSync(file, "utf8"),
                        matches = new RegExp(this.shebangRegExp).exec(content);
                    if (matches && matches[1])
                    {
                        l.value("found shebanged asset", name, 1);
                        l.value("   shebang line text", matches[1], 2);
                        this.entries[name] = { shebang: matches[1] };
                    }
                    else if (this.buildOptions.force && this.build.isNodeApp)
                    {
                        l.value("force adding node shebang to asset", name, 1);
                        this.entries[name] = { shebang: "#!/usr/bin/env node" };
                    }
                }
            }
        }
        l.write(`found ${Object.keys(this.entries).length} shebanged asset(s)`, 1);
        this.hookdone();
    };
}


module.exports = WpwShebangPlugin.create;