import { existsSync } from 'node:fs' import { readdir, rm } from 'node:fs/promises' import { createServer } from 'node:net' import { join, relative, resolve, sep } from 'node:path' import { type A11yScanner, type A11yScanStatus, createA11yScanner, } from '../checks/a11y-scanner' import { buildCatalog, slugify } from '../core/catalog' import type { LoadedModule } from '../core/discovery' import { cacheDir, discoverCaseFiles, loadModules, resolveConfig, } from '../core/discovery' import { findWatchRoot, graphWatchDirs } from '../core/graph-watch' import { buildGroupTree, makeGroupResolver } from '../core/groups' import type { BrowseMode, Manifest, ManifestSubstrate } from '../core/manifest' import type { Substrate } from '../core/substrate' import { type DisplayCaseConfig, isSurfaceLevel, type ThemeSignal, } from '../index' import type { PrimerHtmlResult } from '../render/ssr-primer' import type { CaseRenderer } from '../render/ssr-render' import { renderShellToHtml } from '../render/ssr-shell' import { effectiveThemeSignals, resolveThemeSignals, themeRootAttrs, themeSignalsSeedScript, } from '../render/theme-signals' import { renderVariants, resolveSubstrate } from '../substrate/resolve' import type { Theme } from '../ui/shell-core' import { buildTimeoutMs, killActiveBuildWorkers, spawnBuildWorker, } from './build-runner' // Re-exported so existing importers (and tests) resolve these from `./server` // unchanged after the spawn/classify primitives moved to `build-runner.ts`. export { type BuildOutcome, classifyBuildResult } from './build-runner' const HERE = resolve(import.meta.dir, '..') const CHROME_CSS = join(HERE, 'ui', 'chrome.css') const CLI = join(HERE, 'cli.ts') // EVERY `Bun.build` runs in a fresh, short-lived child (see build-case.ts and the // build-runner) — never in this long-lived server — so the server never accumulates // the bundler heap state that segfaults on a large catalog. The spawn/classify/ // concurrency primitives live in `build-runner.ts`, shared with `publish.ts`. // Startup invariant tracing (report §6.B): with DISPLAY_CASE_TRACE set, log the // wall-time + reported input count of each startup step, so the "size-independent // startup" invariant can be confirmed on a large consumer (no step should grow // with catalog size now that bundling and the manifest load are both isolated). const TRACE = !!process.env.DISPLAY_CASE_TRACE async function trace(label: string, fn: () => Promise): Promise { if (!TRACE) return fn() const t0 = performance.now() const r = await fn() console.log(` ⏱ ${label}: ${(performance.now() - t0).toFixed(0)}ms`) return r } // `spawnBuild` is the shared `spawnBuildWorker` (build-runner.ts); the alias keeps // the call sites below reading as they did before the extraction. const spawnBuild = spawnBuildWorker // The package's own design system — "The Vitrine". Display Case dogfoods it: // the browse chrome is styled entirely from these `--dc-*` tokens. The token // files are inlined (in @import order, fonts excluded) ahead of chrome.css; the // webfonts load via the s below so the declaration leads the document and // never fights a consumer's stylesheet @imports. See ui/design-system/. const DS_DIR = join(HERE, 'ui', 'design-system', 'tokens') const DS_TOKEN_FILES = ['colors.css', 'typography.css', 'spacing.css'] const FONT_LINKS = '' + '' + '' async function readDesignTokens(): Promise { const parts = await Promise.all( DS_TOKEN_FILES.map((f) => Bun.file(join(DS_DIR, f)).text()), ) return parts.join('\n') } // The Vitrine's own chrome stylesheet, assembled by reading and concatenating // (in path-sorted order) the shell layout (chrome.css), every design-system // component's co-located CSS, and the primer chrome's CSS. The design-system // components no longer inject their CSS at runtime; this blob is inlined into // every document head so the chrome paints before scripts run. Mirrors // readDesignTokens (read N files, join) — no bundler step, no JS-graph import. const COMPONENTS_DIR = join(HERE, 'ui', 'design-system', 'components') const PRIMER_CSS = join(HERE, 'ui', 'primer.css') async function readVitrineCss(): Promise { const componentFiles: string[] = [] for await (const f of new Bun.Glob('**/*.css').scan({ cwd: COMPONENTS_DIR, absolute: true, })) { componentFiles.push(f) } componentFiles.sort() const files = [CHROME_CSS, ...componentFiles] if (existsSync(PRIMER_CSS)) files.push(PRIMER_CSS) const parts = await Promise.all(files.map((f) => Bun.file(f).text())) return parts.join('\n') } /** Walk up from this package to find the repo root (nearest dir with `.git`). */ function findRepoRoot(): string { let dir = HERE for (let i = 0; i < 12; i++) { if (existsSync(join(dir, '.git'))) return dir const parent = resolve(dir, '..') if (parent === dir) break dir = parent } return process.cwd() } const REPO_ROOT = findRepoRoot() interface BuiltState { manifest: Manifest /** component id → absolute placard-doc path (only present when a doc exists). */ placardById: Map /** Concatenated consumer global stylesheet contents. */ globalCss: string /** Pre-render the primer to markup, or null when no primer is configured. */ renderPrimer: (() => PrimerHtmlResult) | null /** Absolute paths of every on-disk file the bundles read this build — the * actual module graph, transitive workspace-sibling source included. The dev * watcher follows this so editing a source-resolved dependency rebuilds. */ inputs: Set /** The chrome (shell + primer) bundle's own module graph, kept apart from the * per-case inputs that accumulate into `inputs`. A rebuild whose changed files * don't intersect this set reuses the prior chrome instead of rebuilding it — * a consumer's component edits never touch Display Case's own UI graph. */ shellInputs: Set /** Set when the chrome (shell) bundle failed or crashed the bundler. The server * still binds and serves a diagnostic instead of the browse UI, rather than the * tool terminating with a bare native panic. */ shellError?: string } // Monotonic suffix for the SSR bundle's filename. Bun caches `import()` by // resolved path and ignores `?v=` busting, so each rebuild must write — and // import — a uniquely-named bundle to pick up edited case source. (The browser // render bundle is always fresh because `Bun.build` re-reads from disk; this is // the in-process import equivalent of that freshness.) let ssrBuildSeq = 0 function relPath(p: string): string { return relative(REPO_ROOT, p) } /** * Delete every file in `dir` matching `re` whose captured seq group is not * `keepSeq`. The seq-named SSR bundles + codegen entries (see `codegenCaseSsrEntry` * and the `ssr-*-` build naming) otherwise accumulate for the life of the * server — every rebuild writes a new one and the old ones are never reclaimed, * leaking both disk and (via the parent's resolved-path `import()` cache) heap. * Pruning the superseded seqs after each build bounds the disk growth. A missing * dir or a failed unlink is ignored — pruning is best-effort cache hygiene. */ async function pruneSeqIn( dir: string, re: RegExp, keepSeq: number, ): Promise { let names: string[] try { names = await readdir(dir) } catch { return // dir not created yet — nothing to prune } await Promise.all( names.map(async (name) => { const m = re.exec(name) if (m && Number(m[1]) !== keepSeq) { await rm(join(dir, name), { force: true }).catch(() => {}) } }), ) } /** Escape a catalog slug for safe inclusion in a `RegExp` (slugs are normally * `[a-z0-9-]`, but never assume — a stray metachar must not widen the match). */ function escapeRegExp(s: string): string { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') } /** Drop a component's superseded SSR bundle (`ssr/`) and codegen entry (cache * root) seqs, keeping only `keepSeq`. The slug is anchored + escaped so e.g. * pruning `button` never matches `button-group`. */ async function pruneCaseSsrSeq( pkgDir: string, componentId: string, keepSeq: number, ): Promise { const dir = cacheDir(pkgDir) const esc = escapeRegExp(componentId) await Promise.all([ pruneSeqIn( join(dir, 'ssr'), new RegExp(`^ssr-case-${esc}-(\\d+)\\.js(\\.map)?$`), keepSeq, ), pruneSeqIn(dir, new RegExp(`^ssr-case-${esc}-(\\d+)\\.tsx$`), keepSeq), ]) } /** Drop superseded primer SSR bundle seqs, keeping only `keepSeq`. */ async function prunePrimerSsrSeq( pkgDir: string, keepSeq: number, ): Promise { await pruneSeqIn( join(cacheDir(pkgDir), 'ssr'), /^ssr-primer-entry-(\d+)\.js(\.map)?$/, keepSeq, ) } /** * Clear the on-disk SSR cache before the first build of a session. `ssrBuildSeq` * resets to 0 on restart, so a prior session's higher-seq bundles/entries would * otherwise linger forever (nothing imports them, and the per-build prune only * touches the current component's seqs). Nothing has been imported yet, so wiping * is safe; the worker recreates `ssr/` on its next build. */ async function sweepStaleSsr(pkgDir: string): Promise { const dir = cacheDir(pkgDir) await rm(join(dir, 'ssr'), { recursive: true, force: true }).catch(() => {}) // The codegen entries live at the cache root (not under ssr/); sweep those too. await pruneSeqIn(dir, /^ssr-case-.+-(\d+)\.tsx$/, -1).catch(() => {}) } /** * Whether a change to one of `changed` can alter the **manifest** (the catalog's * shape: which components/cases exist, their names/levels/tweak schemas, the * present modes) — as opposed to a component *implementation* edit, which changes * only that component's rendered bundle and leaves the manifest identical. Only a * manifest-relevant change needs the (catalog-size-dependent) manifest subprocess * re-run; an implementation edit reuses the prior manifest and just rebuilds the * affected case bundle on its next request. */ export function manifestRelevant( changed: readonly string[], configPath: string, primerAbs: string | null, ): boolean { const config = resolve(configPath) const primer = primerAbs ? resolve(primerAbs) : null return changed.some( (p) => /\.case\.tsx?$/.test(p) || /\.placard\.md$/.test(p) || resolve(p) === config || (primer !== null && resolve(p) === primer), ) } /** The independent surfaces a rebuild may reuse from the prior served state. */ export interface RebuildPlan { /** Re-run the (catalog-size-dependent) manifest subprocess. */ needManifest: boolean /** Re-bundle the chrome (shell + primer). */ needShell: boolean } /** * Decide which independent surfaces a change can have touched, so a rebuild can * reuse the rest. No prior chrome graph (startup) or no specific changed paths ⇒ * rebuild everything, matching `staleCaseIds`' "empty changed ⇒ invalidate all" * convention. Otherwise the manifest re-runs only for a manifest-relevant change * (`manifestRelevant`), and the chrome rebuilds only when a changed path * intersects the prior chrome graph (`prevShellInputs`) — a consumer's component * edits never touch Display Case's own UI, so they reuse the chrome wholesale. */ export function planRebuild( changed: readonly string[], prevShellInputs: ReadonlySet | null, configPath: string, primerSrc: string | null, ): RebuildPlan { if (!prevShellInputs || changed.length === 0) { return { needManifest: true, needShell: true } } return { needManifest: manifestRelevant(changed, configPath, primerSrc), // Mirror `staleCaseIds`' direct membership test (no `resolve(p)`): the // watcher's path strings already match the recorded graph inputs by the same // invariant case invalidation relies on. Don't normalize one side only. needShell: changed.some((p) => prevShellInputs.has(p)), } } /** * Wrap an async `pass` so overlapping triggers can never run it concurrently. A * rebuild is slow on a large catalog; a file change arriving *during* one must * not start a second pass (they'd race on the served `state`, `ssrBuildSeq`, and * the case cache). Instead a trigger received while a pass is in flight marks the * run dirty, so the in-flight loop repeats `pass` exactly once more on completion * — coalescing any number of mid-flight triggers into a single follow-up pass. * * The starting trigger's `arg` is used for every coalesced iteration of that run * (a mid-flight trigger only sets the dirty flag); a throw is reported via * `onError` and ends the run, leaving the runner idle so the next trigger starts * fresh. The debounce that batches a burst *before* a pass starts stays with the * caller — this only guards the *concurrent* case. */ export function createCoalescingRunner( pass: (arg: A) => Promise, onError: (err: unknown) => void, ): (arg: A) => Promise { let inFlight = false let dirty = false return async function trigger(arg: A): Promise { if (inFlight) { dirty = true return } inFlight = true try { do { dirty = false await pass(arg) // A trigger that landed mid-pass set `dirty` ⇒ loop once more so the // result reflects every change, without ever running two passes at once. } while (dirty) } catch (err) { onError(err) } finally { inFlight = false } } } /** Absolute path of the configured primer `.mdx`, or null if none/missing. */ function primerFile(pkgDir: string, config: DisplayCaseConfig): string | null { if (!config.primer) return null const abs = resolve(pkgDir, config.primer) return existsSync(abs) ? abs : null } function buildManifest( pkgDir: string, modules: LoadedModule[], config: DisplayCaseConfig, hasPrimer: boolean, ): { manifest: Manifest; placardById: Map } { const fileByComponent = new Map( modules.map((m) => [m.module.component, m.file]), ) // The manifest-building load path (loadModules) doesn't tag modules with their // source path the way the codegen'd bundles do, so set it here — group // resolution and the decorator both key off it. Package-relative, matching the // `roots` globs. for (const m of modules) { if (m.module.sourcePath == null) m.module.sourcePath = relative(pkgDir, m.file) } const placardById = new Map() const resolveGroup = makeGroupResolver(config) const catalog = buildCatalog( modules.map((m) => m.module), resolveGroup, ) const components = catalog.map((c) => { const file = fileByComponent.get(c.name) as string const placardAbs = file.replace(/\.case\.tsx?$/, '.placard.md') const hasDoc = existsSync(placardAbs) if (hasDoc) placardById.set(c.id, placardAbs) return { id: c.id, name: c.name, level: c.level, isFlow: c.isFlow, group: c.group, caseFile: relPath(file), placardDoc: hasDoc ? relPath(placardAbs) : null, cases: c.cases.map((cs) => ({ id: cs.id, name: cs.name, // The mode is the path prefix: `/e/` for an Exhibits surface, `/c/` for // a Components (kit) case. The render endpoint stays unified. browseUrl: `/${isSurfaceLevel(c.level) ? 'e' : 'c'}/${c.id}/${cs.id}`, renderUrl: `/render/${c.id}/${cs.id}`, tweaks: cs.tweaks, transitions: cs.transitions, })), } }) // The present modes, in canonical order: a mode is offered only when it has // content (a primer; ≥1 building-block component; ≥1 page/flow surface). const hasKit = catalog.some((c) => !isSurfaceLevel(c.level)) const hasSurfaces = catalog.some((c) => isSurfaceLevel(c.level)) const modes: BrowseMode[] = [] if (hasPrimer) modes.push('primer') if (hasKit) modes.push('components') if (hasSurfaces) modes.push('exhibits') // Land on the configured mode when it's present; otherwise the first present // mode (primer → components → exhibits). With no config and a primer present, // this lands on the primer, as before. const want = config.landing const landing: BrowseMode = want && modes.includes(want) ? want : (modes[0] ?? 'components') const groups = buildGroupTree(catalog, config) return { manifest: { title: config.title, components, groups, modes, landing, flowMarker: config.nav?.flowMarker ?? 'tag', // What this showcase renders through, and what it varies over. An agent // reads the axes rather than assuming light/dark: a substrate for another // medium declares entirely different ones. substrate: manifestSubstrate(resolveSubstrate(config)), }, placardById, } } /** Flatten a substrate's identity and declared axes into manifest data. */ function manifestSubstrate(substrate: Substrate): ManifestSubstrate { return { id: substrate.id, variants: substrate.variants.map((axis) => ({ id: axis.id, label: axis.label, kind: axis.kind, values: axis.values.map((v) => ({ value: v.value, label: v.label })), default: axis.default, })), levelLabels: substrate.levelLabels, } } async function readGlobalCss( pkgDir: string, config: DisplayCaseConfig, ): Promise { const parts: string[] = [] for (const rel of config.globalStyles ?? []) { const abs = resolve(pkgDir, rel) if (await Bun.file(abs).exists()) parts.push(await Bun.file(abs).text()) } return parts.join('\n') } /** * Build the manifest in a fresh subprocess. Bun caches ES modules by resolved * path for the life of a process (a `?v=` query does not bust it), so an * in-process re-import after an edit would return the stale module — the * manifest shape (case order/names, level, tweak schema) would never update on * a watch rebuild. Spawning `--print-manifest` gives a clean module graph each * time; the child's stderr (load errors) is relayed to ours. */ async function loadManifestFresh(pkgDir: string): Promise { const proc = Bun.spawn(['bun', CLI, pkgDir, '--print-manifest'], { stdout: 'pipe', stderr: 'pipe', }) const collect = Promise.all([ new Response(proc.stdout).text(), new Response(proc.stderr).text(), proc.exited, ]) let timer: ReturnType | undefined const timeout = new Promise((res) => { timer = setTimeout(() => res(null), buildTimeoutMs()) }) try { // A `--print-manifest` child that hangs (a case module with a never-resolving // top-level await) must not stall the rebuild — and at startup, the bind — for // ever. Bound it: on timeout, kill and throw, so the hang becomes a contained, // logged failure instead of an indefinite stall. const done = await Promise.race([collect, timeout]) if (done === null) { proc.kill() throw new Error( `manifest build subprocess hung (no result within ${buildTimeoutMs()}ms; killed)`, ) } const [out, err, code] = done if (err.trim()) process.stderr.write(err.endsWith('\n') ? err : `${err}\n`) if (code !== 0) { throw new Error(`manifest build subprocess exited with code ${code}`) } return JSON.parse(out) as Manifest } finally { if (timer) clearTimeout(timer) void collect.catch(() => {}) } } /** Options that let a rebuild reuse the surfaces a given change can't have * touched. Absent (startup, or a conservative fallback) ⇒ a full rebuild. */ interface RebuildOpts { /** Absolute paths changed since the last rebuild (empty ⇒ full rebuild). */ changed?: string[] /** The previously-served state, reused for unaffected surfaces. */ prev?: BuiltState } /** * Discover, codegen, bundle, and assemble the served state — rebuilding only the * surfaces a change can have touched. The chrome (shell) and the manifest are * independent and gated separately: a component *implementation* edit reuses both * (only its case bundle is invalidated, on demand); a `.case`/`.placard`/config * change re-runs the manifest subprocess; a change inside Display Case's own UI * graph rebuilds the chrome. The needed steps — plus the consumer global-CSS read * — run concurrently (their cost is the max of the steps, not the sum). Bundling * still happens only in worker children (the crash-containment precondition). */ async function rebuild( pkgDir: string, config: DisplayCaseConfig, configPath: string, opts: RebuildOpts = {}, ): Promise { const changed = opts.changed ?? [] const prev = opts.prev const ssrOutDir = join(cacheDir(pkgDir), 'ssr') const primerSrc = primerFile(pkgDir, config) const primerPath = primerSrc ? (config.primer as string) : null // Decide which independent surfaces this change can have touched, reusing the // rest from `prev` (see `planRebuild`). A Set is always truthy, so passing // `prev?.shellInputs ?? null` preserves the "no prior state ⇒ rebuild all" arm. const { needManifest, needShell } = planRebuild( changed, prev?.shellInputs ?? null, configPath, primerSrc, ) // Only a chrome build that includes the primer consumes a fresh SSR seq. const seq = needShell && primerPath ? ++ssrBuildSeq : 0 const [shellOutcome, manifest, globalCss] = await Promise.all([ needShell ? trace('shell build (worker)', () => spawnBuild([ 'shell', pkgDir, configPath, primerPath ?? '', String(seq), ]), ) : Promise.resolve(null), needManifest ? trace('manifest (subprocess)', () => loadManifestFresh(pkgDir)) : // Not manifest-relevant ⇒ prev is present (needManifest is true whenever // prev is absent), so reuse the prior catalog unchanged. Promise.resolve((prev as BuiltState).manifest), readGlobalCss(pkgDir, config), ]) let shellInputs: Set let renderPrimer: (() => PrimerHtmlResult) | null let shellError: string | undefined if (!needShell) { // Reuse the prior chrome wholesale — its on-disk bundle is still current. const p = prev as BuiltState shellInputs = p.shellInputs renderPrimer = p.renderPrimer shellError = p.shellError } else if (shellOutcome && !shellOutcome.ok) { // The chrome bundle failed or crashed the bundler. Don't take the tool down — // record it; the server serves a diagnostic instead of the browse UI. shellInputs = new Set() renderPrimer = null shellError = shellOutcome.error ?? 'shell bundle failed' console.error( ` ✗ chrome ${shellOutcome.crashed ? 'crashed the bundler' : 'failed to build'}: ${shellError}`, ) } else { shellInputs = new Set(shellOutcome?.inputs ?? []) if (TRACE) console.log(` ⏱ shell graph: ${shellInputs.size} module(s)`) shellError = undefined renderPrimer = null if (primerPath) { // The primer's SSR bundle is on disk (the worker wrote it); importing it is // evaluation, not bundling — safe in this process. const ssrPrimerModule = (await import( join(ssrOutDir, `ssr-primer-entry-${seq}.js`) )) as { renderPrimerToHtml: () => PrimerHtmlResult } renderPrimer = ssrPrimerModule.renderPrimerToHtml await prunePrimerSsrSeq(pkgDir, seq) // drop superseded primer bundles } } const placardById = new Map() for (const c of manifest.components) { if (c.placardDoc) placardById.set(c.id, resolve(REPO_ROOT, c.placardDoc)) } // The watcher's base graph is the chrome's; per-component inputs re-accumulate // as each case is (re)built on demand (see `buildCase`). const inputs = new Set(shellInputs) console.log( ` ${manifest.components.length} component(s), ${manifest.components.reduce((n, c) => n + c.cases.length, 0)} case(s)`, ) return { manifest, placardById, globalCss, renderPrimer, inputs, shellInputs, shellError, } } /** * A classic (non-module) inline script that runs *before* the deferred module * bundle. If a bundled module references a Node/Bun runtime global that is * undefined in the browser (`process`/`Bun`), it throws during module * evaluation — before React mounts, and before any error boundary or module-level * handler exists — so every case would otherwise blank *silently*. This catches * that uncaught error and paints a visible, explained banner instead. The same * impurity is caught statically by the `page-component-purity` lint; this is the * runtime backstop for anything that slips through (or a non-app package). */ const ERROR_OVERLAY_SCRIPT = `` /** * Dev-only live-reload client. Subscribes to the `/__livereload` SSE stream and * reloads the page on a `reload` event (an in-process rebuild after editing the * Display Case app — chrome, components, primer). It also reloads when the * stream *reconnects* after dropping, which is how it picks up a backend change: * `bun --watch` restarts the server process, the stream errors, and the reload * fires on the fresh connection. Injected only when the server runs with `dev`. */ const LIVERELOAD_SCRIPT = `` /** * Runtime config the browse chrome reads to wire its own event stream: whether * live reload is on (so it refetches the manifest + reloads the iframe on a * rebuild — in non-dev, where there's no inline full-page reload), and whether * a11y surfacing is configured (so it requests + receives scan results). */ function clientConfigScript(cfg: { reload: boolean a11y: boolean dev: boolean }): string { return `` } function shellHtml( title: string, globalCss: string, vitrineCss: string, tokensCss: string, liveReload: boolean, clientConfig: string, doc: { theme: Theme; markup: string; ssr: boolean; seedScript: string }, signals: readonly ThemeSignal[], ): string { // Reset html/body and paint the themed surface on them. The theme is baked // into so the token background reaches the body edges from first paint // (and the client's hydration finds a matching theme); the shell still tracks // later theme toggles on the client. Background is the chrome's own `--dc-bg` // (the Vitrine canvas), not a consumer token. Design-system tokens lead the //
${doc.markup}
${ERROR_OVERLAY_SCRIPT}${doc.seedScript}${themeSignalsSeedScript(signals)}${clientConfig}${liveReload ? LIVERELOAD_SCRIPT : ''}` } /** The document-level state the render template bakes in, mirroring what the * client otherwise sets imperatively on load (theme, the decorated/transparent * surface, the fit-to-content mount), plus the pre-rendered case markup. */ interface RenderDoc { theme: 'light' | 'dark' /** Drop the document background (decorated exhibit on the stage grid). */ transparent: boolean /** Shrink-wrap the mount to the case's natural width. */ fit: boolean /** The substrate's rendered frame, handed straight back to its `document()`. * Opaque here — the server never inspects it (see the `Frame` contract). */ frame: unknown /** Whether the frame was pre-rendered, so the client adopts the delivered * content instead of mounting fresh. */ ssr: boolean } /** The render state the server decodes from a `/render/...` address — the same * shape `render-mount`'s `stateFromUrl` reads on the client, so the server's * initial render and the client's hydration agree. */ interface ParsedRenderState extends RenderDoc { componentId: string caseId: string /** Values for every `render`-kind axis the substrate declares, defaults * filled in. For the DOM substrate this is `{ theme }`. */ variants: Record width: number | null tweaks: Record } function parseRenderState(url: URL, substrate: Substrate): ParsedRenderState { const parts = url.pathname.split('/').filter(Boolean) // ['render', comp, case] const p = url.searchParams const tweaks: Record = {} for (const [k, v] of p) if (k.startsWith('t.')) tweaks[k.slice(2)] = v const widthParam = p.get('width') return { componentId: parts[1] ?? '', caseId: parts[2] ?? '', theme: p.get('theme') === 'dark' ? 'dark' : 'light', variants: renderVariants(p, substrate), width: widthParam ? Number(widthParam) : null, tweaks, fit: p.get('fit') === '1', transparent: p.get('transparent') === '1', frame: undefined, ssr: false, } } /** The substrate-defined address options a `/render/...` request carries, in the * shape `substrate.document()` reads them back. */ function renderParams(state: ParsedRenderState): Record { const params: Record = {} if (state.fit) params.fit = '1' if (state.transparent) params.transparent = '1' if (state.width !== null) params.width = String(state.width) return params } /** * The isolated render document, produced by the active substrate. * * The document envelope is where a medium's assumptions live — the theme * signals on the root, the body surface, the mount, the script tag — so the * substrate owns all of it and the server contributes only what is genuinely * host-side: the resolved stylesheets, this component's bundle URL, and the * dev-only injects (the error overlay and, when watching, live reload). A * published build passes no host scripts and an importmap; that difference is * the *only* one between the two documents, which is why both now go through * this one call. */ function renderHtml( substrate: Substrate, config: DisplayCaseConfig, globalCss: string, vitrineCss: string, liveReload: boolean, state: ParsedRenderState, scriptSrc: string, ): string { return substrate.document(state.frame, { componentId: state.componentId, caseId: state.caseId, tweaks: state.tweaks, variants: state.variants, params: renderParams(state), config, scriptSrc, // The dev server serves each bundle whole; nothing is vendored out to a // shared chunk, so there is no importmap to resolve. importmap: {}, prerendered: state.ssr, hostScripts: `${ERROR_OVERLAY_SCRIPT}${liveReload ? LIVERELOAD_SCRIPT : ''}`, resources: { globalCss, vitrineCss }, }) } /** * A chrome-free diagnostic document served when a single component's bundle * fails to build. It names the offending component and its source file so the * failure is attributable (not a blank frame), and — like every `/render` * document — it is isolated: only this one case shows the error; every other * component still builds and serves. Carries no case script (the build that * would produce it is what failed). */ /** * A self-contained diagnostic served when the browse chrome (shell) bundle fails * or crashes the bundler — so the tool keeps running and explains the failure * instead of terminating with a bare native panic. Carries no chrome bundle (the * build that would produce it is what failed); only the optional live-reload * client so it refreshes once the chrome rebuilds. */ export function shellErrorHtml(error: string, liveReload: boolean): string { const esc = (s: string) => s.replace(/&/g, '&').replace(//g, '>') return `Display Case — chrome build failed
Display Case could not build its browse chrome.
The tool is still running, but the chrome bundle failed to build${error.includes('bundler crashed') ? ' (the bundler crashed)' : ''}. Fix the error below and save — the page reloads automatically.

