import { basename as pathBasename } from "node:path" import type { ClientModuleGraph } from "./module-graph.ts" export interface BuildManifest { /** URL of the client entry module (content-hashed). */ readonly entry: string /** URLs of every emitted asset (entry + chunks) - for serving + preloading. */ readonly assets: readonly string[] /** `routeId → [layout chunk URLs…, own chunk URL]` - the chunks a route needs, for `createWebApp`'s * `routePreload` (`` the matched route alongside the entry). Each route + * layout is also a build entrypoint, so it gets a named chunk the bootstrap's lazy import dedupes to. */ readonly routes: Readonly> /** URL paths copied from `publicDir`, sorted. Lets the server entry serve them without scanning a * directory per request, and lets an adapter that needs a file list (CDN upload, platform static * assets) consume one. Omitted when there is no `public/`. */ readonly publicFiles?: readonly string[] /** The app's bundled, content-hashed stylesheet(s) - the bootstrap's **aggregate** CSS (every * `import './x.css'` reachable from the app). The complete stylesheet regardless of which file * imported the CSS; the always-safe fallback `createWebApp` links when a route has no per-route entry * in {@link routeStyles}. Omitted when the app imports no CSS. */ readonly css?: readonly string[] /** `routeId → [chain CSS URLs]` - only the stylesheets the matched route's layout chain + own file * actually use (Bun emits a per-entrypoint CSS bundle per route/layout, with shared-component CSS * inlined into each consumer). `createWebApp` links these instead of the aggregate, so a page ships * only its own CSS. A route is omitted (→ aggregate fallback) when its `[name]` collides with another * route's basename (ambiguous CSS↔route) or the build emitted orphan shared-chunk CSS - correctness * over minimality. Absent entirely when the app imports no CSS. */ readonly routeStyles?: Readonly> } /** The built worker bundle - point your `wrangler.toml`'s `main` at `worker`. */ export interface ServerBuild { /** Path to the bundled, self-contained worker entry. */ readonly worker: string /** Paths of every emitted output (entry + any code-split chunks) - what to ship to the platform. */ readonly outputs: readonly string[] } // =================================================================================================== // `nifra build --target` - package the engine above into one command that emits a full deploy dir. // // An app already declares everything the build needs through nifra's conventions: `adapter` + // `clientModule` (nifra.config.ts / framework.ts), an optional `backend` (backend.ts), and `routes/`. // The ONLY thing apps used to hand-write per target was the server entry (`_worker.ts`, `server-bun.ts`, // …) - so we GENERATE it here (per target) instead of asking each app to ship five near-identical files. // =================================================================================================== /** A deploy target `nifra build --target ` can emit. `static` is pure SSG (no server). */ export const BUILD_TARGETS = ["bun", "node", "deno", "cf-pages", "vercel", "static"] as const export type BuildTarget = (typeof BUILD_TARGETS)[number] /** A type guard narrowing an arbitrary string to a {@link BuildTarget}. */ export function isBuildTarget(value: string): value is BuildTarget { return (BUILD_TARGETS as readonly string[]).includes(value) } export type ServerBuildTarget = "browser" | "node" | "bun" export interface StaticBuildTargetPlan { readonly target: "static" readonly kind: "static" readonly serverTarget: undefined readonly outputFile: undefined readonly run: string } export interface ServerBuildTargetPlan { readonly target: Exclude readonly kind: "server" readonly serverTarget: ServerBuildTarget /** The worker's final filename inside the assembled deploy directory. */ readonly outputFile: "_worker.js" | "index.js" | "server.js" readonly run: string } export type BuildTargetPlan = StaticBuildTargetPlan | ServerBuildTargetPlan const finalPathSegment = (path: string): string => { // Do not use a repeated, unanchored regex here: a separator-heavy path followed by a // non-separator can make the regex engine retry every possible start position (ReDoS). let end = path.length while (end > 0) { const code = path.charCodeAt(end - 1) if (code !== 0x2f && code !== 0x5c) break end -= 1 } const trimmed = path.slice(0, end) const slash = Math.max(trimmed.lastIndexOf("/"), trimmed.lastIndexOf("\\")) return slash === -1 ? trimmed : trimmed.slice(slash + 1) } /** * Resolve the target-specific deploy shape before any bundling starts. Keeping this decision pure * means Bun and Vite strategies share the same output filename, server target, and hand-off text; * the filesystem emitter only has to execute the plan. */ export function planBuildTarget(target: BuildTarget, outDir: string): BuildTargetPlan { if (target === "static") { return { target, kind: "static", serverTarget: undefined, outputFile: undefined, run: `static site → ${outDir} (serve the directory with any static host)`, } } if (target === "cf-pages") { return { target, kind: "server", serverTarget: "browser", outputFile: "_worker.js", run: `Cloudflare Pages → ${outDir} (deploy: wrangler pages deploy ${finalPathSegment(outDir)})`, } } if (target === "vercel") { return { target, kind: "server", serverTarget: "browser", outputFile: "index.js", run: `Vercel edge function → ${outDir}/index.js (wrap with your vercel.json or Build Output API)`, } } return { target, kind: "server", serverTarget: target === "node" ? "node" : target === "bun" ? "bun" : "browser", outputFile: "server.js", run: `${target} server → ${outDir} (run: ${target === "node" ? "node" : target} ${finalPathSegment(outDir)}/server.js)`, } } /** * A build-tool STRATEGY for {@link buildTargetWith} - the two bundling steps, and nothing else. Everything * around them (server-entry codegen, deploy assembly, prerender, size report) is bundler-agnostic and * lives in `buildTargetWith`, so a second bundler (Vite) is this interface, not a second orchestrator. * * Plugin lists are `readonly unknown[]` because a Bun plugin and a Vite plugin are different types; each * strategy casts to its own. `buildTargetWith` only forwards them. */ export interface Bundler { /** Build the client bundle → the shared {@link BuildManifest}. */ buildClient(input: { readonly routesDir: string readonly outDir: string readonly clientModule: string readonly plugins?: readonly unknown[] readonly conditions?: readonly string[] readonly define?: Readonly> readonly publicDir?: string | false readonly publicEnvPrefix?: string /** Project root (Vite needs it; the Bun strategy ignores it). */ readonly root?: string }): Promise /** Build the server worker → the shared {@link ServerBuild}. */ buildServer(input: { readonly routesDir: string readonly serverEntry: string readonly outDir: string readonly clientEntry: string readonly target: "browser" | "node" | "bun" readonly plugins?: readonly unknown[] readonly define?: Readonly> readonly root?: string }): Promise } interface GraphImport { readonly path?: string readonly original?: string } interface GraphInput { readonly imports?: readonly GraphImport[] } const basename = (path: string): string => pathBasename(path) const nodeBuiltinOf = (im: GraphImport): string | undefined => { if (im.original?.startsWith("node:")) return im.original if (im.path?.startsWith("node:")) return im.path return undefined } /** One `node:`-builtin-in-the-client finding: the offending builtin, the emitted chunk it landed in, * and the shortest USER-module import chain that pulled it there (entry → … → builtin). */ export interface NodeBuiltinFinding { readonly builtin: string readonly chunk: string /** The shortest import path from a user entry to the builtin, as a list of display labels: * `[entryFile, ...as-written specifiers along the way, builtin]`, e.g. * `["routes/article/[slug].tsx", "../data.ts", "../db/client.ts", "postgres", "node:tls"]`. The * entry is its graph key (the route file); each hop is the import's *as-written* specifier; the tail * is the builtin. Empty only if the builtin module isn't reachable from any traced input (it always * is when flagged). */ readonly chain: readonly string[] } /** * BFS the metafile import graph for the SHORTEST user-module path that pulls `builtin` into the * bundle, returning it as display labels `[entryFile, …as-written specifiers…, builtin]`. The frontier * starts at every `entryInput` (the route/user entrypoints) so the reported chain begins where the dev * actually wrote `import` - the actionable root, not an arbitrary internal module. Traversal crosses * only NON-`node:` edges (so Bun's polyfill chain never extends the path) and stops at the first edge * whose target is the builtin. Returns `[builtin]` if no entry reaches it (defensive; a flagged builtin * is reachable by construction). Pure - operates on the graph, never the emitted text. */ function shortestBuiltinChain( inputs: Readonly>, entryInputs: readonly string[], builtin: string, ): string[] { const seen = new Set(entryInputs) let frontier = entryInputs.map((node) => ({ node, chain: [node] })) while (frontier.length > 0) { const next: Array<{ node: string; chain: string[] }> = [] for (const { node, chain } of frontier) { for (const im of inputs[node]?.imports ?? []) { // The builtin reached via THIS edge → the chain ends here (append the builtin label). Match on // the same `node:` detection the finding used (`original` first, then resolved `path`). if (nodeBuiltinOf(im) === builtin) return [...chain, builtin] const target = im.path // Only follow edges into user (non-`node:`) modules that exist in the input graph and haven't // been visited - so the polyfill subtree can't lengthen the path and there are no cycles. if ( target === undefined || target.startsWith("node:") || inputs[target] === undefined || seen.has(target) ) { continue } seen.add(target) // Display the hop by its as-written specifier (`../db/client.ts`, `postgres`), falling back to // the resolved path - that's what the dev recognizes in their source, not the resolved path. next.push({ node: target, chain: [...chain, im.original ?? target] }) } } frontier = next } return [builtin] } /** * Scan a build's metafile for any `node:` builtin that a USER module pulled into a CLIENT output * chunk, returning a sorted, deduped list of {@link NodeBuiltinFinding}s. Three graph facts combine so * the report is precise AND actionable: * 1. **What the user wrote** - only builtins imported by a NON-`node:` input count, so Bun's own * polyfill chain (`node:crypto` → `node:buffer`/`node:stream`/…) doesn't bury the real cause. * 2. **Where it landed** - the chunk is read from the per-output `inputs`, so the error names the * emitted file to look at. * 3. **How it got there** - the shortest import chain from a user entry to the builtin * (`shortestBuiltinChain`), so the error points straight at the offending `import` line instead of * leaving the dev to grep the dependency tree (the DX gap this closes). * Graph-based (never the emitted text), so it survives minification and can't be fooled by a string * literal that merely contains `"node:crypto"`. Pure + exported for unit testing. Empty ⇒ clean. */ export function detectNodeBuiltinsInClient( graph: ClientModuleGraph, ): ReadonlyArray { const inputs = graph.modules // (1) The builtins a user (non-polyfill) module imports directly - the ones the author controls. const userImported = new Set() for (const [inputKey, input] of Object.entries(inputs)) { if (inputKey.startsWith("node:")) continue // a polyfill importing another builtin - not the cause for (const im of input.imports ?? []) { const builtin = nodeBuiltinOf(im) if (builtin !== undefined) userImported.add(builtin) } } // The entry inputs (route/user entrypoints) - the chain BFS starts here so the reported path begins // at the file the dev wrote an `import` in. Each output's `entryPoint` is one such input. const entryInputs = [ ...new Set( Object.values(graph.chunks) .map((output) => output.entryPoint) .filter((entry): entry is string => entry !== undefined && !entry.startsWith("node:")), ), ] // The chain per builtin is independent of the chunk, so compute it once per builtin (memoized). const chains = new Map() const chainFor = (builtin: string): readonly string[] => { const cached = chains.get(builtin) if (cached !== undefined) return cached const chain = shortestBuiltinChain(inputs, entryInputs, builtin) chains.set(builtin, chain) return chain } // (2) Locate which emitted chunk each user-imported builtin reached, via the per-output `inputs`. const findings = new Map() for (const [outputPath, output] of Object.entries(graph.chunks)) { for (const inputKey of output.modules) { if (!userImported.has(inputKey)) continue const chunk = basename(outputPath) findings.set(`${inputKey}\0${chunk}`, { builtin: inputKey, chunk, chain: chainFor(inputKey), }) } } return [...findings.values()].sort((a, b) => a.builtin === b.builtin ? a.chunk.localeCompare(b.chunk) : a.builtin.localeCompare(b.builtin), ) } // --------------------------------------------------------------------------------------------------- // `server-only` poison-import guard (§3.3/§5.1). The complement to the `.server` convention + the // node-builtin guard: a module of PURE server logic with NO `node:` import (a secret-bearing constant, // a server-only API call) that an author wants to FAIL LOUD if it reaches the client opts in with a // side-effect `import "@nifrajs/web/server-only"` (Next's `import "server-only"`). On the SERVER build // the marker is an empty no-op; the CLIENT build detects - via the SAME Bun metafile graph the // node-builtin guard walks - any module that imports the marker AND lands in a client chunk, and fails // the build with the import chain. Graph-based (never the emitted text), so it survives minification. // --------------------------------------------------------------------------------------------------- /** The marker specifier an author imports to opt a module into the client-leak guard. Matched on the * import edge's *as-written* `original` first (the robust signal: it's exactly what the author typed, * before Bun resolves it to `src/server-only.ts` / `dist/server-only.js`). */ export const SERVER_ONLY_MARKER = "@nifrajs/web/server-only" const isServerOnlyMarkerImport = (im: GraphImport): boolean => { if (im.original === SERVER_ONLY_MARKER) return true return im.path !== undefined && /(^|\/)server-only\.[cm]?[jt]s$/.test(im.path) } /** True when an INPUT graph key is the marker module file itself (`…/server-only.{ts,js}` under web). * Excluded from the "marked" set - the marker is the import target, not an opt-in module. */ const isServerOnlyMarkerModule = (inputKey: string): boolean => /(^|\/)server-only\.[cm]?[jt]s$/.test(inputKey) /** One `server-only`-module-in-the-client finding: the offending module (the as-written marker-import * chain's tail before the marker), the emitted chunk it landed in, and the shortest USER-module import * chain that pulled it there (entry → … → the server-only module). */ export interface ServerOnlyFinding { /** The emitted client chunk the server-only module landed in (basename). */ readonly chunk: string /** The shortest import path from a user entry to the server-only module, as display labels: * `[entryFile, ...as-written specifiers…, " (marked server-only)"]`. Mirrors * {@link NodeBuiltinFinding.chain}; the tail names the marked module so the message reads * `routes/x.tsx → ../secrets.ts (marked server-only)`. */ readonly chain: readonly string[] } /** * Build a resolver from an import EDGE to its INPUT-GRAPH KEY. Bun's metafile records edge `path`s as * ABSOLUTE paths but keys `inputs` by CWD-RELATIVE paths, so a raw `inputs[im.path]` lookup misses * every user edge in a real build. The resolver: (1) an exact key match (the synthetic-metafile / unit * case); else (2) the input key the absolute path ends with (`…/secrets.ts` → `packages/web/.../secrets.ts`). * The longest-suffix match is taken so a shorter key can't shadow a more specific one. Pure. */ function inputKeyResolver( inputs: Readonly>, ): (im: GraphImport) => string | undefined { const keys = Object.keys(inputs) return (im) => { const path = im.path if (path === undefined) return undefined if (inputs[path] !== undefined) return path let best: string | undefined for (const key of keys) { if (path.endsWith(`/${key}`) && (best === undefined || key.length > best.length)) best = key } return best } } /** * BFS the metafile import graph for the SHORTEST user-module path that reaches a module which imports * the `server-only` marker, returning it as display labels `[entryFile, …as-written specifiers…, * " (marked server-only)"]`. Mirrors {@link shortestBuiltinChain}: the frontier starts at * every `entryInput` so the chain begins at the file the dev wrote an `import` in; traversal crosses * only NON-`node:` user edges (no cycles via `seen`); it stops at the first node whose import set * contains the marker (the marked module - the actionable tail), labelling that node by the as-written * specifier the previous hop used to reach it. Pure - operates on the graph, never the emitted text. */ function shortestServerOnlyChain( inputs: Readonly>, entryInputs: readonly string[], markedModule: string, resolveTarget: (im: GraphImport) => string | undefined, ): string[] { // The label for the marked module's tail: its as-written specifier (filled when we cross the edge // that reaches it) suffixed with `(marked server-only)`; the entry case uses the entry key itself. const tail = (label: string): string => `${label} (marked server-only)` // An entry that is ITSELF the marked module - the chain is just that one node. if (entryInputs.includes(markedModule)) return [tail(markedModule)] const seen = new Set(entryInputs) let frontier = entryInputs.map((node) => ({ node, chain: [node] })) while (frontier.length > 0) { const next: Array<{ node: string; chain: string[] }> = [] for (const { node, chain } of frontier) { for (const im of inputs[node]?.imports ?? []) { // Resolve the edge's `path` to the matching INPUT-GRAPH KEY - in a real build the edge `path` // is absolute while the input keys are cwd-relative, so a raw equality/lookup would miss every // multi-hop user edge (and the chain would degrade to just the tail). The resolver maps both. const target = resolveTarget(im) if (target === undefined || target.startsWith("node:")) continue // The marked module reached via THIS edge → the chain ends here (label it as the marked tail // using the as-written specifier the author wrote, falling back to the resolved key). if (target === markedModule) return [...chain, tail(im.original ?? target)] if (seen.has(target)) continue seen.add(target) next.push({ node: target, chain: [...chain, im.original ?? target] }) } } frontier = next } return [tail(markedModule)] } /** * Scan a build's metafile for any module that opts into the `server-only` marker (a side-effect * `import "@nifrajs/web/server-only"`) yet landed in a CLIENT output chunk, returning a sorted, deduped * list of {@link ServerOnlyFinding}s. Mirrors {@link detectNodeBuiltinsInClient}: it reads the SAME * graph facts - which inputs import the marker (the "marked" modules), which chunk each landed in (the * per-output `inputs`), and the shortest import chain from a user entry to it. The marker module ITSELF * (which imports nothing) is excluded - only the modules that *opt in* are flagged. Pure + exported for * unit testing. Empty ⇒ clean. */ export function detectServerOnlyInClient( graph: ClientModuleGraph, ): ReadonlyArray { const inputs = graph.modules // (1) The modules that import the marker - the ones the author opted into the guard. The marker // module itself is skipped: it's the import TARGET, not an opt-in (it imports nothing of its own). const marked = new Set() for (const [inputKey, input] of Object.entries(inputs)) { if (isServerOnlyMarkerModule(inputKey)) continue if ((input.imports ?? []).some(isServerOnlyMarkerImport)) marked.add(inputKey) } if (marked.size === 0) return [] const entryInputs = [ ...new Set( Object.values(graph.chunks) .map((output) => output.entryPoint) .filter((entry): entry is string => entry !== undefined && !entry.startsWith("node:")), ), ] // The entry inputs - the chain BFS starts here, so the reported path begins at the file the dev // wrote an `import` in. Same derivation as the node-builtin guard. const resolveTarget = inputKeyResolver(inputs) const chains = new Map() const chainFor = (module: string): readonly string[] => { const cached = chains.get(module) if (cached !== undefined) return cached const chain = shortestServerOnlyChain(inputs, entryInputs, module, resolveTarget) chains.set(module, chain) return chain } // (2) Locate which emitted chunk each marked module reached, via the per-output `inputs`. const findings = new Map() for (const [outputPath, output] of Object.entries(graph.chunks)) { for (const inputKey of output.modules) { if (!marked.has(inputKey)) continue const chunk = basename(outputPath) findings.set(`${inputKey}\0${chunk}`, { chunk, chain: chainFor(inputKey) }) } } return [...findings.values()].sort((a, b) => { const am = a.chain[a.chain.length - 1] ?? "" const bm = b.chain[b.chain.length - 1] ?? "" return am === bm ? a.chunk.localeCompare(b.chunk) : am.localeCompare(bm) }) } // --------------------------------------------------------------------------------------------------- // Guard MESSAGES - one owner for both production pipelines. The Bun build and the Vite/Rollup leak-guard // plugin both call these, so a leak reads IDENTICALLY whichever bundler produced it. A second bundler // must not grow a second, subtly-different wording of a security error; that is exactly the "mostly // ported" outcome the neutral graph seam exists to prevent. // --------------------------------------------------------------------------------------------------- /** The build-failing message for `node:` builtins that reached the client bundle. `undefined` ⇒ clean. */ export function formatNodeBuiltinLeak( findings: ReadonlyArray, ): string | undefined { if (findings.length === 0) return undefined const lines = findings.map((finding) => finding.chain.length > 1 ? ` - ${finding.builtin} reached the client bundle via ${finding.chain.join(" → ")} (chunk: ${finding.chunk})` : ` - ${finding.builtin} reached the client bundle via ${finding.chunk}`, ) return ( `[nifra/web] Node built-in(s) in the client bundle - move them behind a server-only path ` + `(a loader/action runs on the server; import the \`node:\` module there, not at a route's ` + `top level):\n${lines.join("\n")}` ) } /** The build-failing message for `server-only`-marked modules that reached the client. `undefined` ⇒ clean. */ export function formatServerOnlyLeak( findings: ReadonlyArray, ): string | undefined { if (findings.length === 0) return undefined const lines = findings.map((finding) => finding.chain.length > 1 ? ` - server-only module reached the client bundle via ${finding.chain.join(" → ")} (chunk: ${finding.chunk})` : ` - server-only module reached the client bundle via ${finding.chunk}`, ) return ( `[nifra/web] server-only module(s) in the client bundle - a module marked ` + `\`import "${SERVER_ONLY_MARKER}"\` reached the browser. Move it behind a server-only path ` + `(reach it via a loader/action, or rename it \`*.server.ts\`), so its server logic never ships ` + `to the client:\n${lines.join("\n")}` ) } // --------------------------------------------------------------------------------------------------- // Bundle-size report (`nifra build --report`). The raw byte + gzip size of each emitted chunk lets a // dev catch a bundle regression at build time. Pure aggregation/formatting helpers (no fs, no Bun // build) so they're unit-testable in isolation; the orchestrator below wires them to real outputs. // --------------------------------------------------------------------------------------------------- /** One emitted chunk's measured size, in raw bytes + gzipped bytes (over-the-wire weight). */ export interface ChunkSize { /** The emitted file's basename (e.g. `index-abc123.js`). */ readonly name: string /** Raw byte length of the file. */ readonly bytes: number /** Gzipped byte length (what the client actually downloads, modulo brotli). */ readonly gzip: number } /** A whole build's size report - every chunk (largest first) + the totals. */ export interface SizeReport { /** Per-chunk sizes, sorted biggest gzip first (the regression you want to see at the top). */ readonly chunks: readonly ChunkSize[] /** Sum of every chunk's raw bytes. */ readonly totalBytes: number /** Sum of every chunk's gzip bytes. */ readonly totalGzip: number } /** * Aggregate a list of measured chunks into a {@link SizeReport}: sort biggest-gzip-first (ties broken * by raw bytes, then name for stable output) and sum the totals. Pure - the measurement (reading the * file + gzipping it) happens in the orchestrator; this is the deterministic, unit-testable core. */ export function aggregateSizeReport(chunks: readonly ChunkSize[]): SizeReport { const sorted = [...chunks].sort( (a, b) => b.gzip - a.gzip || b.bytes - a.bytes || a.name.localeCompare(b.name), ) let totalBytes = 0 let totalGzip = 0 for (const chunk of sorted) { totalBytes += chunk.bytes totalGzip += chunk.gzip } return { chunks: sorted, totalBytes, totalGzip } } /** Human-readable byte count: `B`/`KB`/`MB` with one decimal above 1 KB (e.g. `12.3 KB`). Pure. */ export function formatBytes(bytes: number): string { if (bytes < 1024) return `${bytes} B` if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB` return `${(bytes / (1024 * 1024)).toFixed(1)} MB` } /** * Render a {@link SizeReport} as a terse aligned table (biggest first) with a totals row - the text * `nifra build --report` prints. Pure (string in, string out) so the formatting is unit-testable. */ export function renderSizeReport(report: SizeReport): string { const rows = report.chunks.map((chunk) => ({ name: chunk.name, raw: formatBytes(chunk.bytes), gz: formatBytes(chunk.gzip), })) // Column widths from the header + every row + the totals label, so the table never clips a value. const nameW = Math.max(5, ...rows.map((row) => row.name.length), "Total".length) const rawW = Math.max( 3, ...rows.map((row) => row.raw.length), formatBytes(report.totalBytes).length, ) const gzW = Math.max(4, ...rows.map((row) => row.gz.length), formatBytes(report.totalGzip).length) const padEnd = (value: string, width: number): string => value + " ".repeat(Math.max(0, width - value.length)) const padStart = (value: string, width: number): string => " ".repeat(Math.max(0, width - value.length)) + value const line = (name: string, raw: string, gzip: string): string => ` ${padEnd(name, nameW)} ${padStart(raw, rawW)} ${padStart(gzip, gzW)}` return [ line("Chunk", "Raw", "Gzip"), line("-".repeat(nameW), "-".repeat(rawW), "-".repeat(gzW)), ...rows.map((row) => line(row.name, row.raw, row.gz)), line("Total", formatBytes(report.totalBytes), formatBytes(report.totalGzip)), ].join("\n") } // --------------------------------------------------------------------------------------------------- // Server-manifest drift detection (#7). `server-manifest.ts` is a committed generated file: it bakes // the route list + the client-entry hash for a disk-less worker (`generateServerManifest`). If `routes/` // changes but the manifest isn't regenerated, the worker serves a stale route table - a silent edge // break. These pure helpers diff the COMMITTED manifest source against the freshly-discovered routes so // `nifra check` (and `buildServer`) can fail with a named, actionable error before the drift ships. // --------------------------------------------------------------------------------------------------- /** A drift finding between a committed server-manifest and the live `routes/` tree. */ export interface ManifestDrift { /** Route files present in `routes/` but ABSENT from the committed manifest (the manifest is stale - * the new route won't be served by the worker). */ readonly missing: readonly string[] /** Route files the committed manifest imports that no longer exist in `routes/` (a deleted/renamed * route still wired into the worker - a build/runtime break). */ readonly extra: readonly string[] } // The route-relative file keys the generated manifest's route map declares. Both shapes are matched by // their VALUE: eager ` "routes/x.tsx": m0,` and lazy ` "routes/x.tsx": () => import("./routes/x"),`. // The KEY (not the import specifier) is the route identity `discoverRoutes` produces, extension and all; // the import specifier is emitted extensionless (so a bare `tsc` compiles the manifest) and could not be // mapped back to the exact `.tsx`/`.ts` key. The baked `routeStyles` JSON is a single line, so its keys // are never at an indented line start and never match. `[ \t]+` (not `\s+`) so the `m` anchor stays on // the same line and one route-map entry cannot span two lines of source. const MANIFEST_ROUTE_ENTRY = /^[ \t]+(["'])([^"'\n]+)\1\s*:\s*(?:m\d+\b|\(\s*\)\s*=>\s*import\b)/gm // The baked client-entry line: `export const clientEntry = "…"`. const MANIFEST_CLIENT_ENTRY = /export\s+const\s+clientEntry\s*=\s*["']([^"']+)["']/ /** * Extract the route-relative file list a committed server-manifest declares, as the same * `routes/`-relative keys `discoverRoutes` produces (e.g. `docs/index.tsx`). Reads the route map's KEYS, * which carry the file extension and no directory prefix - exactly discovery's shape - so the result is * independent of the specifier prefix the manifest happened to import with. The `@nifrajs/web` import and * the baked `clientEntry`/`styles`/`routeStyles` lines carry no route-map entry and are ignored. * * `_routesPrefix` is accepted for call-site compatibility but no longer needed: the keys are already * prefix-free. Pure - operates on source text. */ export function parseManifestRouteFiles(source: string, _routesPrefix?: string): string[] { const files = new Set() for (const match of source.matchAll(MANIFEST_ROUTE_ENTRY)) { if (match[2] !== undefined) files.add(match[2]) } return [...files].sort() } /** The baked `clientEntry` URL in a committed server-manifest, or `undefined` if absent. Pure. */ export function parseManifestClientEntry(source: string): string | undefined { return MANIFEST_CLIENT_ENTRY.exec(source)?.[1] } // The baked asset lines `generateServerManifest` emits: `export const styles = […]` then // `export const routeStyles = {…}`, each a JSON literal running to the next `export const`. Extracted // with `indexOf` slicing rather than a lazy `[\s\S]*?` regex: the slice is multi-line by nature (a // formatter can wrap a long `routeStyles` map, and missing it would silently DROP every baked // stylesheet on re-sync), and a lazy scan re-walks the file per unterminated opener - quadratic. function bakedManifestLiteral(source: string, name: string): string | undefined { const opener = `export const ${name} = ` const start = source.indexOf(opener) if (start === -1) return undefined const from = start + opener.length const end = source.indexOf("\nexport const ", from) return end === -1 ? undefined : source.slice(from, end) } /** Parse a baked JSON literal captured from a committed manifest, tolerating a formatter's TRAILING COMMAS * (biome/prettier add one when wrapping a multi-line array/object; strict `JSON.parse` would reject it). * Only a comma immediately before a closing `]`/`}` is stripped, so commas inside string values are safe. */ function parseManifestLiteral(raw: string): unknown { return JSON.parse(raw.replace(/,(\s*[\]}])/g, "$1")) } /** The baked top-level `styles` array in a committed server-manifest (empty if absent/unparseable). Pure. */ export function parseManifestStyles(source: string): string[] { const raw = bakedManifestLiteral(source, "styles") if (raw === undefined) return [] try { const value = parseManifestLiteral(raw) return Array.isArray(value) ? (value as string[]) : [] } catch { return [] } } /** The baked per-route `routeStyles` map in a committed server-manifest (empty if absent/unparseable). Pure. */ export function parseManifestRouteStyles(source: string): Record { const raw = bakedManifestLiteral(source, "routeStyles") if (raw === undefined) return {} try { const value = parseManifestLiteral(raw) return value !== null && typeof value === "object" && !Array.isArray(value) ? (value as Record) : {} } catch { return {} } } /** * Diff the route files a committed server-manifest imports against the files freshly discovered in * `routes/`. Returns the `missing` (in routes/, not in manifest - stale manifest) and `extra` (in * manifest, gone from routes/ - dangling import) sets. Empty arrays ⇒ in sync. Pure - the caller * supplies both file lists (the committed source is parsed via {@link parseManifestRouteFiles}; the * fresh list comes from `discoverRoutes`). Lists need not be pre-sorted; the result is sorted. */ export function diffManifestRoutes( manifestFiles: readonly string[], discoveredFiles: readonly string[], ): ManifestDrift { const inManifest = new Set(manifestFiles) const inRoutes = new Set(discoveredFiles) return { missing: discoveredFiles.filter((file) => !inManifest.has(file)).sort(), extra: manifestFiles.filter((file) => !inRoutes.has(file)).sort(), } } /** True when a drift report is clean (no missing + no extra routes). */ export function isManifestInSync(drift: ManifestDrift): boolean { return drift.missing.length === 0 && drift.extra.length === 0 } /** * Format a {@link ManifestDrift} as a named, actionable error message, or `undefined` when in sync. * Names the exact missing/extra routes + the one fix (regenerate the manifest by re-running the build). * `manifestPath` is shown for the dev to locate the stale file. Pure. */ export function formatManifestDrift( drift: ManifestDrift, manifestPath = "server-manifest.ts", ): string | undefined { if (isManifestInSync(drift)) return undefined const lines = [ `[nifra/web] server-manifest drift - \`${manifestPath}\` is out of sync with routes/.`, ] if (drift.missing.length > 0) { lines.push( ` Missing (in routes/, not in the manifest - these routes won't be served): ${drift.missing.join(", ")}`, ) } if (drift.extra.length > 0) { lines.push( ` Extra (imported by the manifest, gone from routes/ - a dangling import): ${drift.extra.join(", ")}`, ) } lines.push(" Fix: re-run the build to regenerate the server manifest, then commit it.") return lines.join("\n") }