import { join, resolve } from 'node:path' import type { BunPlugin } from 'bun' import { cacheDir, codegenCaseRenderEntry, codegenCaseSsrEntry, codegenPrimerEntry, codegenSsrPrimerEntry, resolveConfig, } from '../core/discovery' import { mdxPlugin } from '../core/mdx-plugin' import { pinReact } from '../core/pin-react' /** * The URL prefix the dev server serves bundler output from (the `/dist/` handler * in `server.ts` streams files out of the cache `dist` dir). Passed to every dev * `Bun.build` as `publicPath` so a `file`-loader asset import (e.g. `import logo * from './logo.png'`) is rewritten to an absolute `/dist/-.ext` URL * that resolves against that mount — not a document-relative URL that resolves * against `/render//…` and 404s. It applies to both the browser build * (emits the asset bytes into `dist`) and the SSR build (whose rendered `` must reference the same URL); the content hash matches across targets, so * the SSR markup points at the browser-emitted file. See `contributing/NOTES.md`. */ const DEV_ASSET_PUBLIC_PATH = '/dist/' /** * Record a build's real module graph from Bun's native `metafile` — the set of * on-disk files the bundler actually loaded (transitive imports, file-loader * assets, and paths another plugin's `onLoad` handled, all included). Keys are * relative to the worker's cwd, so each is resolved to an absolute path (the form * the dev watcher and `owningPackage` expect). Tolerant of a `metafile`-less * result (a logical build failure) so callers can still surface a partial graph. * * This replaces the former `graphRecorder` plugin — a pass-through, catch-all * `onLoad` observer that returned `undefined`. That observer, combined * with a `file`-loader asset imported with a value binding, tripped a Bun 1.3.14 * use-after-free in the parallel chunk linker (`generateChunksInParallel`), * crashing every build surface for such a component. `metafile` yields the same * graph without an `onLoad` hook, so the collision class is gone. (The plugin was * hand-rolled originally because Bun exposed no module graph; it since added one.) */ function collectInputs( into: Set, result: Awaited>, ): void { if (!result.metafile) return for (const key of Object.keys(result.metafile.inputs)) { into.add(resolve(process.cwd(), key)) } } /** * Externalize ONLY the exact specifiers given — not their subpaths. Bun's built-in * `external` option matches a package by prefix, so `external: ['markdown-to-jsx']` * would also externalize an internal `markdown-to-jsx/entities` import, leaving a * bare specifier the document's importmap (which maps only the declared specifiers) * can't resolve in the browser. The shared-runtime build maps each declared * specifier to its own vendor bundle, so a browser surface must externalize exactly * those and **inline** any undeclared subpath (the publish spec's render-correct * fallback). SSR keeps Bun's prefix `external` — there, a bare subpath resolves from * the deployed `node_modules` at runtime, so prefix matching is correct. */ function externalExact(specs: string[]): BunPlugin { return { name: 'display-case-external-exact', setup(build) { if (specs.length === 0) return const filter = new RegExp( `^(${specs .map((s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')) .join('|')})$`, ) build.onResolve({ filter }, (args) => ({ path: args.path, external: true, })) }, } } /** * The **build worker**: every `Bun.build` Display Case runs for the dev server * happens here, spawned by `server.ts` as a short-lived child process (`bun * build-case.ts …`, see the `import.meta.main` block below) — never in the * long-lived server. This is the generalization of `loadManifestFresh`: the * worker's bundler heap dies with the process, so the server never accumulates the * bundler heap state that corrupts and segfaults on a large catalog ("a bug in * Bun, not your code"). The server only orchestrates the spawn and serves the * bytes the worker wrote to the `.display-case/` cache. * * Two build kinds: `shell` (the browse chrome + optional primer) and `case` (one * component's browser + SSR bundle). Each emits `{ ok, inputs, error? }` JSON on * stdout. A *build error* (an unresolved import) is `{ ok: false }`; a Bun bundler * *crash* kills the worker (a signal exit with no JSON), which the parent detects * and attributes to that surface instead of inheriting the panic. The functions * are also exported so they are unit-testable directly. */ /** * Build a `Bun.build` `define` map that inlines the consumer's public env * (`BUN_PUBLIC_*`) into the browser bundle — the same values the app's own * production build inlines (`bun build … --env='BUN_PUBLIC_*'`). * * Why `define` and not `Bun.build({ env: 'BUN_PUBLIC_*' })`: the `env` option * only inlines vars present in the environment Bun snapshotted at *process* * startup (plus the CWD-relative `.env` Bun auto-loads). Display Case runs from * the repo root, not the consumer package, so a public var defined only in * `/.env` (e.g. the API base URL) is absent at build time — and mutating * `process.env` at runtime does not influence it. Left unreplaced, a * `process.env.BUN_PUBLIC_*` read survives as a literal that throws * `process is not defined` in the browser. `define` replaces the literal * unconditionally, independent of env timing. * * Scoped strictly to the public prefix so non-public env (secrets, NODE_ENV, * ports) never enters the bundle. A real exported `process.env` value wins over * the file so local overrides still apply. */ async function publicEnvDefines( pkgDir: string, ): Promise> { const values = new Map() for (const name of ['.env', '.env.local']) { const file = Bun.file(join(pkgDir, name)) if (!(await file.exists())) continue for (const raw of (await file.text()).split('\n')) { const line = raw.trim() if (!line || line.startsWith('#')) continue const eq = line.indexOf('=') if (eq === -1) continue const key = line .slice(0, eq) .replace(/^export\s+/, '') .trim() if (!key.startsWith('BUN_PUBLIC_')) continue let value = line.slice(eq + 1).trim() if ( (value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'")) ) { value = value.slice(1, -1) } values.set(key, value) } } // A real exported env value overrides the file (local override wins). for (const [key, value] of Object.entries(process.env)) { if (key.startsWith('BUN_PUBLIC_') && value !== undefined) { values.set(key, value) } } const defines: Record = {} for (const [key, value] of values) { defines[`process.env.${key}`] = JSON.stringify(value) } return defines } export interface BuildCaseArgs { pkgDir: string file: string configPath: string componentId: string /** Sequence suffix so the SSR bundle gets a fresh on-disk name each build (Bun * caches `import()` by resolved path). `buildCase` imports the matching file. */ seq: number } export interface BuildCaseResult { ok: boolean /** Absolute paths the build read — the component's real module graph, so the * dev watcher can follow source-resolved deps. */ inputs: string[] /** Present when `ok` is false: the bundler logs explaining the build failure. */ error?: string } /** * Codegen and build one component's browser render bundle (→ * `/dist/render-case-.js`) and SSR bundle (→ * `/ssr/ssr-case--.js`). Returns the recorded module-graph inputs. * A *build failure* is returned as `{ ok: false }`; a native bundler crash kills * the worker process (which is the point of running it as a child). */ export async function buildCaseBundles( args: BuildCaseArgs, ): Promise { const { pkgDir, file, configPath, componentId, seq } = args const inputs = new Set() const outdir = join(cacheDir(pkgDir), 'dist') const ssrOutDir = join(cacheDir(pkgDir), 'ssr') try { const define = await publicEnvDefines(pkgDir) // The stage runtime the substrate declares (the DOM mount by default). const { resolveSubstrate } = await import('../substrate/resolve') const { config } = await resolveConfig(pkgDir) const renderEntry = await codegenCaseRenderEntry( pkgDir, file, configPath, componentId, resolveSubstrate(config).stage?.entry, ) const browser = await Bun.build({ entrypoints: [renderEntry], outdir, target: 'browser', publicPath: DEV_ASSET_PUBLIC_PATH, plugins: [mdxPlugin(), pinReact(pkgDir)], define, metafile: true, naming: { entry: '[name].[ext]', chunk: '[name]-[hash].[ext]', asset: '[name]-[hash].[ext]', }, }) collectInputs(inputs, browser) if (!browser.success) { return { ok: false, inputs: [...inputs], error: browser.logs.map(String).join('\n') || 'browser bundle failed', } } const ssrEntry = await codegenCaseSsrEntry( pkgDir, file, configPath, componentId, seq, ) const ssr = await Bun.build({ entrypoints: [ssrEntry], outdir: ssrOutDir, target: 'bun', // SSR-rendered `` must reference the same `/dist/` asset URL the // browser build emits (matching content hash), so the markup resolves. publicPath: DEV_ASSET_PUBLIC_PATH, plugins: [mdxPlugin(), pinReact(pkgDir)], define, metafile: true, naming: { entry: `ssr-case-${componentId}-${seq}.[ext]`, chunk: '[name]-[hash].[ext]', asset: '[name]-[hash].[ext]', }, }) collectInputs(inputs, ssr) if (!ssr.success) { return { ok: false, inputs: [...inputs], error: ssr.logs.map(String).join('\n') || 'SSR bundle failed', } } return { ok: true, inputs: [...inputs] } } catch (err) { // Bun.build *rejects* (rather than returning `success: false`) for some // failures — an unresolvable import, a syntax error in the graph. Report it // as a structured failure. (A native bundler *crash* kills the worker; the // parent sees the signal exit — see the module comment.) return { ok: false, inputs: [...inputs], error: err instanceof Error ? (err.message ?? String(err)) : String(err), } } } export interface BuildShellArgs { pkgDir: string configPath: string /** The config's `primer` value (package-relative), or null when no primer. */ primerPath: string | null /** Sequence suffix for the primer SSR bundle's on-disk name (see seq above). */ seq: number } const HERE = resolve(import.meta.dir, '..') const BROWSER_ENTRY = join(HERE, 'ui', 'browser-entry.tsx') /** * Build the browse chrome (`browser-entry`) plus, when configured, the primer's * browser entry and its SSR entry — the catalog-size-independent startup bundles. * Mirrors the per-case worker: outputs to the `.display-case/` cache, returns the * recorded module graph. The chrome carries no case modules, so this graph is * small; running it in the worker (not the server) is what keeps the bundler heap * out of the long-lived process entirely. */ export async function buildShellBundles( args: BuildShellArgs, ): Promise { const { pkgDir, configPath, primerPath, seq } = args const inputs = new Set() const outdir = join(cacheDir(pkgDir), 'dist') const ssrOutDir = join(cacheDir(pkgDir), 'ssr') try { const define = await publicEnvDefines(pkgDir) const primerEntry = primerPath ? await codegenPrimerEntry(pkgDir, primerPath) : null const entrypoints = [BROWSER_ENTRY] if (primerEntry) entrypoints.push(primerEntry) const browser = await Bun.build({ entrypoints, outdir, target: 'browser', publicPath: DEV_ASSET_PUBLIC_PATH, plugins: [mdxPlugin(), pinReact(pkgDir)], define, metafile: true, naming: { entry: '[name].[ext]', chunk: '[name]-[hash].[ext]', asset: '[name]-[hash].[ext]', }, }) collectInputs(inputs, browser) if (!browser.success) { return { ok: false, inputs: [...inputs], error: browser.logs.map(String).join('\n') || 'shell bundle failed', } } if (primerPath) { const ssrPrimerEntry = await codegenSsrPrimerEntry( pkgDir, primerPath, configPath, ) const ssr = await Bun.build({ entrypoints: [ssrPrimerEntry], outdir: ssrOutDir, target: 'bun', publicPath: DEV_ASSET_PUBLIC_PATH, plugins: [mdxPlugin(), pinReact(pkgDir)], define, metafile: true, naming: { entry: `ssr-primer-entry-${seq}.[ext]`, chunk: '[name]-[hash].[ext]', asset: '[name]-[hash].[ext]', }, }) collectInputs(inputs, ssr) if (!ssr.success) { return { ok: false, inputs: [...inputs], error: ssr.logs.map(String).join('\n') || 'primer SSR bundle failed', } } } return { ok: true, inputs: [...inputs] } } catch (err) { return { ok: false, inputs: [...inputs], error: err instanceof Error ? (err.message ?? String(err)) : String(err), } } } /** * A serializable `Bun.build` request for one **publish** bundle. The publish * command (`publish.ts`) computes the entry files (codegen), the content-hashed * naming, the `define` map (`BUN_PUBLIC_*` + production `NODE_ENV`), and the SSR * `external` list, then hands this descriptor to the worker so the actual * `Bun.build` runs in a fresh child — never in the long-lived publish process. * Non-serializable plugins are reconstructed worker-side from `pkgDir`/`pinReact`. */ export interface PublishBuildRequest { pkgDir: string entrypoints: string[] outdir: string target: 'browser' | 'bun' minify: boolean /** URL prefix Bun rewrites `file`-loader asset imports to, so an emitted asset's * URL points at the mount that serves it (`/assets/` for a publish build). * Omit to leave imports document-relative. See `DEV_ASSET_PUBLIC_PATH`. */ publicPath?: string naming: { entry: string; chunk: string; asset?: string } define: Record external?: string[] /** Match `external` by EXACT specifier (a browser surface, so an undeclared subpath * inlines instead of leaking past the importmap) rather than Bun's package-prefix * matching (SSR, where a subpath resolves at runtime). See `externalExact`. */ externalExact?: boolean /** Chrome/render bundles pin the consumer React; SSR keeps React external. */ pinReact: boolean /** Split shared code into chunks across entrypoints. Used ONLY for the bounded * shared-vendor build (a handful of entries), where it dedups code common to * the shared specifiers (e.g. the reconciler under `react-dom` and * `react-dom/client`) into one chunk — the one place splitting is safe, since * the catalog is never built as one graph. Off everywhere else. */ splitting?: boolean } export interface PublishBuildResult { ok: boolean inputs: string[] /** Entry-point outputs the build wrote, so the parent maps content-hashed * basenames to asset URLs. */ outputs: { path: string; kind: string }[] error?: string } /** * Run one publish `Bun.build` from a {@link PublishBuildRequest} and report its * entry-point outputs. Mirrors the dev kinds: a *build failure* is `{ ok:false }`; * a native bundler crash kills the worker (the point of the child process). */ export async function buildPublishBundle( req: PublishBuildRequest, ): Promise { const inputs = new Set() try { const plugins = [mdxPlugin()] if (req.pinReact) plugins.push(pinReact(req.pkgDir)) // Exact-match externalization is a resolve plugin (not Bun's prefix `external`), // so an undeclared subpath stays inlined rather than leaking past the importmap. if (req.externalExact && req.external?.length) { plugins.push(externalExact(req.external)) } const result = await Bun.build({ entrypoints: req.entrypoints, outdir: req.outdir, target: req.target, minify: req.minify, publicPath: req.publicPath, sourcemap: 'none', splitting: req.splitting ?? false, plugins, define: req.define, external: req.externalExact ? undefined : req.external, metafile: true, naming: req.naming, }) collectInputs(inputs, result) if (!result.success) { return { ok: false, inputs: [...inputs], outputs: [], error: result.logs.map(String).join('\n') || 'publish bundle failed', } } const outputs = result.outputs .filter((o) => o.kind === 'entry-point') .map((o) => ({ path: o.path, kind: o.kind })) return { ok: true, inputs: [...inputs], outputs } } catch (err) { return { ok: false, inputs: [...inputs], outputs: [], error: err instanceof Error ? (err.message ?? String(err)) : String(err), } } } // Worker entry: `bun build-case.ts …`. Emits the JSON result on stdout and // exits 0 (built) / 1 (build error) / 2 (bad args). A native bundler crash exits // abnormally with no stdout — the parent treats either as a per-surface failure. if (import.meta.main) { const [kind, ...rest] = process.argv.slice(2) // A `function` declaration (not a `const` arrow) so TypeScript's control-flow // analysis treats its `never` return as diverging — that's what lets each // branch's `else { badArgs() }` satisfy `result`'s definite assignment. function badArgs(): never { process.stderr.write(`build-case: bad args for kind '${kind}'\n`) process.exit(2) } const parsePublishRequest = (json: string): PublishBuildRequest => { try { return JSON.parse(json) } catch { return badArgs() } } let result: BuildCaseResult | PublishBuildResult // Each branch does its work *inside* the truthy-narrowed `if`, so the parsed // argv slots (each `string | undefined` under noUncheckedIndexedAccess) are // already narrowed to `string` — no non-null assertions in the worker entry. if (kind === 'publish') { // — a JSON-encoded PublishBuildRequest. const [descriptor] = rest if (descriptor) { result = await buildPublishBundle(parsePublishRequest(descriptor)) } else { badArgs() } } else if (kind === 'case') { // const [pkgDir, file, configPath, componentId, seqStr] = rest if (pkgDir && file && configPath && componentId && seqStr) { result = await buildCaseBundles({ pkgDir, file, configPath, componentId, seq: Number(seqStr), }) } else { badArgs() } } else if (kind === 'shell') { // — primerPath may be empty. const [pkgDir, configPath, primerPath, seqStr] = rest if (pkgDir && configPath && primerPath !== undefined && seqStr) { result = await buildShellBundles({ pkgDir, configPath, primerPath: primerPath || null, seq: Number(seqStr), }) } else { badArgs() } } else { process.stderr.write(`build-case: unknown build kind '${kind}'\n`) process.exit(2) } process.stdout.write(JSON.stringify(result)) process.exit(result.ok ? 0 : 1) }