UNPKG

obuild

Version:

Zero-config ESM/TS package builder

374 lines (368 loc) 14.1 kB
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 };