import fs from "node:fs/promises"; import { beginTtscTransformBuild, createTtscTransformCache, isTransformTarget, resetTtscTransformCache, resolveOptions, transformTtsc, } from "./core/index"; import type { TtscUnpluginOptions } from "./core/options"; import { bunTypeScriptTransformSourcePattern, typescriptTransformBunLoader, } from "./core/sourceExtensions"; /** * Minimal subset of the Bun plugin API consumed by this adapter. * * Bun does not yet ship TypeScript types for its bundler plugin interface, so * we define the subset we need here. This keeps the adapter free of a Bun * runtime dependency while remaining type-safe. */ export interface BunLikePlugin { /** Plugin identifier shown in Bun bundler output. */ name: string; /** Called by Bun when the plugin is registered. */ setup(build: BunLikeBuild): void | Promise; } /** Bun loader identifiers this adapter can emit (only TypeScript is matched). */ export type BunLoader = "ts" | "tsx"; /** * Options accepted by {@link bun}, either resolved eagerly or supplied through a * provider evaluated lazily on the first `onLoad` call. * * The provider form exists for the runtime registration path (`bun-register`), * where a single Bun plugin is registered on import but its effective options * may be overridden by explicit `register(options)` calls made before the first * transformable TypeScript load. Resolving through the provider on that first * load, rather than at registration, lets the last pending call win without Bun * ever seeing a second shadowing loader. */ export type TtscBunOptions = | TtscUnpluginOptions | (() => TtscUnpluginOptions | undefined); /** Resolve {@link TtscBunOptions} to a plain options object (or `undefined`). */ function resolveBunOptions( options?: TtscBunOptions, ): TtscUnpluginOptions | undefined { return typeof options === "function" ? options() : options; } /** * Minimal subset of the Bun `BuildConfig` plugin build object. * * `onLoad` drives the source transform. Bun's bundler also exposes `onStart` * and `onEnd`, which bracket the shared plugin's build lifecycle. The runtime * plugin API omits those hooks, so plugin setup itself starts its one * process-scoped module-loading session. */ export interface BunLikeBuild { /** * Build configuration exposed unchanged by Bun's bundler plugin builder. * * Runtime plugin builders do not supply `files`. Bun's bundler accepts an * in-memory file map whose values deliberately remain `unknown` here because * this adapter only needs to preserve ownership, not consume their contents. */ config?: { files?: Readonly>; }; /** * Register a callback for the start of a bundler build. * * Optional because `Bun.plugin()` runtime builders do not expose this hook. */ onStart?(callback: () => void | Promise): void; /** Register a callback for deterministic bundler-session teardown. */ onEnd?(callback: () => void | Promise): void; /** * Register a loader callback for files matching `filter`. * * The callback receives the file path and must return the transformed file * contents plus the `loader` Bun should apply next. Configured in-memory * files retain relative key spellings; ordinary disk files are normally * absolute. The `loader` matters most for the runtime path (`Bun.plugin`), * where Bun must be told the returned contents are still TypeScript so it * keeps transpiling them before execution. */ onLoad( options: { filter: RegExp }, loader: (args: { path: string; }) => Promise<{ contents: string; loader: BunLoader } | undefined>, ): void; } /** * Create a ttsc plugin for Bun's bundler AND runtime. * * Bun does not implement the unplugin protocol, so this adapter wires the * shared ttsc transform core to Bun's `onLoad` hook directly. It reads each * included file from disk and forwards the content to the transform. Under * `Bun.build`, excluded files and no-op transforms return `undefined` so the * next loader retains ownership. Entries supplied through `BuildConfig.files` * also stay with Bun's in-memory loader: they are not filesystem project inputs * and reading the same path from disk would either fail or silently replace the * configured contents. The runtime `Bun.plugin()` API rejects an undefined * `onLoad` result, so that path explicitly returns the original source and * loader instead. * * The same object works for `Bun.build({ plugins: [ttsc()] })` (bundler) and * for `Bun.plugin(ttsc())` / a `bunfig.toml` preload (runtime) — see * `bun-register`. Every result carries an explicit `loader` so Bun keeps * transpiling the emitted TypeScript at runtime; `bunSourceFilePattern` only * matches TypeScript, so the loader is always `ts`/`tsx`. A runtime plugin * instance is one immutable load session, like Bun's own module cache; restart * the process after changing compiler inputs. */ export default function bun(options?: TtscBunOptions): BunLikePlugin { return { name: "ttsc-unplugin", setup(build) { // Resolve options lazily on the first transformable TypeScript load. // Runtime registration may replace its pending configuration at any time // before then; the provider form must observe that last update without // installing a second shadowing loader. let resolved: ReturnType | undefined; const getOptions = () => (resolved ??= resolveOptions(resolveBunOptions(options))); const cache = createTtscTransformCache(); const runtime = build.onStart === undefined; const ownsInMemoryFile = createBunInMemoryFileMatcher(build); // Bun.plugin() has no onStart callback, but one setup invocation belongs // to exactly one runtime process and module-loading session. Mark that // session up front so first delivery of every emitted project module is // constant-time instead of re-reading the whole project. Bun.build() // immediately starts the same initial scope again through onStart and // repeats it for subsequent builds. beginTtscTransformBuild(cache); build.onStart?.(() => beginTtscTransformBuild(cache)); if (!runtime) { build.onEnd?.(() => resetTtscTransformCache(cache)); } build.onLoad( { filter: bunTypeScriptTransformSourcePattern }, async (args) => { if (!runtime && ownsInMemoryFile(args.path)) { return undefined; } if (!isTransformTarget(args.path)) { if (!runtime) return undefined; return { contents: await fs.readFile(args.path, "utf8"), loader: bunLoaderFor(args.path), }; } const loader = bunLoaderFor(args.path); const transformOptions = getOptions(); const source = await fs.readFile(args.path, "utf8"); // Bun has no dependency subscription API. Omitting watch hooks also // avoids deriving a filesystem watch graph that this host cannot use. const result = await transformTtsc( args.path, source, transformOptions, undefined, cache, ); if (result !== undefined) { return { contents: result.code, loader }; } return runtime ? { contents: source, loader } : undefined; }, ); }, }; } /** * Pick the Bun loader recorded beside the matched extension in the shared * source table. Reaching this function without a table entry would mean Bun * invoked a callback whose registration filter did not match. */ function bunLoaderFor(filePath: string): BunLoader { const loader = typescriptTransformBunLoader(filePath); if (loader === undefined) { throw new Error(`Bun delivered an unsupported source path: ${filePath}`); } return loader; } /** * Create a stable ownership matcher for Bun's `BuildConfig.files` map. * * Bun preserves relative `files` keys in the corresponding `onLoad` path. * Preserve relative versus absolute spelling and dot segments exactly. Windows * normalizes separators and drive-letter case, but not component case. No path * is resolved against cwd, so `process.chdir()` cannot change ownership. */ function createBunInMemoryFileMatcher( build: BunLikeBuild, ): (file: string) => boolean { const files = build.config?.files; if (files === undefined) return () => false; const identities = new Set(Object.keys(files).map(bunPathIdentityKey)); return (file) => identities.has(bunPathIdentityKey(file)); } /** * Normalize the path forms Bun equates for its in-memory file map. * * Bun normalizes Windows separators and drive-letter case, but preserves path * component case, relative versus absolute spelling, and dot segments. A * filesystem identity key is broader and would suppress real disk transforms. */ function bunPathIdentityKey(file: string): string { if (process.platform !== "win32") return file; return file .replace(/\\/g, "/") .replace( /^([a-z]):/i, (_match, drive: string) => `${drive.toLowerCase()}:`, ); }