UNPKG

quickbundle

Version:

The zero-configuration transpiler and bundler for the web

507 lines (496 loc) 17.3 kB
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