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