quickbundle
Version:
The zero-configuration transpiler and bundler for the web
507 lines (496 loc) • 17.3 kB
JavaScript
import { helpers, termost } from "termost";
import { gzipSize } from "gzip-size";
import { basename, dirname, join, resolve } from "node:path";
import { rolldown, watch } from "rolldown";
import url from "@rollup/plugin-url";
import { createRequire } from "node:module";
import { dts } from "rolldown-plugin-dts";
import externals from "rollup-plugin-node-externals";
import decompress from "decompress";
import { createWriteStream } from "node:fs";
import { copyFile, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
import { Readable } from "node:stream";
import { finished } from "node:stream/promises";
import os from "node:os";
//#region package.json
var name = "quickbundle";
var version = "3.0.0";
//#endregion
//#region src/bundler/build.ts
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 rolldown(config);
if (config.output) {
const outputEntries = Array.isArray(config.output) ? config.output : [config.output];
const promises = [];
for (const outputEntry of outputEntries) promises.push(new Promise((resolve, reject) => {
bundle.write(outputEntry).then(({ output: rolldownOutput }) => {
resolve({
elapsedTime: Date.now() - initialTime,
filePath: join(outputEntry.dir ?? "", rolldownOutput.find((item) => item.type === "chunk" && item.isEntry)?.fileName ?? "")
});
}).catch((error) => {
if (error instanceof Error) reject(error);
});
}));
output.push(...await Promise.all(promises));
}
}
return output;
};
//#endregion
//#region src/helpers.ts
/**
* 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$1 = async (fromPath, toPath) => {
await createDirectory(dirname(toPath));
await copyFile(fromPath, toPath);
};
const removePath = async (path) => {
await rm(path, {
force: true,
recursive: true
});
};
const readFile$1 = async (filePath) => {
return readFile(filePath);
};
const writeFile$1 = async (filePath, content) => {
await createDirectory(dirname(filePath));
await writeFile(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({
defaultValue: false,
description: "Enable minification",
key: "minification",
name: "minification"
}).option({
defaultValue: false,
description: "Enable source maps generation",
key: "sourceMaps",
name: "source-maps"
});
};
//#endregion
//#region src/bundler/helpers.ts
const isRecord = (value) => {
return typeof value === "object" && value !== null && !Array.isArray(value);
};
//#endregion
//#region src/bundler/config.ts
const PKG = createRequire(import.meta.url)(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
};
};
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 [{
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 = void 0;
const output = Object.entries(PKG.exports).map(([field, value]) => {
if (isRecord(value)) return [field, value];
if (["source", ...buildableExportFields].includes(field)) {
if (!singleExport) {
singleExport = { [field]: value };
return [".", singleExport];
}
singleExport[field] = value;
}
}).reduce((buildableExports, currentExport) => {
if (!currentExport) return buildableExports;
const [exportField, exportValue] = currentExport;
const conditionalExportFields = Object.keys(exportValue);
if (!conditionalExportFields.includes("source")) return buildableExports;
if (buildableExportFields.some((entryPointField) => conditionalExportFields.includes(entryPointField))) {
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 getFileOutput = (filePath) => {
return {
dir: dirname(filePath),
entryFileNames: basename(filePath)
};
};
const getPlugins = (options) => {
const output = [url()];
if (!options.standalone) output.push(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
}));
return output;
};
const createMainConfig = (entryPoints, options) => {
const { minification, sourceMaps } = options;
const cjsInput = entryPoints.require;
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 commonOutputConfig = {
minify: minification,
sourcemap: sourceMaps
};
const output = [cjsInput && {
...commonOutputConfig,
...getFileOutput(cjsInput),
codeSplitting: Boolean(options.standalone),
format: "cjs"
}, esmInput && {
...commonOutputConfig,
...getFileOutput(esmInput),
format: "es"
}].filter(Boolean);
return {
input: entryPoints.source,
output,
plugins: getPlugins(options)
};
};
const createTypesConfig = (entryPoints, options) => {
const { dir, entryFileNames } = getFileOutput(entryPoints.types);
return {
input: entryPoints.source,
output: {
dir,
entryFileNames({ name }) {
return name.endsWith(".d") ? entryFileNames : "";
}
},
plugins: [...getPlugins(options), dts({ emitDtsOnly: true })]
};
};
//#endregion
//#region src/commands/build.ts
const createBuildCommand = (program) => {
return createCommand(program, {
description: "Build the source code (production mode)",
name: "build"
}).task({
async handler(context) {
return build(createConfiguration({
minification: context.minification,
sourceMaps: context.sourceMaps,
standalone: false
}));
},
key: "buildOutput",
label: "Bundle assets 📦"
}).task({
async handler(context) {
return computeBundleSize(context.buildOutput);
},
key: "logInput",
label: "Generate report 📝",
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$1(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 / 1e3;
return kiloBytes < 1 ? `${bytes} B` : `${kiloBytes.toFixed(2)} kB`;
};
//#endregion
//#region src/commands/compile.ts
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({
description: "Compiles the source code into a self-contained executable",
name: "compile"
}).option({
defaultValue: "local",
description: "Set a different cross-compilation target",
key: "targetInput",
name: {
long: "target",
short: "t"
}
}).task({
handler() {
return createConfiguration({
minification: true,
sourceMaps: false,
standalone: true
});
},
key: "config",
label: "Create configuration"
}).task({
async handler({ targetInput }) {
if (targetInput === "local") {
await copyFile$1(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;
},
key: "osType",
label({ targetInput }) {
return `Get \`${targetInput}\` runtime`;
}
}).task({
async handler({ config }) {
await build(config);
},
label: "Build"
}).task({
async handler({ config, osType }) {
await Promise.all(config.metadata.map(async ({ bin, require }) => {
if (!require || !bin) return;
return compile({
bin,
input: require,
osType
});
}));
},
label({ config }) {
return `Compile ${config.metadata.map(({ bin }) => {
if (!bin) return void 0;
return `\`${bin}\``;
}).filter(Boolean).join(", ")}`;
}
});
};
const getOsType = (input) => {
switch (input) {
case "Darwin":
case "darwin": return "macos";
case "Linux":
case "linux": return "linux";
case "win":
case "Windows_NT": return "windows";
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$1(seaConfigFileName, JSON.stringify({
disableExperimentalSEAWarning: true,
main: input,
output: blobFileName,
useCodeCache: false,
useSnapshot: false
}));
await Promise.all([helpers.exec(`node --experimental-sea-config ${seaConfigFileName}`), copyFile$1(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)));
};
//#endregion
//#region src/bundler/watch.ts
const watch$1 = (input) => {
process.env.NODE_ENV ??= "development";
const watcher = watch(input.data);
let startDuration;
console.clear();
watcher.on("event", async (event) => {
switch (event.code) {
case "BUNDLE_END":
await event.result.close();
break;
case "END":
clearLog(`Build done in ${Date.now() - startDuration}ms (at ${(/* @__PURE__ */ new Date()).toLocaleTimeString()})`, { type: "success" });
return;
case "ERROR": {
const { error } = event;
clearLog(error.message, { type: "error" });
console.error("\n", error);
return;
}
case "START":
startDuration = Date.now();
clearLog("Build in progress…", { type: "information" });
return;
default: break;
}
});
};
const clearLog = (...input) => {
console.clear();
helpers.message(...input);
};
//#endregion
//#region src/commands/watch.ts
const createWatchCommand = (program) => {
return createCommand(program, {
description: "Watch and rebuild on any code change (development mode)",
name: "watch"
}).task({ handler(context) {
watch$1(createConfiguration({
minification: context.minification,
sourceMaps: context.sourceMaps,
standalone: false
}));
} });
};
//#endregion
//#region src/index.ts
const createProgram = (...commandBuilders) => {
const program = termost({
description: "The zero-configuration transpiler and bundler for the web",
name,
version
});
for (const commandBuilder of commandBuilders) commandBuilder(program);
};
createProgram(createBuildCommand, createWatchCommand, createCompileCommand);
//#endregion