import type { BunPlugin } from "bun"; import { createHash } from "node:crypto"; import { mkdirSync, rmSync, writeFileSync } from "node:fs"; import { createRequire } from "node:module"; import { basename, join } from "node:path"; export interface IslandInput { name: string; sourcePath: string; } export interface IslandBuildResult { assets: Array<{ name: string; hash: string; path: string }>; sharedChunks: string[]; } /** * Generates the per-island browser entry. * * Never imports react-dom/server — islands are client-only. */ export function generateIslandEntry(input: IslandInput): string { return [ "/** @jsxImportSource react */", `import Component from ${JSON.stringify(input.sourcePath)};`, `export const name = ${JSON.stringify(input.name)};`, `export default Component;`, "", ].join("\n"); } /** * Compiles every island `.tsx` against React's JSX runtime. * * The repo's root tsconfig sets `jsxImportSource` to `@wrnexus/core`, so an * island would otherwise compile to WRNexus's HTML-string renderer and * silently never mount. A `@jsxImportSource` pragma applies only to the file * that carries it, so putting one in the generated entry does nothing for the * author's own component — the injection has to happen per source file, which * is what this plugin does. App-authored islands stay plain `.tsx`. */ export function reactJsxPlugin(): BunPlugin { return { name: "wrnexus-island-jsx", setup(build) { build.onLoad({ filter: /\.tsx$/ }, async (args) => { // Only first-party island sources need the pragma. Third-party .tsx // under node_modules is left alone so Bun's own handling is untouched. if (args.path.includes("node_modules")) return undefined; return { contents: `/** @jsxImportSource react */\n${await Bun.file(args.path).text()}`, loader: "tsx" as const, }; }); }, }; } export function assertReactAvailable( appRoot: string, ): { code: "WRN-ISLAND-REACT-MISSING"; message: string; severity: "error" } | null { const require = createRequire(join(appRoot, "package.json")); try { require.resolve("react"); require.resolve("react-dom"); return null; } catch { return { code: "WRN-ISLAND-REACT-MISSING", severity: "error", message: "This app imports a .tsx island but react and react-dom are not installed. " + "Run: bun add react react-dom", }; } } /** * Bundles island entries. `splitting: true` is required so React is emitted * once as a shared chunk rather than duplicated into every island. */ export async function buildIslands(input: { islands: IslandInput[]; outDir: string; /** * App root used to resolve the island mount runtime. When given, the runtime * is emitted as `runtime.js` in the SAME build as the islands. * * This is not a convenience: building the runtime separately gives it its own * copy of React, and a component rendered by one copy while importing hooks * from another fails with "Cannot read properties of null (reading * 'useState')". One build with splitting keeps React in a single shared chunk. */ appRoot?: string; }): Promise { if (input.islands.length === 0) return { assets: [], sharedChunks: [] }; // Each island gets its own generated entry file named after the island. // Passing the component sources directly would dedupe two islands that share // a source file, and output order is not guaranteed to match input order — // both of which silently mismatch island names to bundles. // // The entries live inside outDir so `react` resolves from the app that // installed it, exactly as the island's own imports do. const entryDir = join(input.outDir, ".entries"); mkdirSync(entryDir, { recursive: true }); const islandNames = new Set(input.islands.map((island) => island.name)); for (const island of input.islands) { writeFileSync(join(entryDir, `${island.name}.tsx`), generateIslandEntry(island), "utf8"); } const entrypoints = input.islands.map((island) => join(entryDir, `${island.name}.tsx`)); if (input.appRoot) { const resolveFrom = createRequire(join(input.appRoot, "package.json")); const runtimeEntry = join(entryDir, "runtime.ts"); writeFileSync( runtimeEntry, `export * from ${JSON.stringify(resolveFrom.resolve("@wrnexus/react/browser"))}; `, "utf8", ); entrypoints.push(runtimeEntry); } try { const result = await Bun.build({ entrypoints, outdir: input.outDir, target: "browser", format: "esm", splitting: true, minify: true, plugins: [reactJsxPlugin()], }); if (!result.success) { throw new AggregateError(result.logs, "Island bundling failed"); } const assets: IslandBuildResult["assets"] = []; const sharedChunks: string[] = []; for (const output of result.outputs) { if (output.kind === "entry-point") { // Bun names an entry's output after its entry file, so the basename // identifies the island unambiguously. const stem = basename(output.path).replace(/\.js$/, ""); if (stem === "runtime") continue; if (!islandNames.has(stem)) continue; assets.push({ name: stem, hash: createHash("sha256").update(output.path).digest("hex").slice(0, 16), path: output.path, }); } else if (output.kind === "chunk") { sharedChunks.push(output.path); } } return { assets, sharedChunks }; } finally { rmSync(entryDir, { recursive: true, force: true }); } }