import { access, mkdir, mkdtemp, readdir, readFile, rm, } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import type { BuildSettings } from "./build-settings.ts"; import type { BuildArtifact, BuildStrategy } from "./build-strategy.ts"; import { defaultHttpPortForBuildType } from "./config/frameworks.ts"; import { type BuildCommandIo, buildCommandEnv, runBuildCommand, runChildProcess, } from "./workspace.ts"; /** * Build strategy that runs `bun build` CLI and manages its own temp output directory. * Owns entrypoint resolution from explicit arg or package.json main field. * * When build settings carry a build command (a `package.json` build script), * it runs first — so a Bun app can compile assets or generate code before the * entrypoint is bundled. */ export class BunBuild implements BuildStrategy { readonly #appPath: string; readonly #entrypoint?: string; readonly #buildSettings?: BuildSettings; readonly #io?: BuildCommandIo; constructor(options: { appPath: string; entrypoint?: string; buildSettings?: BuildSettings; io?: BuildCommandIo; }) { this.#appPath = options.appPath; this.#entrypoint = options.entrypoint; this.#buildSettings = options.buildSettings; this.#io = options.io; } async canBuild(_signal?: AbortSignal): Promise { return true; } async execute(signal?: AbortSignal): Promise { signal?.throwIfAborted(); if (this.#buildSettings?.buildCommand) { await runBuildCommand({ appPath: this.#appPath, command: this.#buildSettings.buildCommand, failurePrefix: "Bun", env: this.#io?.env, onOutput: this.#io?.onOutput, signal, }); } const entrypoint = await this.#resolveEntrypoint(signal); const outDir = await mkdtemp(path.join(os.tmpdir(), "compute-build-")); const bundleDir = path.join(outDir, "bundle"); await mkdir(bundleDir, { recursive: true }); const absoluteEntrypoint = path.join(this.#appPath, entrypoint); try { await this.#runBuild(absoluteEntrypoint, bundleDir, signal); } catch (error) { await rm(outDir, { recursive: true, force: true }); throw error; } const outputFiles = await readdir(bundleDir); const jsOutputs = outputFiles .filter((f) => f.endsWith(".js")) .map((f) => path.join(bundleDir, f)); const runtimeEntrypoint = this.#selectRuntimeEntrypoint( jsOutputs, bundleDir, absoluteEntrypoint, ); return { directory: bundleDir, entrypoint: runtimeEntrypoint, defaultPortMapping: { http: defaultHttpPortForBuildType("bun") }, cleanup: () => rm(outDir, { recursive: true, force: true }), }; } async #resolveEntrypoint(signal?: AbortSignal): Promise { signal?.throwIfAborted(); const candidate = this.#entrypoint ?? (await this.#readPackageJsonMain()); if (!candidate) { throw new Error( "Entrypoint is required. Pass --entrypoint or define package.json main", ); } if (path.isAbsolute(candidate)) { throw new Error("Entrypoint must be a relative path"); } const normalized = path.normalize(candidate); if ( normalized.startsWith("..") || path.isAbsolute(normalized) || normalized.includes(`${path.sep}..${path.sep}`) ) { throw new Error("Entrypoint must not escape the app directory"); } const entrypointPath = path.join(this.#appPath, normalized); try { await access(entrypointPath); } catch { throw new Error(`Entrypoint file does not exist: ${entrypointPath}`); } return normalized.split(path.sep).join("/"); } async #readPackageJsonMain(): Promise { const packageJsonPath = path.join(this.#appPath, "package.json"); let content: string; try { content = await readFile(packageJsonPath, "utf-8"); } catch (error) { if ( error instanceof Error && "code" in error && error.code === "ENOENT" ) { return undefined; } throw new Error( `Failed to read ${packageJsonPath}: ${error instanceof Error ? error.message : String(error)}`, ); } let parsed: { main?: unknown }; try { parsed = JSON.parse(content) as { main?: unknown }; } catch (error) { throw new Error( `Failed to parse ${packageJsonPath}: ${error instanceof Error ? error.message : String(error)}`, ); } return typeof parsed.main === "string" ? parsed.main : undefined; } async #runBuild( absoluteEntrypoint: string, bundleDir: string, signal?: AbortSignal, ): Promise { const env = await buildCommandEnv( this.#appPath, { ...process.env, ...this.#io?.env }, signal, ); try { await runChildProcess({ command: "bun", args: [ "build", absoluteEntrypoint, "--outdir", bundleDir, "--target", "bun", "--sourcemap=external", ], cwd: this.#appPath, env, failurePrefix: "Bun", onOutput: this.#io?.onOutput, signal, }); } catch (error) { if ( error instanceof Error && "code" in error && (error as NodeJS.ErrnoException).code === "ENOENT" ) { throw new Error( "Bun is required to build the application but was not found. Install it from https://bun.sh", ); } throw error; } } #selectRuntimeEntrypoint( outputPaths: string[], bundleDir: string, absoluteEntrypoint: string, ): string { const jsOutputs = outputPaths.filter((output) => output.endsWith(".js")); if (jsOutputs.length === 0) { throw new Error("Bun build produced no JavaScript output"); } const expectedBasename = `${path.basename(absoluteEntrypoint, path.extname(absoluteEntrypoint))}.js`; const matchingByName = jsOutputs.find( (output) => path.basename(output) === expectedBasename, ); const selected = matchingByName ?? jsOutputs[0]; if (!selected) { throw new Error("Unable to determine built entrypoint"); } const relative = path.relative(bundleDir, selected); if (relative.startsWith("..") || path.isAbsolute(relative)) { throw new Error("Built entrypoint is outside the bundle directory"); } return relative.split(path.sep).join("/"); } }