${esc(error)}
${liveReload ? LIVERELOAD_SCRIPT : ''}` } function renderErrorHtml( globalCss: string, vitrineCss: string, liveReload: boolean, doc: { theme: Theme; componentId: string; caseFile: string; error: string }, signals: readonly ThemeSignal[], ): string { const esc = (s: string) => s.replace(/&/g, '&').replace(//g, '>') const banner = `
` + `Display Case build error
` + `Component ${esc(doc.componentId)} (${esc(doc.caseFile)}) ` + `could not be bundled, so this case can't be shown. Every other case still works.
` + `
${esc(doc.error)}
` const htmlAttrs = themeRootAttrs(resolveThemeSignals(doc.theme, signals)) return `Display Case build error
${banner}
${liveReload ? LIVERELOAD_SCRIPT : ''}` } function primerHtml( globalCss: string, tokensCss: string, vitrineCss: string, liveReload: boolean, doc: { theme: 'light' | 'dark' markup: string ssr: boolean headStyles?: string }, signals: readonly ThemeSignal[], ): string { // The Primer's own document. It needs the Vitrine `--dc-*` tokens (the // reading-page + Display-card chrome paints from them), the consumer's // globalCss (the embedded specimens are real consumer components), and the // Vitrine stylesheet (the specimen + card chrome CSS, inlined server-side so // it paints before scripts). A single
landmark keeps the a11y runner // honest. The theme is baked into so the first paint is correct; the // mount re-applies it (idempotent) and accepts later theme messages. // `data-ssr` tells the client whether to adopt the markup or mount fresh. // `color-scheme` matches the theme so user-agent surfaces (scrollbars, default // control chrome) follow it rather than rendering in their light defaults. const reset = `html,body{margin:0;height:100%;background:var(--dc-bg)}html{color-scheme:${doc.theme}}` const rootAttrs = ` data-ssr="${doc.ssr ? '1' : '0'}"` const htmlAttrs = themeRootAttrs(resolveThemeSignals(doc.theme, signals)) // Style-engine output follows the static ${doc.headStyles ?? ''}
${doc.markup}
${themeSignalsSeedScript(signals)}${ERROR_OVERLAY_SCRIPT}${liveReload ? LIVERELOAD_SCRIPT : ''}` } export interface StartOptions { port?: number /** * Developing Display Case *itself* (not just authoring cases). Enables live * reload: the served documents subscribe to an SSE stream, the watcher also * covers the app's own source (chrome, components, primer) and re-reads the * inlined CSS, and a rebuild pushes a browser reload — so editing the chrome * hot-reloads the open page. (We don't run under `bun --watch`: re-invoking * `Bun.build` inside a watch process corrupts module resolution. Backend edits * to this server need a manual restart; the client auto-reloads on the SSE * reconnect that follows.) Off for normal case authoring and for `check`. */ dev?: boolean } // Probe whether a port is bindable on localhost, without disturbing whatever // might currently hold it. const isPortFree = (port: number): Promise => new Promise((res) => { const srv = createServer() srv.once('error', () => res(false)) srv.once('listening', () => srv.close(() => res(true))) srv.listen(port, '127.0.0.1') }) // Treat a requested port as *preferred*: if it is busy — e.g. another git // worktree is already running Display Case on it — bump to the next free port so // concurrent checkouts don't fight over one port. Falls back to the original if // nothing nearby is free (Bun.serve then surfaces the bind error). const firstFreePort = async (start: number): Promise => { for (let p = start; p < start + 100; p++) { if (await isPortFree(p)) return p } return start } export async function startDisplayCase( pkgDir: string, opts: StartOptions = {}, ) { const dev = opts.dev ?? false // The headless check harness runs on port 0 and wants none of the live-server // behaviors (watch, SSE, on-demand a11y) — it does its own one-shot scan. const interactive = opts.port !== 0 const { config, configPath } = await resolveConfig(pkgDir) // The substrate every isolated render goes through — the DOM one unless the // showcase configures otherwise. Resolved once at start-up: it is a property // of the showcase, not of a request. const substrate = resolveSubstrate(config) // The effective theme root signals emitted on every delivered document, so a // showcased component reading any configured convention follows the theme. const themeSignals = effectiveThemeSignals(config) // The initial build runs in cold `bun` subprocesses (the shell worker + the // manifest); don't block the listen on it. If the port stayed closed until // they finished, several servers booting at once on a constrained CI runner // could blow Playwright's `webServer` readiness budget. Instead the server // starts listening first — so `/health` is reachable immediately — and every // other route awaits the `ready` promise built below. `startDisplayCase` still // awaits `ready` before returning, so callers get a fully-prepared server. // // `current` is the built state, set by the initial build and each rebuild; // it's optional because the server now listens before that first build lands. // Every consumer reaches it through `getState()` after `await ready`, which // throws loudly rather than letting an unbuilt read slip out as a render fault // (a definite-assignment `!` would silence exactly that check). let current: BuiltState | undefined const getState = (): BuiltState => { if (!current) throw new Error('server state read before the initial build completed') return current } // `let` so dev mode can re-read them when the chrome's CSS/tokens change. let vitrineCss = await readVitrineCss() let tokensCss = await readDesignTokens() const outdir = join(cacheDir(pkgDir), 'dist') // Live reload is the default for the interactive server (not just `--dev`): a // rebuild reloads the stage iframe and refetches the manifest. In `--dev` // (developing the chrome itself) the shell additionally does a full reload. const reload = interactive // Cases that threw under `renderToString` (browser-only). Once recorded, the // server skips the server-render attempt and serves an adopt-free document // that the client mounts. Cleared on rebuild so a fixed case recovers. const browserOnly = new Set() // Per-component on-demand bundle cache. Each component (one case file) is built // into its own browser + in-process SSR bundle the first time it is requested, // so the catalog's combined module graph is never built in a single bundler // pass — the precondition for the Bun-bundler crash on large showcases. Cleared // on rebuild so edits are picked up. A failed build is cached too (so it isn't // retried every request) and surfaced as a per-case diagnostic. type CaseEntry = | { ok: true renderCase: CaseRenderer browserUrl: string /** This component's module graph, so a file edit invalidates only the * components whose graph includes it (see scheduleRebuild). */ inputs: Set } | { ok: false; componentId: string; caseFile: string; error: string } const caseCache = new Map() // In-flight builds, so concurrent requests for the same component (e.g. the // a11y startup sweep) share one build instead of racing. const caseBuilding = new Map>() // Late-bound so `buildCase` can reconcile dependency watchers without a // temporal-dead-zone reference to `syncGraphWatchers` (defined after the // server). A no-op until wired up below. let onGraphGrew: () => Promise = async () => {} const buildCase = async (componentId: string): Promise => { const state = getState() const comp = state.manifest.components.find((c) => c.id === componentId) if (!comp) return null // unknown id → the chrome shows a not-found state const file = resolve(REPO_ROOT, comp.caseFile) const fail = (error: string): CaseEntry => ({ ok: false, componentId, caseFile: comp.caseFile, error, }) // Sequence-name the SSR bundle so Bun's resolved-path import cache returns the // current module after an edit (same reason the manifest is a subprocess). const seq = ++ssrBuildSeq try { // Build this one component in the worker child (browser → /dist/render-case // -.js, served by the `/dist/` handler; SSR → the cache). A build error // OR a native bundler crash (the worker dies on a signal) is contained here // and surfaced as a per-case diagnostic while every other component keeps // serving — the server never runs Bun.build itself. const result = await spawnBuild([ 'case', pkgDir, file, configPath, componentId, String(seq), ]) if (!result.ok) { console.warn( ` ⚠ ${componentId} ${result.crashed ? 'crashed the bundler' : 'could not be bundled'}; other cases keep serving`, ) return fail(result.error ?? 'bundling failed') } // Merge the component's module graph so the dev watcher follows its // source-resolved deps, then reconcile the dependency watchers. for (const f of result.inputs) state.inputs.add(f) await onGraphGrew() // The SSR bundle is on disk; importing it is module evaluation (safe — only // *bundling* the large graph crashes Bun, and that happens in the worker). const ssrPath = join( cacheDir(pkgDir), 'ssr', `ssr-case-${componentId}-${seq}.js`, ) const mod = (await import(ssrPath)) as { renderCaseToHtml: CaseRenderer } // Reclaim this component's superseded SSR bundle/codegen seqs now that the // current one is imported — they accumulate on disk on every rebuild. await pruneCaseSsrSeq(pkgDir, componentId, seq) return { ok: true, renderCase: mod.renderCaseToHtml, browserUrl: `/dist/render-case-${componentId}.js`, inputs: new Set(result.inputs), } } catch (err) { return fail( err instanceof Error ? (err.stack ?? err.message) : String(err), ) } } const ensureCase = async (componentId: string): Promise => { const cached = caseCache.get(componentId) if (cached) return cached const inflight = caseBuilding.get(componentId) if (inflight) return inflight const p = buildCase(componentId) .then((entry) => { if (entry) caseCache.set(componentId, entry) return entry }) .finally(() => caseBuilding.delete(componentId)) caseBuilding.set(componentId, p) return p } // SSE fan-out: open streams (one per browser tab) that a rebuild pushes a // `reload` event to, and that completed a11y scans push an `a11y` event to. const encoder = new TextEncoder() const reloadClients = new Set() const broadcast = (chunk: Uint8Array) => { for (const c of reloadClients) { try { c.enqueue(chunk) } catch { reloadClients.delete(c) } } } // A rebuild reloads the open tabs, but how depends on *what* changed: a change // to the shell bundle itself (the chrome — its layout, the design-system // components it composes) needs a full page reload, while a change that only // affects rendered case/component content can reload just the stage iframe and // refetch the manifest, preserving nav state. The event payload tells the // client which; we detect a shell change by hashing the browser-entry bundle. const shellBundleHash = async (): Promise => { const f = Bun.file(join(outdir, 'browser-entry.js')) return (await f.exists()) ? Bun.hash(await f.arrayBuffer()).toString(16) : '' } let shellHash = '' const triggerReload = (kind: 'shell' | 'content') => broadcast(encoder.encode(`event: reload\ndata: ${kind}\n\n`)) // On-demand a11y scanner (only when configured + interactive). Completed scans // are pushed to open browsers over the SSE stream so the panel updates in place. let scanner: A11yScanner | null = null // Latest known verdict per `${component}__${case}__${theme}`, recorded as // results flow through `onResult`. SSE only reaches tabs open at emit time, so // start-up population (and any earlier scan) would be invisible to a tab that // connects later; `/a11y/known` replays these so a fresh client seeds its nav. const lastA11y = new Map< string, { component: string case: string theme: 'light' | 'dark' } & A11yScanStatus >() // Port 0 (the headless check harness) means "let the OS pick" — leave it. Any // other port is preferred-not-mandatory, so two worktrees never collide. const port = opts.port === 0 ? 0 : await firstFreePort(opts.port ?? 3100) // The initial build, kicked off here so it runs concurrently with the listen // instead of blocking it (see the note at `current`). Created with no `await` // between it and the `await ready` after `Bun.serve` below, so its rejection // always has a waiter — never a floating promise. Sequential inside: the // shell-bundle hash reads what the build just wrote. const ready = (async () => { // Wipe a prior session's stale seq-named SSR bundles before the first build — // `ssrBuildSeq` resets to 0 on restart, so they'd otherwise linger forever. await sweepStaleSsr(pkgDir) current = await rebuild(pkgDir, config, configPath) shellHash = await shellBundleHash() })() const server = Bun.serve({ port, // The `/__livereload` SSE stream is long-lived; the default 10s idle timeout // would close it and churn reconnects. Disable it for the interactive server. // (0 = no timeout.) The check harness (non-interactive) keeps the default. idleTimeout: interactive ? 0 : 10, async fetch(req) { const url = new URL(req.url) const path = url.pathname // The one liveness signal that must answer before the initial build // finishes — it's what makes the server reachable while the cold build // subprocesses run. Every route below needs the prepared state, so it // waits for the build to complete here. if (path === '/health') return new Response('ok') await ready const state = getState() if (interactive && path === '/__livereload') { let self: ReadableStreamDefaultController | null = null const stream = new ReadableStream({ start(controller) { self = controller reloadClients.add(controller) controller.enqueue(encoder.encode(': connected\n\n')) }, cancel() { if (self) reloadClients.delete(self) }, }) return new Response(stream, { headers: { 'content-type': 'text/event-stream', 'cache-control': 'no-cache', connection: 'keep-alive', }, }) } if (path === '/manifest.json') { return Response.json(state.manifest) } // Every verdict known so far (start-up population + completed scans), so a // client connecting after those SSE events still seeds its nav markers. if (scanner && path === '/a11y/known') { return Response.json([...lastA11y.values()]) } // On-demand a11y for the viewed variant: cached → result, miss → enqueue a // scan and report `pending` (the result later arrives over the SSE stream). if (scanner && path === '/a11y') { const component = url.searchParams.get('component') const caseId = url.searchParams.get('case') const theme = url.searchParams.get('theme') if (!component || !caseId || (theme !== 'light' && theme !== 'dark')) { return new Response('bad request', { status: 400 }) } const force = url.searchParams.get('rescan') === '1' return Response.json( await scanner.request(component, caseId, theme, force), ) } if (path.startsWith('/dist/')) { const abs = resolve(outdir, path.slice('/dist/'.length)) // Defense-in-depth: never serve outside the dist dir even if a crafted // path slips a `..` past `URL` normalization. if (abs !== outdir && !abs.startsWith(outdir + sep)) { return new Response('not found', { status: 404 }) } const file = Bun.file(abs) return (await file.exists()) ? new Response(file) : new Response('not found', { status: 404 }) } if (path.startsWith('/doc/')) { const id = path.slice('/doc/'.length) const docPath = state.placardById.get(id) if (!docPath) return new Response('no doc', { status: 404 }) return new Response(Bun.file(docPath), { headers: { 'content-type': 'text/markdown; charset=utf-8' }, }) } // The Primer's chrome-free document lives under the reserved // `/render/primer` name — a sibling of the other `/render/*` snapshots, // not the SPA's `/primer` browse route (handled by the shell fallthrough // below). Matched before the generic `/render/*` so it's served as the // Primer, never mistaken for a component render of an id `primer`. if (path === '/render/primer') { const scanning = url.searchParams.has('dcscan') const theme = url.searchParams.get('theme') === 'dark' ? 'dark' : 'light' // Pre-render the primer (prose + live specimens) into the document so it // reads without scripting. A browser-only specimen makes the renderer // report `browserOnly`; the whole primer then falls back to client // rendering (delivered empty for the client to mount). let markup = '' let ssr = false let headStyles: string | undefined if (state.renderPrimer) { const result = state.renderPrimer() if (result.browserOnly) { console.warn( ` ⚠ primer can't render server-side (${result.error ?? 'threw'}); the client will render it`, ) } else { markup = result.html ssr = true headStyles = result.headStyles } } return new Response( primerHtml( state.globalCss, tokensCss, vitrineCss, reload && !scanning, { theme, markup, ssr, headStyles, }, themeSignals, ), { headers: { 'content-type': 'text/html; charset=utf-8' }, }, ) } if (path === '/render' || path.startsWith('/render/')) { // The a11y scanner appends `?dcscan=1` and waits for network idle, which // an open live-reload SSE would never reach — so omit it for that fetch. const scanning = url.searchParams.has('dcscan') const rs = parseRenderState(url, substrate) const key = `${rs.componentId}/${rs.caseId}` // Build this component's bundle on demand (cached). Each component is its // own small bundler pass, so the catalog's combined graph is never built // at once — what crashes Bun's bundler at scale. const built = rs.componentId ? await ensureCase(rs.componentId) : null if (built && !built.ok) { // The build failed: serve a chrome-free diagnostic for this case alone. // Every other component still builds and serves — one bad case can't // take down the whole showcase. return new Response( renderErrorHtml( state.globalCss, vitrineCss, reload && !scanning, { theme: rs.theme, componentId: built.componentId, caseFile: built.caseFile, error: built.error, }, themeSignals, ), { headers: { 'content-type': 'text/html; charset=utf-8' } }, ) } // Pre-render the case into a frame, baked into the document so its // content is present before the page's scripts run. A browser-only case // (or one already recorded as such) is served adopt-free for the stage // runtime to paint. if (built?.ok && rs.caseId && !browserOnly.has(key)) { const result = await built.renderCase( { componentId: rs.componentId, caseId: rs.caseId, width: rs.width, tweaks: rs.tweaks, }, rs.variants, renderParams(rs), ) if (result.browserOnly) { browserOnly.add(key) console.warn( ` ⚠ ${key} can't render server-side (${result.error ?? 'threw'}); the client will render it`, ) } else { rs.frame = result.frame rs.ssr = true } } // Nothing was pre-rendered (an unbuilt component, an unknown id, or a // case that needs the client): ask the substrate for its own empty // frame rather than fabricating one, so the document stays entirely the // substrate's to shape. if (!rs.ssr) { rs.frame = await substrate.render(null, { componentId: rs.componentId, caseId: rs.caseId, tweaks: rs.tweaks, variants: rs.variants, params: renderParams(rs), clientOnly: true, config, }) } // The per-component bundle (or a harmless fallback for an unknown id, which // renders nothing — the chrome shows a not-found state). const scriptSrc = built?.ok ? built.browserUrl : `/dist/render-case-${rs.componentId}.js` return new Response( renderHtml( substrate, config, state.globalCss, vitrineCss, reload && !scanning, rs, scriptSrc, ), { headers: { 'content-type': 'text/html; charset=utf-8' }, }, ) } // The chrome bundle failed or crashed the bundler: serve a self-contained // diagnostic instead of the browse UI (whose bundle is missing), so the tool // keeps running and explains the failure rather than terminating. if (state.shellError) { return new Response(shellErrorHtml(state.shellError, reload && dev), { status: 500, headers: { 'content-type': 'text/html; charset=utf-8' }, }) } // Shell handles `/`, `/primer`, and all `/c/...` + `/e/...` browse routes. The server // pre-renders the shell from the in-memory manifest + this request's route // so the landing surface and every deep link arrive painted; the client // adopts it. The shell does a full reload only in `--dev` (chrome may have // changed); the runtime config drives the non-dev iframe + manifest refresh. const theme: Theme = url.searchParams.get('theme') === 'dark' ? 'dark' : 'light' const a11y = scanner !== null const shell = renderShellToHtml({ manifest: state.manifest, pathname: path, search: url.search, theme, a11y, }) const seedScript = `` return new Response( shellHtml( state.manifest.title, state.globalCss, vitrineCss, tokensCss, dev, interactive ? clientConfigScript({ reload, a11y, dev }) : '', { theme, markup: shell.html, ssr: shell.ssr, seedScript }, themeSignals, ), { headers: { 'content-type': 'text/html; charset=utf-8' }, }, ) }, // Bun already isolates a handler throw into a 500 rather than crashing the // server; this makes that response controlled — a sanitized body (never a // stack) and a server-side log — instead of Bun's default error page. error(err) { console.error('[display-case] request handler error:', err) return new Response('Internal Server Error', { status: 500, headers: { 'content-type': 'text/plain; charset=utf-8' }, }) }, }) // The socket is open and `/health` answers now; wait for the initial build // before wiring the build-dependent machinery (the a11y start-up sweep, the // dependency watchers) and before returning a fully-prepared server. A // captured build failure is kept in `current.shellError` and does not reject; a // catastrophic throw does — close the socket rather than leak a live server. try { await ready } catch (err) { server.stop(true) throw err } // Build the on-demand scanner now that the server has a URL to scan against. if (interactive && config.a11y?.enabled) { const base = String(server.url).replace(/\/$/, '') scanner = createA11yScanner({ pkgDir, config, baseUrl: () => base, caseFileAbs: (id) => { const state = getState() const c = state.manifest.components.find((x) => x.id === id) return c ? resolve(REPO_ROOT, c.caseFile) : null }, onResult: (component, caseId, theme, status) => { // Remember the latest verdict so late-joining tabs can replay it, then // push it to the tabs already listening. lastA11y.set(`${component}__${caseId}__${theme}`, { component, case: caseId, theme, ...status, }) broadcast( encoder.encode( `event: a11y\ndata: ${JSON.stringify({ component, case: caseId, theme, ...status })}\n\n`, ), ) }, }) // Populate the nav at start-up per the configured mode (default 'off' — a // no-op). Detached: scanning must never delay the server becoming reachable, // and `refresh` work rides the scanner's own bounded queue. const startupMode = config.a11y?.startup ?? 'off' if (startupMode !== 'off') { const themes = config.a11y?.themes ?? ['light', 'dark'] const state = getState() const variants = state.manifest.components.flatMap((c) => c.cases.flatMap((cs) => themes.map((theme) => ({ componentId: c.id, caseId: cs.id, theme, })), ), ) void scanner.populateAtStartup(variants, startupMode).catch(() => {}) } } // Debounced rebuild. Refreshes the manifest + bundle, drops the a11y cache's // in-flight bookkeeping (the on-disk hashes still decide what re-scans), and — // when reload is on — pushes a reload so the iframe + manifest refresh. In dev // it also re-reads the inlined chrome CSS/tokens (the shell full-reloads). // Absolute paths changed since the last rebuild, accumulated across the debounce // window so the rebuild invalidates only the components whose graph includes one. const pendingChanges = new Set() let timer: ReturnType | null = null // A rebuild is async and (on a large catalog) slow. The 150ms debounce in // `scheduleRebuild` only coalesces a burst that arrives *before* a rebuild // starts; the *concurrent* case — a change arriving mid-rebuild — is handled by // `createCoalescingRunner`, which loops the pass once more rather than starting // a second rebuild that would race on `state`, `ssrBuildSeq`, and the case cache. const runRebuild = createCoalescingRunner( async (label: string) => { console.log(`↻ ${label}, rebuilding…`) if (dev) { vitrineCss = await readVitrineCss() tokensCss = await readDesignTokens() } const changed = [...pendingChanges] pendingChanges.clear() // Reuse the surfaces this change can't have touched (the chrome and/or the // manifest); only the affected case bundles are invalidated below. current = await rebuild(pkgDir, config, configPath, { changed, prev: current, }) browserOnly.clear() // Invalidate only the components whose graph includes a changed file (and // every failed entry, so a fix is retried) — not the whole cache — so an // edit doesn't force every other component to rebuild on its next visit. // No specific paths (a conservative fallback) drops everything. for (const id of staleCaseIds(caseCache, changed)) caseCache.delete(id) scanner?.invalidateAll() // The module graph may have shifted (a new sibling import, or one // dropped) — reconcile the dependency watchers against it. await syncGraphWatchers() if (reload) { // Full-reload the tab when the chrome bundle changed; otherwise just // refresh the rendered content (iframe + manifest), keeping nav state. const nextHash = await shellBundleHash() const kind = nextHash !== shellHash ? 'shell' : 'content' shellHash = nextHash triggerReload(kind) } }, (err) => console.error(err), ) const scheduleRebuild = (label: string, paths: string[] = []) => { for (const p of paths) pendingChanges.add(p) if (timer) clearTimeout(timer) timer = setTimeout(() => void runRebuild(label), 150) } // Watch the consumer package's source. Any app-relevant source change rebuilds // — component implementations and styles, not just case/doc/primer files — so // editing a component hot-reloads its rendered case (and re-evaluates a11y). // // We watch via @parcel/watcher (native FSEvents / inotify / ReadDirectoryChangesW) // rather than node's `fs.watch`: recursive `fs.watch` on macOS drops events for // atomic/rename writes (most editor saves) and coalesces rapid changes, so an // edit would silently fail to rebuild and the open page would serve stale code. // @parcel/watcher delivers reliable, absolute-path events across platforms. const srcDir = join(pkgDir, 'src') const watchSrc = interactive && existsSync(srcDir) const watchHere = dev && resolve(srcDir) !== resolve(HERE) // Load the native watcher only on the paths that actually watch — `check` and // the per-rebuild `--print-manifest` subprocess import this module too, and // shouldn't pay to dlopen the binding. Interactive servers always watch (the // target src plus the bundle's dependency graph; see syncGraphWatchers). const { subscribe } = watchSrc || watchHere || interactive ? await import('@parcel/watcher') : { subscribe: undefined } const watched = /\.(tsx?|css|mdx)$|\.placard\.md$/ const ignore = ['node_modules', '.git', '.display-case', 'dist'] // Top-level watcher subscriptions, captured so shutdown can unsubscribe them // (the native @parcel/watcher subscriptions are not reclaimed on process exit). const topSubs: Array<{ unsubscribe(): Promise }> = [] if (subscribe && watchSrc) { topSubs.push( await subscribe( srcDir, (err, events) => { if (err) return const hits = events.filter((e) => watched.test(e.path)) if (hits.length) scheduleRebuild( 'change detected', hits.map((e) => e.path), ) }, { ignore }, ), ) } // Dev, showcasing a *different* package: also watch Display Case's own UI // source so editing the chrome hot-reloads even when `pkgDir` is elsewhere. if (subscribe && watchHere) { topSubs.push( await subscribe( HERE, (err, events) => { if (err) return const hits = events.filter((e) => /\.(tsx?|css)$/.test(e.path)) if (hits.length) scheduleRebuild( 'app source changed', hits.map((e) => e.path), ) }, { ignore }, ), ) } // Active dependency-graph subscriptions, keyed by watched dir, reconciled // against the current build's graph after each rebuild. The watch set is // derived from the bundle's real module graph (see graphWatchDirs) — this is // what picks up a workspace sibling resolved to source, so editing it rebuilds // instead of silently serving stale code. const graphWatchers = new Map }>() // The target's own workspace root — bounds the dependency watch. `REPO_ROOT` // tracks where Display Case itself lives (used for manifest-relative paths), // which is a different repo entirely when the tool is installed as a dep. const watchRoot = findWatchRoot(pkgDir) const syncGraphWatchers = async (): Promise => { if (!subscribe || !interactive) return const state = getState() const want = graphWatchDirs(state.inputs, { srcDir, hereDir: HERE, repoRoot: watchRoot, }) for (const dir of want) { if (graphWatchers.has(dir)) continue const sub = await subscribe( dir, (err, events) => { if (err) return const hits = events.filter((e) => watched.test(e.path)) if (hits.length) scheduleRebuild( 'dependency change detected', hits.map((e) => e.path), ) }, { ignore }, ) graphWatchers.set(dir, sub) } for (const [dir, sub] of graphWatchers) { if (!want.has(dir)) { await sub.unsubscribe() graphWatchers.delete(dir) } } } // Wire the on-demand case builder's graph-grew hook to the real reconciler now // that it exists (declared late to avoid a temporal-dead-zone reference). onGraphGrew = syncGraphWatchers // Initialize the dependency watchers from the first build's graph, then keep // them reconciled after every rebuild (the call in scheduleRebuild). await syncGraphWatchers() // Graceful teardown for the *interactive* server (the non-interactive check // harness owns its own lifecycle in check.ts). Without this, a Ctrl-C leaks the // persistent a11y browser (Chromium), the native file-watcher subscriptions, // and any in-flight build-worker children. Registered once and idempotent. let disposed = false const dispose = async (): Promise => { if (disposed) return disposed = true if (timer) clearTimeout(timer) if (scanner) await scanner.close().catch(() => {}) await Promise.all( [...topSubs, ...graphWatchers.values()].map((s) => s.unsubscribe().catch(() => {}), ), ) killActiveBuildWorkers() server.stop(true) } if (interactive) { const onSignal = () => { void dispose().finally(() => process.exit(0)) } process.once('SIGINT', onSignal) process.once('SIGTERM', onSignal) } return server } /** * Build the manifest once and return it (used by `--print-manifest`, and by the * dev server's per-rebuild subprocess). Load errors are written to stderr so the * JSON stays the sole thing on stdout; a spawning parent can relay them. */ export async function getManifest(pkgDir: string): Promise { const { config } = await resolveConfig(pkgDir) const files = await discoverCaseFiles(pkgDir, config) const { modules, errors } = await loadModules(files) for (const e of errors) console.error(` ✗ ${relPath(e.file)}: ${e.error}`) const hasPrimer = primerFile(pkgDir, config) !== null return buildManifest(pkgDir, modules, config, hasPrimer).manifest } /** A per-component cache entry, reduced to what {@link staleCaseIds} reasons * about: whether it built, and (if so) the files its bundle read. */ export type Invalidatable = { ok: true; inputs: Set } | { ok: false } /** * Given the cached components and the absolute paths that changed since the last * rebuild, return the component ids to drop from the cache: every component whose * recorded input graph includes a changed file (its bundle is stale), plus every * failed entry (so a fix is retried on next visit). With no changed paths — a * conservative fallback — everything is invalidated. Pure so the graph-aware * invalidation is tested deterministically, without the OS file watcher. */ export function staleCaseIds( cache: ReadonlyMap, changed: readonly string[], ): Set { if (changed.length === 0) return new Set(cache.keys()) const ids = new Set() for (const [id, entry] of cache) { if (!entry.ok || changed.some((p) => entry.inputs.has(p))) ids.add(id) } return ids } export { slugify }