UNPKG

quickbundle

Version:

The zero-configuration transpiler and bundler for the web

605 lines (594 loc) 22.1 kB
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);