blob: 3819a1523d41d7d88fb8b31c48267c2335faee5c [file]
#!/usr/bin/env node
/**
* @fileoverview wraps the terser CLI to support minifying a directory
* Terser doesn't support it; see https://github.com/terser/terser/issues/75
* TODO: maybe we should generalize this to a package which would be useful outside
* bazel; however we would have to support the full terser CLI and not make
* assumptions about how the argv looks.
*/
const fs = require('fs');
const path = require('path');
const child_process = require('child_process');
const os = require('os')
// Run Bazel with --define=VERBOSE_LOGS=1 to enable this logging
const VERBOSE_LOGS = !!process.env['VERBOSE_LOGS'];
function log_verbose(...m) {
if (VERBOSE_LOGS) console.error('[terser/index.js]', ...m);
}
function log_error(...m) {
console.error('[terser/index.js]', ...m);
}
function isDirectory(input) {
return fs.lstatSync(path.join(process.cwd(), input)).isDirectory();
}
// Returns a single quotes version of str
function singleQuotes(str) {
return `'${str.replace(/'/g, '').replace(/"/g, '')}'`;
}
// Ensures that args are well formed.
// Work-around for an issue on Windows when exec bin path is not quoted.
// In --source-map, base=bazel-out/x64_windows-opt-exec-2B5CBBC6/bin must
// be quoted such as base='bazel-out/x64_windows-opt-exec-2B5CBBC6/bin' pr
// terser fails with
// ERROR: `includeSources,base=bazel-out/x64_windows-opt-exec-2B5CBBC6/bin,content=inline,url=bundle.min.js.map` is not a supported option
function fixArgs(args) {
const sourceMapIndex = args.indexOf('--source-map');
if (sourceMapIndex === -1) {
return args;
}
let sourceMapOptions = args[sourceMapIndex + 1].split(',');
sourceMapOptions = sourceMapOptions.map(o => {
const s = o.split('=');
if (s.length == 1) {
return o;
}
switch (s[0]) {
case 'base':
case 'content':
case 'url':
return `${s[0]}=${singleQuotes(s[1])}`;
default:
return o;
}
});
return [
...args.slice(0, sourceMapIndex + 1),
sourceMapOptions.join(','),
...args.slice(sourceMapIndex + 2),
];
}
/**
* Replaces directory url with the outputFile name in the url option of source-map argument
*/
function directoryArgs(residualArgs, inputFile, outputFile) {
const sourceMapIndex = residualArgs.indexOf('--source-map');
if (sourceMapIndex === -1) {
return residualArgs;
}
let sourceMapOptions = residualArgs[sourceMapIndex + 1].split(',');
// set the correct sourcemap url for this output file
sourceMapOptions = sourceMapOptions.map(
o => o.startsWith('url=') ? `url='${path.basename(outputFile)}.map'` : o);
// if an input .map file exists then set the correct sourcemap content option
if (fs.existsSync(`${inputFile}.map`)) {
// even on Windows terser expects '/' path separators so we normalize these in the sourcemap
// content file path below
sourceMapOptions = sourceMapOptions.map(
o => o.startsWith('content=') ? `content='${inputFile.replace(/\\/g, '/')}.map'` : o);
}
return [
...residualArgs.slice(0, sourceMapIndex + 1),
sourceMapOptions.join(','),
...residualArgs.slice(sourceMapIndex + 2),
];
}
function terserDirectory(input, output, residual, terserBinary) {
if (!fs.existsSync(output)) {
fs.mkdirSync(output);
}
const TERSER_CONCURENCY = (process.env.TERSER_CONCURRENCY || os.cpus().length - 1) || 1
let work = [];
let active = 0;
let errors = [];
function exec([inputFile, outputFile]) {
active++;
let args = [
terserBinary, inputFile, '--output', outputFile,
...directoryArgs(residual, inputFile, outputFile)
];
spawn(process.execPath, [...process.execArgv, ...args])
.then(
(data) => {
if (data.code) {
errors.push(inputFile)
// NOTE: Even though a terser process has errored we continue here to collect all of
// the errors. this behavior is another candidate for user configuration because
// there is value in stopping at the first error in some use cases.
log_error(`errored: ${inputFile}\nOUT: ${data.out}\nERR: ${data.err}\ncode: ${
data.code}`);
} else {
log_verbose('finished: ', inputFile);
}
--active;
next();
},
(err) => {
--active;
log_error('errored: [spawn exception]', inputFile, '\n' + err)
errors.push(inputFile)
next();
})
}
function next() {
if (work.length) {
exec(work.shift());
} else if (!active) {
if (errors.length) {
log_error('terser errored processing javascript in directory.')
process.exitCode = 2;
}
// NOTE: work is done at this point and node should exit here.
}
}
fs.readdirSync(input).forEach(f => {
if (path.extname(f) === '.js' || path.extname(f) === '.mjs') {
const inputFile = path.join(input, path.basename(f));
const outputFile = path.join(output, path.basename(f));
if (active < TERSER_CONCURENCY) {
exec([inputFile, outputFile]);
} else {
work.push([inputFile, outputFile])
}
}
});
}
function spawn(cmd, args) {
return new Promise((resolve, reject) => {
const err = [];
const out = [];
// this may throw syncronously if the process cannot be created.
let proc = child_process.spawn(cmd, args);
proc.stdout.on('data', (buf) => {
out.push(buf);
});
proc.stderr.on('data', (buf) => {err.push(buf)})
proc.on('exit', (code) => {
// we never reject here based on exit code because an error is a valid result of running a
// process.
resolve({out: Buffer.concat(out), err: err.length ? Buffer.concat(err) : false, code});
});
})
}
function main() {
process.argv = fixArgs(process.argv)
// Peek at the arguments to find any directories declared as inputs
let argv = process.argv.slice(2);
// terser_minified.bzl always passes the inputs first,
// then --output [out], then remaining args
// We want to keep those remaining ones to pass to terser
// Avoid a dependency on a library like minimist; keep it simple.
const outputArgIndex = argv.findIndex((arg) => arg.startsWith('--'));
// We don't want to implement a command-line parser for terser
// so we invoke its CLI as child processes when a directory is provided, just altering the
// input/output arguments. See discussion: https://github.com/bazelbuild/rules_nodejs/issues/822
const inputs = argv.slice(0, outputArgIndex);
const output = argv[outputArgIndex + 1];
const residual = argv.slice(outputArgIndex + 2);
// Allow for user to override terser binary via TERSER_BINARY for testing
let terserBinary = process.env.TERSER_BINARY;
if (!terserBinary) {
try {
// Node 12 and above will respect exports field in package.json, Terser 5 added these
// but did not add ./bin/terser as an export so we instead resolve to terser/package.json
// and strip the /package.json and add /bin/terser in its place. This has now been
// fixed upstream in https://github.com/terser/terser/pull/971 but this code should remain
// so we support all versions of terser.
// NB: slice(0,-13) trims the '/pacakge.json' from the end of the resolved path.
const terserNpmPath = require.resolve('terser/package.json').slice(0,-13);
terserBinary = `${terserNpmPath}/bin/terser`;
if (!fs.existsSync(terserBinary)) {
// Try the old `uglifyjs` binary from <4.3.0
terserBinary = `${terserNpmPath}/bin/uglify`;
}
} catch (_) {
// fall through here; will check for valid terserBinary below
}
}
if (!terserBinary || !fs.existsSync(terserBinary)) {
throw new Error('terser binary not found. Maybe you need to set the terser_bin attribute?')
}
// choose a default concurrency of the number of cores -1 but at least 1.
log_verbose(`Running terser/index.js
inputs: ${inputs}
output: ${output}
residual: ${residual}`);
if (!inputs.find(isDirectory) && inputs.length) {
// Inputs were only files
// Just use terser CLI exactly as it works outside bazel
require(terserBinary);
} else if (inputs.length > 1) {
// We don't know how to merge multiple input dirs to one output dir
throw new Error('terser_minified only allows a single input when minifying a directory');
} else if (inputs[0]) {
terserDirectory(inputs[0], output, residual, terserBinary);
}
}
// export this for unit testing purposes
exports.directoryArgs = directoryArgs;
if (require.main === module) {
main();
}