quickbundle
Version:
The zero-configuration transpiler and bundler for the web
605 lines (594 loc) • 22.1 kB
JavaScript
import { helpers, termost } from 'termost';
import { finished } from 'node:stream/promises';
import { Readable } from 'node:stream';
import process from 'node:process';
import { resolve, dirname, join, basename } from 'node:path';
import { copyFile as copyFile$1, rename, mkdir, writeFile as writeFile$1, rm, readFile as readFile$1 } from 'node:fs/promises';
import { createWriteStream } from 'node:fs';
import decompress from 'decompress';
import { watch as watch$1, rollup } from 'rollup';
import { createRequire } from 'node:module';
import { swc } from 'rollup-plugin-swc3';
import externals from 'rollup-plugin-node-externals';
import dts from 'rollup-plugin-dts';
import url from '@rollup/plugin-url';
import { nodeResolve } from '@rollup/plugin-node-resolve';
import json from '@rollup/plugin-json';
import commonjs from '@rollup/plugin-commonjs';
import os from 'node:os';
import { gzipSize } from 'gzip-size';
/**
* Resolve a relative path from the Quickbundle node modules directory.
* @param paths - Relative paths.
* @returns The resolved absolute path.
* @example
* resolveFromInternalDirectory("dist", "node");
*/ const resolveFromInternalDirectory = (...paths)=>{
return resolve(import.meta.dirname, "../", ...paths);
};
/**
* Resolve a relative path from the current working project directory.
* @param paths - Relative paths.
* @returns The resolved absolute path.
* @example
* resolveFromExternalDirectory("package.json");
*/ const resolveFromExternalDirectory = (...paths)=>{
return resolve(process.cwd(), ...paths);
};
const createRegExpMatcher = (regex)=>{
return (value)=>{
return regex.exec(value)?.groups;
};
};
const createDirectory = async (path)=>{
await mkdir(path, {
recursive: true
});
};
const copyFile = async (fromPath, toPath)=>{
await createDirectory(dirname(toPath));
await copyFile$1(fromPath, toPath);
};
const removePath = async (path)=>{
await rm(path, {
force: true,
recursive: true
});
};
const readFile = async (filePath)=>{
return readFile$1(filePath);
};
const writeFile = async (filePath, content)=>{
await createDirectory(dirname(filePath));
await writeFile$1(filePath, content, "utf8");
};
const download = async (url, filePath)=>{
await createDirectory(dirname(filePath));
const { body, ok, status, statusText } = await fetch(url);
if (!ok) {
throw new Error(`An error ocurred while downloading \`${url}\`. Received \`${status}\` status code with the following message \`${statusText}\`.`);
}
if (!body) {
throw new Error(`Empty body received while downloading \`${url}\`.`);
}
await finished(Readable.fromWeb(body).pipe(createWriteStream(filePath)));
};
const unzip = async (input, output)=>{
const { targetedArchivePath } = input;
const { directoryPath } = output;
await decompress(input.path, directoryPath, {
filter (file) {
return file.path === targetedArchivePath;
}
});
await rename(join(directoryPath, targetedArchivePath), join(directoryPath, output.filename));
};
const createCommand = (program, input)=>{
return program.command(input).option({
key: "minification",
name: "minification",
description: "Enable minification",
defaultValue: false
}).option({
key: "sourceMaps",
name: "source-maps",
description: "Enable source maps generation",
defaultValue: false
});
};
const onLog = (_, log)=>{
if (log.message.includes("Generated an empty chunk")) return;
};
const isRecord = (value)=>{
return typeof value === "object" && value !== null && !Array.isArray(value);
};
const watch = (input)=>{
process.env.NODE_ENV ??= "development";
const watcher = watch$1(input.data.map((configItem)=>({
...configItem,
onLog
})));
let startDuration;
console.clear();
watcher.on("event", async (event)=>{
switch(event.code){
case "START":
{
startDuration = Date.now();
clearLog("Build in progress…", {
type: "information"
});
return;
}
case "BUNDLE_END":
{
await event.result.close();
break;
}
case "END":
{
const duration = Date.now() - startDuration;
clearLog(`Build done in ${duration}ms (at ${new Date().toLocaleTimeString()})`, {
type: "success"
});
return;
}
case "ERROR":
{
const { error } = event;
clearLog(error.message, {
type: "error"
});
console.error("\n", error);
return;
}
}
});
};
const clearLog = (...input)=>{
console.clear();
helpers.message(...input);
};
const require = createRequire(import.meta.url);
const PKG = require(resolveFromExternalDirectory("package.json"));
const DEFAULT_OPTIONS = {
minification: false,
sourceMaps: false,
standalone: false
};
const createConfiguration = (options = DEFAULT_OPTIONS)=>{
const buildableExports = getBuildableExports(options);
return {
data: buildableExports.flatMap((buildableExport)=>{
return [
buildableExport.source && createMainConfig({
...buildableExport,
source: buildableExport.source
}, options),
buildableExport.source && buildableExport.types && createTypesConfig({
source: buildableExport.source,
types: buildableExport.types
}, options)
].filter(Boolean);
}),
metadata: buildableExports
};
};
// eslint-disable-next-line sonarjs/cyclomatic-complexity
const getBuildableExports = ({ standalone })=>{
if (standalone) {
/**
* Entry-point resolution invariants for standalone target (mostly binaries).
*/ if (!PKG.source || !PKG.bin || !PKG.name) {
throw new Error("Invalid package entry points contract. Standalone compilation is enabled but required fields are missing. Make sure to set `name`, `source`, and `bin` fields.");
}
const bin = PKG.bin;
const name = PKG.name;
const source = PKG.source;
if (isRecord(bin)) {
return Object.entries(bin).map((data)=>({
bin: data[0],
require: data[1],
source
}));
}
return [
{
// For scoped packages and if the `bin` is defined with a string value, the [scope name is discarded](the scope name is discarded when creating a binary) when creating a binary.
bin: name.replace(/^(@.*?\/)/, ""),
require: bin,
source
}
];
}
/**
* Entry-point resolution invariants for non-standalone target (mostly libraries):
* Following the [package entry-point specification](https://nodejs.org/api/packages.html#package-entry-points),
* whenever an export object is defined, it take precedence over other classical entry-point fields
* (such as main, module, and types defined at the root package.json level).
*/ if (PKG.main || PKG.module || PKG.types || !PKG.exports) {
throw new Error("Invalid package entry points contract. Use the recommended [`exports` field](https://nodejs.org/api/packages.html#package-entry-points) instead and, for TypeScript-based projects, update the `tsconfig.json` file to resolve it properly (`moduleResolution` must be set to `Bundler` (or `NodeNext`)).");
}
const buildableExportFields = [
"default",
"import",
"require",
"types"
];
let singleExport = undefined;
const output = Object.entries(PKG.exports).map(([field, value])=>{
if (isRecord(value)) {
return [
field,
value
];
}
if ([
"source",
...buildableExportFields
].includes(field)) {
if (!singleExport) {
singleExport = {};
singleExport[field] = value;
return [
".",
singleExport
];
}
singleExport[field] = value;
}
return undefined;
}).reduce((buildableExports, currentExport)=>{
if (!currentExport) return buildableExports;
const [exportField, exportValue] = currentExport;
const conditionalExportFields = Object.keys(exportValue);
if (!conditionalExportFields.includes("source")) return buildableExports;
const hasAtLeastOneRequiredField = buildableExportFields.some((entryPointField)=>conditionalExportFields.includes(entryPointField));
if (hasAtLeastOneRequiredField) {
buildableExports.push(exportValue);
return buildableExports;
}
throw new Error(`A \`source\` field is defined without an output defined for the \`${exportField}\` export. Make sure to define at least one conditional entry point (including ${buildableExportFields.map((field)=>`\`${field}\``).join(", ")})`);
}, []);
if (output.length === 0) {
throw new Error("No `source` field is set for the targeted package. If a build step is necessary, make sure to configure at least one `source` field in the package `exports` contract. If not, do not execute quickbundle on this package.");
}
return output;
};
const getPlugins = (customPlugins, options)=>{
return [
!options.standalone && externals({
builtins: true,
deps: true,
/**
* As they're not installed consumer side, `devDependencies` are declared as internal dependencies (via the `false` value)
* and bundled into the dist if and only if imported and not listed as `peerDependencies` (otherwise, they're considered external).
*/ devDeps: false,
optDeps: true,
peerDeps: true
}),
commonjs(),
url(),
json(),
...customPlugins
].filter(Boolean);
};
const createMainConfig = (entryPoints, options)=>{
const { minification, sourceMaps } = options;
const esmInput = entryPoints.import ?? entryPoints.default;
if (entryPoints.import && entryPoints.default && entryPoints.import !== entryPoints.default) {
throw new Error("Both `import` and `default` export fields have been defined but with different values. To preserve proper `default` field resolution on the consumer side (i.e. to target ESM format), make sure to provide the same file path for both fields.");
}
const output = [
entryPoints.require && {
file: entryPoints.require,
format: "cjs",
inlineDynamicImports: Boolean(options.standalone),
sourcemap: sourceMaps
},
esmInput && {
file: esmInput,
format: "es",
sourcemap: sourceMaps
}
].filter(Boolean);
return {
input: entryPoints.source,
output,
plugins: getPlugins([
nodeResolve(),
swc({
minify: minification,
sourceMaps
})
], options)
};
};
const createTypesConfig = (entryPoints, options)=>{
return {
input: entryPoints.source,
output: [
{
file: entryPoints.types
}
],
plugins: getPlugins([
nodeResolve({
/**
* The `exports` conditional fields definition order is important in the `package.json file`.
* To be resolved first, `types` field must always come first in the package.json exports definition.
* @see https://devblogs.microsoft.com/typescript/announcing-typescript-4-7/#package-json-exports-imports-and-self-referencing.
*/ exportConditions: [
"types"
]
}),
dts({
compilerOptions: {
declaration: true,
emitDeclarationOnly: true,
incremental: false,
preserveSymlinks: false
},
respectExternal: true
})
], options)
};
};
const createWatchCommand = (program)=>{
return createCommand(program, {
name: "watch",
description: "Watch and rebuild on any code change (development mode)"
}).task({
handler (context) {
watch(createConfiguration({
minification: context.minification,
sourceMaps: context.sourceMaps,
standalone: false
}));
}
});
};
const build = async (input)=>{
process.env.NODE_ENV ??= "production";
const { data: configurations } = input;
const output = [];
for (const config of configurations){
const initialTime = Date.now();
const bundle = await rollup({
...config,
onLog
});
if (config.output) {
const outputEntries = Array.isArray(config.output) ? config.output : [
config.output
];
const promises = [];
for (const outputEntry of outputEntries){
const outputFilePath = outputEntry.file ?? outputEntry.dir;
if (!outputFilePath) {
throw new Error("Misconfigured file entry point. Make sure to define an `import`, `require`, or `default` field.");
}
promises.push(new Promise((resolve, reject)=>{
bundle.write(outputEntry).then(()=>{
resolve({
elapsedTime: Date.now() - initialTime,
filePath: outputFilePath
});
}).catch((error)=>{
reject(error);
});
}));
}
output.push(...await Promise.all(promises));
}
}
return output;
};
const TEMPORARY_PATH = resolveFromInternalDirectory("dist", "tmp");
const TEMPORARY_DOWNLOAD_PATH = join(TEMPORARY_PATH, "zip");
const TEMPORARY_RUNTIME_PATH = join(TEMPORARY_PATH, "runtime");
const createCompileCommand = (program)=>{
return program.command({
name: "compile",
description: "Compiles the source code into a self-contained executable"
}).option({
key: "targetInput",
name: {
long: "target",
short: "t"
},
description: "Set a different cross-compilation target",
defaultValue: "local"
}).task({
key: "config",
label: "Create configuration",
handler () {
return createConfiguration({
minification: true,
sourceMaps: false,
standalone: true
});
}
}).task({
key: "osType",
label ({ targetInput }) {
return `Get \`${targetInput}\` runtime`;
},
async handler ({ targetInput }) {
if (targetInput === "local") {
await copyFile(process.execPath, TEMPORARY_RUNTIME_PATH);
return getOsType(os.type());
}
const matchedRuntimeParts = matchRuntimeParts(targetInput);
if (!matchedRuntimeParts) {
throw new Error("Invalid `runtime` flag input. The accepted targets are the one listed in https://nodejs.org/download/release/ with the following format `node-vx.y.z-(darwin|linux|win)-(arm64|x64|x86)`.");
}
const osType = getOsType(matchedRuntimeParts.os);
const extension = osType === "windows" ? "zip" : "tar.gz";
await download(`https://nodejs.org/download/release/${matchedRuntimeParts.version}/${targetInput}.${extension}`, TEMPORARY_DOWNLOAD_PATH);
await unzip({
path: TEMPORARY_DOWNLOAD_PATH,
targetedArchivePath: osType === "windows" ? join(targetInput, "node.exe") : join(targetInput, "bin", "node")
}, {
directoryPath: dirname(TEMPORARY_RUNTIME_PATH),
filename: basename(TEMPORARY_RUNTIME_PATH)
});
return osType;
}
}).task({
label: "Build",
async handler ({ config }) {
await build(config);
}
}).task({
label ({ config }) {
const binaries = config.metadata.map(({ bin })=>{
if (!bin) return undefined;
return `\`${bin}\``;
}).filter(Boolean).join(", ");
return `Compile ${binaries}`;
},
async handler ({ config, osType }) {
await Promise.all(config.metadata.map(async ({ bin, require })=>{
if (!require || !bin) return;
return compile({
bin,
input: require,
osType
});
}));
}
});
};
const getOsType = (input)=>{
switch(input){
case "Windows_NT":
case "win":
{
return "windows";
}
case "Darwin":
case "darwin":
{
return "macos";
}
case "Linux":
case "linux":
{
return "linux";
}
default:
{
throw new Error(`Unsupported operating system \`${input}\``);
}
}
};
const matchRuntimeParts = createRegExpMatcher(/^node-(?<version>v\d+\.\d+\.\d+)-(?<os>darwin|linux|win)-(?<architecture>arm64|x64|x86)$/);
const compile = async ({ bin, input, osType })=>{
const inputFileName = basename(input);
const inputDirectory = dirname(input);
const resolveFromInputDirectory = (...paths)=>{
return resolve(inputDirectory, ...paths);
};
const blobFileName = resolveFromInputDirectory(`${inputFileName}.blob`);
const executableFileName = resolveFromInputDirectory(`${bin}${osType === "windows" ? ".exe" : ""}`);
const seaConfigFileName = resolveFromInputDirectory(`${inputFileName}.sea-config.json`);
await writeFile(seaConfigFileName, JSON.stringify({
disableExperimentalSEAWarning: true,
main: input,
output: blobFileName,
useCodeCache: false,
useSnapshot: false
}));
await Promise.all([
helpers.exec(`node --experimental-sea-config ${seaConfigFileName}`),
copyFile(TEMPORARY_RUNTIME_PATH, executableFileName)
]);
if (osType === "macos") {
await helpers.exec(`codesign --remove-signature ${executableFileName}`);
}
await helpers.exec(`npx postject ${executableFileName} NODE_SEA_BLOB ${blobFileName} --sentinel-fuse NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2 ${osType === "macos" ? "--macho-segment-name NODE_SEA" : ""}`);
if (osType === "macos") {
await helpers.exec(`codesign --sign - ${executableFileName}`);
}
await Promise.all([
blobFileName,
seaConfigFileName,
TEMPORARY_PATH
].map(async (path)=>removePath(path)));
};
const createBuildCommand = (program)=>{
return createCommand(program, {
name: "build",
description: "Build the source code (production mode)"
}).task({
key: "buildOutput",
label: "Bundle assets 📦",
async handler (context) {
return build(createConfiguration({
minification: context.minification,
sourceMaps: context.sourceMaps,
standalone: false
}));
}
}).task({
key: "logInput",
label: "Generate report 📝",
async handler (context) {
return computeBundleSize(context.buildOutput);
},
skip (context) {
return context.buildOutput.length === 0;
}
}).task({
handler (context) {
context.logInput.forEach((item)=>{
helpers.message([
`${formatSize(item.rawSize)} raw`,
`${formatSize(item.compressedSize)} gzip`
].map((message, index)=>{
return index === 0 ? message : ` ${message}`;
}).join("\n"), {
label: `${item.filePath} (took ${item.elapsedTime}ms)`,
lineBreak: {
end: false,
start: true
},
type: "information"
});
});
},
skip (context) {
return context.buildOutput.length === 0;
}
});
};
const computeBundleSize = async (buildOutput)=>{
const computeFileSize = async (buildItemOutput)=>{
const content = await readFile(buildItemOutput.filePath);
const gzSize = await gzipSize(content);
return {
...buildItemOutput,
compressedSize: gzSize,
rawSize: content.byteLength
};
};
return Promise.all(buildOutput.map(async (item)=>computeFileSize(item)));
};
const formatSize = (bytes)=>{
const kiloBytes = bytes / 1000;
return kiloBytes < 1 ? `${bytes} B` : `${kiloBytes.toFixed(2)} kB`;
};
var name = "quickbundle";
var version = "2.13.0";
const createProgram = (...commandBuilders)=>{
const program = termost({
name,
description: "The zero-configuration transpiler and bundler for the web",
version
});
for (const commandBuilder of commandBuilders){
commandBuilder(program);
}
};
createProgram(createBuildCommand, createWatchCommand, createCompileCommand);