obuild
Version:
Zero-config ESM/TS package builder
374 lines (368 loc) • 14.1 kB
JavaScript
import { fileURLToPath, pathToFileURL } from "node:url";
import { basename, dirname, extname, isAbsolute, join, relative, resolve } from "node:path";
import { mkdir, readFile, rm, symlink, writeFile } from "node:fs/promises";
import { consola } from "consola";
import { colors } from "consola/utils";
import { builtinModules } from "node:module";
import { rolldown } from "rolldown";
import { dts } from "rolldown-plugin-dts";
import oxcParser from "oxc-parser";
import { resolveModulePath } from "exsolve";
import prettyBytes from "pretty-bytes";
import { promises, readdirSync, statSync } from "node:fs";
import { minify } from "oxc-minify";
import { gzipSync } from "node:zlib";
import { defu } from "defu";
import MagicString from "magic-string";
import oxcTransform from "oxc-transform";
import { glob } from "tinyglobby";
//#region src/utils.ts
function fmtPath(path) {
return resolve(path).replace(process.cwd(), ".");
}
function analyzeDir(dir) {
if (Array.isArray(dir)) {
let totalSize$1 = 0;
let totalFiles = 0;
for (const d of dir) {
const { size, files: files$1 } = analyzeDir(d);
totalSize$1 += size;
totalFiles += files$1;
}
return {
size: totalSize$1,
files: totalFiles
};
}
let totalSize = 0;
const files = readdirSync(dir, {
withFileTypes: true,
recursive: true
});
for (const file of files) {
const fullPath = join(file.parentPath, file.name);
if (file.isFile()) {
const { size } = statSync(fullPath);
totalSize += size;
}
}
return {
size: totalSize,
files: files.length
};
}
async function distSize(dir, entry) {
const build$1 = await rolldown({
input: join(dir, entry),
plugins: [],
platform: "neutral",
external: (id) => id[0] !== "." && !id.startsWith(dir)
});
const { output } = await build$1.generate({ inlineDynamicImports: true });
const code = output[0].code;
const { code: minified } = await minify(entry, code);
return {
size: Buffer.byteLength(code),
minSize: Buffer.byteLength(minified),
minGzipSize: gzipSync(minified).length
};
}
async function sideEffectSize(dir, entry) {
const virtualEntry = {
name: "virtual-entry",
async resolveId(id, importer, opts) {
if (id === "#entry") return { id };
const resolved = await this.resolve(id, importer, opts);
if (!resolved) return null;
resolved.moduleSideEffects = null;
return resolved;
},
load(id) {
if (id === "#entry") return `import * as _lib from "${join(dir, entry)}";`;
}
};
const build$1 = await rolldown({
input: "#entry",
platform: "neutral",
external: (id) => id[0] !== "." && !id.startsWith(dir),
plugins: [virtualEntry]
});
const { output } = await build$1.generate({ inlineDynamicImports: true });
if (process.env.INSPECT_BUILD) {
console.log("---------[side effects]---------");
console.log(entry);
console.log(output[0].code);
console.log("-------------------------------");
}
return Buffer.byteLength(output[0].code.trim());
}
//#endregion
//#region src/builders/plugins/shebang.ts
const SHEBANG_RE = /^#![^\n]*/;
function shebangPlugin() {
return {
name: "obuild-shebang",
async writeBundle(options, bundle) {
for (const [fileName, output] of Object.entries(bundle)) {
if (output.type !== "chunk") continue;
if (hasShebang(output.code)) {
const outFile = resolve(options.dir, fileName);
await makeExecutable(outFile);
}
}
}
};
}
function hasShebang(code) {
return SHEBANG_RE.test(code);
}
async function makeExecutable(filePath) {
await promises.chmod(filePath, 493).catch(() => {});
}
//#endregion
//#region src/builders/bundle.ts
async function rolldownBuild(ctx, entry, hooks) {
const inputs = normalizeBundleInputs(entry.input, ctx);
if (entry.stub) {
for (const [distName, srcPath] of Object.entries(inputs)) {
const distPath = join(ctx.pkgDir, "dist", `${distName}.mjs`);
await mkdir(dirname(distPath), { recursive: true });
consola.log(`${colors.magenta("[stub bundle] ")} ${colors.underline(fmtPath(distPath))}`);
const srcContents = await readFile(srcPath, "utf8");
const parsed = await oxcParser.parseSync(srcPath, srcContents);
const exportNames = parsed.module.staticExports.flatMap((e) => e.entries.map((e$1) => e$1.exportName.kind === "Default" ? "default" : e$1.exportName.name));
const hasDefaultExport = exportNames.includes("default");
const firstLine = srcContents.split("\n")[0];
const hasShebangLine = firstLine.startsWith("#!");
await writeFile(distPath, `${hasShebangLine ? firstLine + "\n" : ""}export * from "${srcPath}";\n${hasDefaultExport ? `export { default } from "${srcPath}";\n` : ""}`, "utf8");
if (hasShebangLine) await makeExecutable(distPath);
await writeFile(distPath.replace(/\.mjs$/, ".d.mts"), `export * from "${srcPath}";\n${hasDefaultExport ? `export { default } from "${srcPath}";\n` : ""}`, "utf8");
}
return;
}
const rolldownConfig = defu(entry.rolldown, {
cwd: ctx.pkgDir,
input: inputs,
plugins: [shebangPlugin()],
platform: "neutral",
external: [
...builtinModules,
...builtinModules.map((m) => `node:${m}`),
...[...Object.keys(ctx.pkg.dependencies || {}), ...Object.keys(ctx.pkg.peerDependencies || {})].flatMap((p) => [p, new RegExp(`^${p}/`)])
]
});
if (entry.dts !== false) rolldownConfig.plugins.push(...dts({ ...entry.dts }));
await hooks.rolldownConfig?.(rolldownConfig, ctx);
const res = await rolldown(rolldownConfig);
const outDir = resolve(ctx.pkgDir, entry.outDir || "dist");
const outConfig = {
dir: outDir,
entryFileNames: "[name].mjs",
chunkFileNames: "_chunks/[name]-[hash].mjs",
minify: entry.minify
};
await hooks.rolldownOutput?.(outConfig, res, ctx);
const { output } = await res.write(outConfig);
await res.close();
const outputEntries = [];
const depsCache = new Map();
const resolveDeps = (chunk) => {
if (!depsCache.has(chunk)) depsCache.set(chunk, new Set());
const deps = depsCache.get(chunk);
for (const id of chunk.imports) {
if (builtinModules.includes(id) || id.startsWith("node:")) {
deps.add(`[Node.js]`);
continue;
}
const depChunk = output.find((o) => o.type === "chunk" && o.fileName === id);
if (depChunk) {
for (const dep of resolveDeps(depChunk)) deps.add(dep);
continue;
}
deps.add(id);
}
return [...deps].sort();
};
for (const chunk of output) {
if (chunk.type !== "chunk" || !chunk.isEntry) continue;
if (chunk.fileName.endsWith("ts")) continue;
outputEntries.push({
name: chunk.fileName,
exports: chunk.exports,
deps: resolveDeps(chunk),
...await distSize(outDir, chunk.fileName),
sideEffectSize: await sideEffectSize(outDir, chunk.fileName)
});
}
consola.log(`\n${outputEntries.map((o) => [
colors.magenta(`[bundle] `) + `${colors.underline(fmtPath(join(outDir, o.name)))}`,
colors.dim(`${colors.bold("Size:")} ${prettyBytes(o.size)}, ${colors.bold(prettyBytes(o.minSize))} minified, ${prettyBytes(o.minGzipSize)} min+gzipped (Side effects: ${prettyBytes(o.sideEffectSize)})`),
o.exports.some((e) => e !== "default") ? colors.dim(`${colors.bold("Exports:")} ${o.exports.map((e) => e).join(", ")}`) : "",
o.deps.length > 0 ? colors.dim(`${colors.bold("Dependencies:")} ${o.deps.join(", ")}`) : ""
].filter(Boolean).join("\n")).join("\n\n")}`);
}
function normalizeBundleInputs(input, ctx) {
const inputs = {};
for (let src of Array.isArray(input) ? input : [input]) {
src = resolveModulePath(src, {
from: ctx.pkgDir,
extensions: [
".ts",
".mjs",
".js"
]
});
let relativeSrc = relative(join(ctx.pkgDir, "src"), src);
if (relativeSrc.startsWith("..")) relativeSrc = relative(join(ctx.pkgDir), src);
if (relativeSrc.startsWith("..")) throw new Error(`Source should be within the package directory (${ctx.pkgDir}): ${src}`);
const distName = join(dirname(relativeSrc), basename(relativeSrc, extname(relativeSrc)));
if (inputs[distName]) throw new Error(`Rename one of the entries to avoid a conflict in the dist name "${distName}":\n - ${src}\n - ${inputs[distName]}`);
inputs[distName] = src;
}
return inputs;
}
//#endregion
//#region src/builders/transform.ts
/**
* Transform all .ts modules in a directory using oxc-transform.
*/
async function transformDir(ctx, entry) {
if (entry.stub) {
consola.log(`${colors.magenta("[stub transform] ")} ${colors.underline(fmtPath(entry.outDir) + "/")}`);
await symlink(entry.input, entry.outDir, "junction");
return;
}
const promises$1 = [];
for await (const entryName of await glob("**/*.*", { cwd: entry.input })) promises$1.push((async () => {
const entryPath = join(entry.input, entryName);
const ext = extname(entryPath);
switch (ext) {
case ".ts": {
const transformed = await transformModule(entryPath, entry);
const entryDistPath = join(entry.outDir, entryName.replace(/\.ts$/, ".mjs"));
await mkdir(dirname(entryDistPath), { recursive: true });
await writeFile(entryDistPath, transformed.code, "utf8");
if (SHEBANG_RE.test(transformed.code)) await makeExecutable(entryDistPath);
if (transformed.declaration) await writeFile(entryDistPath.replace(/\.mjs$/, ".d.mts"), transformed.declaration, "utf8");
return entryDistPath;
}
default: {
const entryDistPath = join(entry.outDir, entryName);
await mkdir(dirname(entryDistPath), { recursive: true });
const code = await readFile(entryPath, "utf8");
await writeFile(entryDistPath, code, "utf8");
if (SHEBANG_RE.test(code)) await makeExecutable(entryDistPath);
return entryDistPath;
}
}
})());
const writtenFiles = await Promise.all(promises$1);
consola.log(`\n${colors.magenta("[transform] ")}${colors.underline(fmtPath(entry.outDir) + "/")}\n${writtenFiles.map((f) => colors.dim(fmtPath(f))).join("\n\n")}`);
}
/**
* Transform a .ts module using oxc-transform.
*/
async function transformModule(entryPath, entry) {
let sourceText = await readFile(entryPath, "utf8");
const sourceOptions = {
lang: "ts",
sourceType: "module"
};
const parsed = oxcParser.parseSync(entryPath, sourceText, { ...sourceOptions });
if (parsed.errors.length > 0) throw new Error(`Errors while parsing ${entryPath}:`, { cause: parsed.errors });
const magicString = new MagicString(sourceText);
const updatedStarts = new Set();
const rewriteSpecifier = (req) => {
const moduleId = req.value;
if (!moduleId.startsWith(".")) return;
if (updatedStarts.has(req.start)) return;
updatedStarts.add(req.start);
const resolvedAbsolute = resolveModulePath(moduleId, { from: pathToFileURL(entryPath) });
const newId = relative(dirname(entryPath), resolvedAbsolute.replace(/\.ts$/, ".mjs"));
magicString.remove(req.start, req.end);
magicString.prependLeft(req.start, JSON.stringify(newId.startsWith(".") ? newId : `./${newId}`));
};
for (const staticImport of parsed.module.staticImports) rewriteSpecifier(staticImport.moduleRequest);
for (const staticExport of parsed.module.staticExports) for (const staticExportEntry of staticExport.entries) if (staticExportEntry.moduleRequest) rewriteSpecifier(staticExportEntry.moduleRequest);
sourceText = magicString.toString();
const transformed = oxcTransform.transform(entryPath, sourceText, {
...entry.oxc,
...sourceOptions,
cwd: dirname(entryPath),
typescript: {
declaration: { stripInternal: true },
...entry.oxc?.typescript
}
});
const transformErrors = transformed.errors.filter((err) => !err.message.includes("--isolatedDeclarations"));
if (transformErrors.length > 0) {
await writeFile("build-dump.ts", `/** Error dump for ${entryPath} */\n\n` + sourceText, "utf8");
throw new Error(`Errors while transforming ${entryPath}: (hint: check build-dump.ts)`, { cause: transformErrors });
}
if (entry.minify) {
const res = minify(entryPath, transformed.code, entry.minify === true ? {} : entry.minify);
transformed.code = res.code;
transformed.map = res.map;
}
return transformed;
}
//#endregion
//#region src/build.ts
/**
* Build dist/ from src/
*/
async function build(config) {
const start = Date.now();
const pkgDir = normalizePath(config.cwd);
const pkg = await readJSON(join(pkgDir, "package.json")).catch(() => ({}));
const ctx = {
pkg,
pkgDir
};
consola.log(`📦 Building \`${ctx.pkg.name || "<no name>"}\` (\`${ctx.pkgDir}\`)`);
const hooks = config.hooks || {};
await hooks.start?.(ctx);
const entries = (config.entries || []).map((rawEntry) => {
let entry;
if (typeof rawEntry === "string") {
const [input, outDir] = rawEntry.split(":");
entry = input.endsWith("/") ? {
type: "transform",
input,
outDir
} : {
type: "bundle",
input: input.split(","),
outDir
};
} else entry = rawEntry;
if (!entry.input) throw new Error(`Build entry missing \`input\`: ${JSON.stringify(entry, null, 2)}`);
entry = { ...entry };
entry.outDir = normalizePath(entry.outDir || "dist", pkgDir);
entry.input = Array.isArray(entry.input) ? entry.input.map((p) => normalizePath(p, pkgDir)) : normalizePath(entry.input, pkgDir);
return entry;
});
await hooks.entries?.(entries, ctx);
const outDirs = [];
for (const outDir of entries.map((e) => e.outDir).sort()) if (!outDirs.some((dir) => outDir.startsWith(dir))) outDirs.push(outDir);
for (const outDir of outDirs) {
consola.log(`🧻 Cleaning up \`${fmtPath(outDir)}\``);
await rm(outDir, {
recursive: true,
force: true
});
}
for (const entry of entries) await (entry.type === "bundle" ? rolldownBuild(ctx, entry, hooks) : transformDir(ctx, entry));
await hooks.end?.(ctx);
const dirSize = analyzeDir(outDirs);
consola.log(colors.dim(`\nΣ Total dist byte size: ${colors.underline(prettyBytes(dirSize.size))} (${colors.underline(dirSize.files)} files)`));
consola.log(`\n✅ obuild finished in ${Date.now() - start}ms`);
}
function normalizePath(path, resolveFrom) {
return typeof path === "string" && isAbsolute(path) ? path : path instanceof URL ? fileURLToPath(path) : resolve(resolveFrom || ".", path || ".");
}
function readJSON(specifier) {
return import(specifier, { with: { type: "json" } }).then((r) => r.default);
}
//#endregion
export { build };