/** * `@nifrajs/web/build` - the production build (Bun-only, build-time). `buildClient` codegens + bundles * the client entry (content-hashed, code-split); `buildServer` codegens the static-import server * manifest + bundles a self-contained **worker** for the disk-less edge (Cloudflare Workers). Both * are Bun-specific and never on the request path (own subpath, like `@nifrajs/web/fs`); the *output* * runs on any runtime. */ import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs" import { cp, lstat, mkdir, realpath } from "node:fs/promises" import { dirname, join, basename as pathBasename, relative, resolve as resolvePath, sep, } from "node:path" import { singleCopyPlugin as declaredSingleCopyPlugin, readSingleCopyDeclaration, } from "@nifrajs/core/single-copy" import { type BunPlugin, Glob } from "bun" import { aggregateSizeReport, type BuildManifest, type BuildTarget, type Bundler, type ChunkSize, detectNodeBuiltinsInClient, detectServerOnlyInClient, formatNodeBuiltinLeak, formatServerOnlyLeak, parseManifestClientEntry, parseManifestRouteStyles, parseManifestStyles, planBuildTarget, type ServerBuild, type SizeReport, } from "./build-plan.ts" import { sanitizeOutputNames } from "./chunk-names.ts" import { discoverRoutes } from "./fs.ts" import { generateClientEntry, generateServerManifest } from "./index.ts" import { dedupePolicyFor } from "./internal/identity-policy.ts" import { assertDevelopmentProductionParity, assertIdentityParity, collectDevelopmentParityInput, } from "./internal/parity.ts" import { generateServerFnStub, SERVER_FN_MODULE, SERVER_ONLY_MODULE, SERVER_ONLY_REPLACEMENT, serverFnNamespace, } from "./internal/server-boundary.ts" // `buildTarget(static)` drives the SSG prerender engine directly (it's also re-exported below). import { fromBunMetafile } from "./module-graph.ts" import { prerenderRoutes } from "./prerender.ts" export * from "./build-plan.ts" const basename = (path: string): string => pathBasename(path) // The lazy shape declares `const loaders`, the eager shape `const modules`. Anchored at a line start, // NOT `.includes("const loaders =")`: the real emit is `const loaders: Record<...> = {`, whose `:` type // annotation sits between the name and the `=`, so the ` =` substring never matched and every lazy // manifest was re-synced as eager (silently converting `import()` splitting into a single eager bundle). const MANIFEST_IS_LAZY = /^const loaders\b/m const entryName = (file: string): string => { const base = basename(file) const dot = base.lastIndexOf(".") return dot === -1 ? base : base.slice(0, dot) } /** The slice of Bun's build metafile this build path reads: per JS OUTPUT, its source `entryPoint` and * the `cssBundle` Bun emitted for that entry. The guard-facing import graph is adapted separately by * `fromBunMetafile`; this smaller view is only for the per-route stylesheet map. */ interface BunMetafile { readonly outputs?: Readonly< Record > } // Build-time SSG: prerender opted-in static + dynamic routes to `index.html` (+ static `_data.json`), // run after `buildClient`. export { type CloudflarePagesRoutes, type CloudflarePagesRoutesOptions, cloudflarePagesRoutes, dataFileFor, htmlFileFor, type PrerenderApp, type PrerenderEntry, type PrerenderOptions, type PrerenderResult, prerenderRoutes, } from "./prerender.ts" export interface BuildClientOptions { /** The `routes/` directory to discover (absolute path). */ readonly routesDir: string /** Output directory for the bundle + `manifest.json` (absolute path). */ readonly outDir: string /** The adapter's client runtime (exports `mountRouter`), e.g. `"@nifrajs/web-solid/client"`. */ readonly clientModule: string /** Route/layout file → import specifier (default: `${routesDir}/${file}`). */ readonly resolve?: (file: string) => string /** Adapter build plugins (e.g. `solidBunPlugin("dom")`). */ readonly plugins?: readonly BunPlugin[] /** `Bun.build` export conditions (e.g. `["bun", "solid", "browser"]`). */ readonly conditions?: readonly string[] /** Compile-time replacements (e.g. `{ "process.env.NODE_ENV": '"production"' }`). */ readonly define?: Readonly> /** Minify the output (default `true`). */ readonly minify?: boolean /** URL prefix the assets are served under (default `"/assets/"`); also Bun's chunk `publicPath`. */ readonly publicPath?: string /** * Directory of user-authored static files copied into the build and served at the root (default * `"public"`). Absent directory ⇒ nothing copied, no error. * * NOT the same thing as {@link publicPath}, despite the names: that is the URL prefix for * content-hashed bundle chunks and never covers files an author put on disk. The collision is a * real source of confusion, which is why both are spelled out here. */ readonly publicDir?: string | false /** * Prefix that opts an environment variable into the **client** bundle (Vite/Next convention; default * `"PUBLIC_"`). Every var in the build environment whose name starts with this prefix is baked into * the client `define` as `"process.env.NAME": JSON.stringify(value)`, so `process.env.PUBLIC_API_URL` * compiles to its literal value in the browser. Vars WITHOUT the prefix are never exposed - the bare * `process.env` define resolves them to `undefined`, so server secrets can't leak into the client * bundle. Set to `""` to disable auto-exposure entirely (no var is baked in). `options.define` still * wins over an auto-exposed var (it's layered last). Sourced from `Bun.env` (falls back to * `process.env`) at build time. */ readonly publicEnvPrefix?: string } /** * The `process.env.` → `JSON.stringify(value)` define entries for every env var whose name * carries `prefix` (the Vite/Next public-env convention). Exposing ONLY the prefixed vars is the * security boundary: an unprefixed var (a secret) never gets a define, so the bare `process.env` * define resolves it to `undefined` in the client bundle. An empty `prefix` exposes nothing (the * opt-out). Pure + exported so the prefix/redaction contract is unit-testable without a real build. */ export function publicEnvDefines( prefix: string, env: Readonly>, ): Record { const defines: Record = {} if (prefix === "") return defines // opt-out: bake in nothing for (const [name, value] of Object.entries(env)) { // Skip `undefined` (a deleted/unset key can still enumerate) so we never bake in `"undefined"`. if (name.startsWith(prefix) && value !== undefined) { defines[`process.env.${name}`] = JSON.stringify(value) } } return defines } /** * Percent-encode one path segment exactly the way a browser encodes it into a request URL. * * NOT `encodeURIComponent`. That escapes the sub-delimiters `, @ + = & ; $`, which a browser sends raw - * so a file named `report,2026.csv` would be recorded as `/report%2C2026.csv` while the request arrives * as `/report,2026.csv`, the allowlist lookup misses, and the file 404s in production only. `encodeURI` * agrees with `URL.pathname` on every character except `?` and `#`, which terminate a path and so must * be escaped explicitly here. */ function encodePathSegment(segment: string): string { return encodeURI(segment).replace( /[?#]/g, (char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`, ) } /** * Cloudflare Pages rejects a `_routes.json` with more than 100 include+exclude rules combined, or any * single rule longer than 100 characters. */ const CF_MAX_ROUTE_RULES = 100 const CF_MAX_RULE_LENGTH = 100 export interface CloudflareRouteRules { readonly include: readonly string[] readonly exclude: readonly string[] /** Public files the budget could not name, so the build can say so instead of capping silently. */ readonly omitted: readonly string[] } /** * What a route pattern's first URL segment says about the paths it can reach. * * The three cases are genuinely different and collapsing them is a fail-open bug: `/` reaches only the * root and can never match below a directory, while `/:locale/about` can match below ANY directory, and * both would otherwise read as "no literal first segment". `dynamic` is also where unrecognised syntax * lands, deliberately - a pattern form this does not understand must never be taken as proof that * nothing matches. */ type FirstSegment = | { readonly kind: "root" } | { readonly kind: "literal"; readonly value: string } | { readonly kind: "dynamic" } function firstSegmentOf(pattern: string): FirstSegment { const first = pattern.split("/").find((segment) => segment !== "") if (first === undefined) return { kind: "root" } return /^[A-Za-z0-9._-]+$/.test(first) ? { kind: "literal", value: first } : { kind: "dynamic" } } /** * Directories under `public/` whose every file can be replaced by one `//*` rule. * * Safe only when no route can ever be served beneath that directory, because the glob does not merely * describe today's files - it hands Pages every future path under the prefix, and the worker never sees * them. A `public/blog/hero.png` next to a `/blog/:slug` route is the ordinary case that breaks: the * glob would send `/blog/my-post` to the CDN, which has no such file, and the page 404s in production * only. So a directory collapses only when every route pattern starts with a literal segment that is * something else. One dynamic first segment anywhere (`/:locale/…`) disables collapsing entirely, which * is correct - such a route can match under any directory name. * * A collapsed directory also gives up the app's own 404 page for a missing file beneath it, since the * request never reaches the worker. That is the right trade for a directory that holds only static * files: a missing image should fail as a fast CDN 404, not as an HTML error page. */ function collapsibleDirs( publicFiles: readonly string[], routePatterns: readonly string[], ): ReadonlySet { const reserved = new Set() for (const pattern of routePatterns) { const first = firstSegmentOf(pattern) // A dynamic first segment can match anywhere, so nothing is collapsible at all. if (first.kind === "dynamic") return new Set() // `root` reserves nothing: `/` cannot match a path below a directory. if (first.kind === "literal") reserved.add(first.value) } const dirs = new Set() for (const file of publicFiles) { const slash = file.indexOf("/", 1) if (slash === -1) continue // a root-level file has no directory to collapse into const dir = file.slice(1, slash) if (!reserved.has(dir)) dirs.add(dir) } return dirs } /** * Build the cf-pages `_routes.json` rules for a set of copied public files, within Cloudflare's budget. * * `exclude` is what Pages serves straight from the CDN instead of invoking the worker, so naming every * public file is ideal - and impossible past ~99 of them. A `public/` of icons, fonts and share images * clears 100 easily, and the rejection lands at `wrangler pages deploy`, long after the build reported * success. * * A glob is emitted only where it cannot be wrong. `/assets/*` always is: the build owns that prefix * outright, since it is where the hashed bundle is written. A `public/` subdirectory is different - the * name is the author's, and a `/blog/*` rule would hand Pages every future path under `/blog/`, so a * `/blog/:slug` route would 404 from the CDN in production only. {@link collapsibleDirs} therefore * collapses a directory only after checking it against the app's real route patterns, which is what * makes the compaction safe rather than merely smaller. Everything else stays an exact path, and a name * containing `*` is dropped rather than escaped, since the rule would become a wildcard. * * What still does not fit is dropped, which is safe because the list is only an optimization: the * generated worker serves any allowlisted path it receives through the ASSETS binding, so an omitted * file costs one worker invocation, not a 404. */ export function cloudflareRouteRules( publicFiles: readonly string[], /** * Every route pattern the app serves. Required, not defaulted: an empty list means "this app serves * no routes", which makes every directory collapsible - a defensible answer to state deliberately and * a dangerous one to arrive at by forgetting the argument. */ routePatterns: readonly string[], ): CloudflareRouteRules { const include = ["/*"] const exclude = ["/assets/*"] const omitted: string[] = [] // Collapse only where the route table proves nothing can be served beneath the directory, and only // when the per-file list would not have fit anyway - a smaller `_routes.json` is worth nothing on its // own, and exact paths keep the app's own 404 for a missing file under that directory. const perFileFits = publicFiles.length + include.length + exclude.length <= CF_MAX_ROUTE_RULES const collapsible = perFileFits ? new Set() : collapsibleDirs(publicFiles, routePatterns) const emitted = new Set() for (const file of publicFiles) { const slash = file.indexOf("/", 1) const dir = slash === -1 ? undefined : file.slice(1, slash) const collapsed = dir !== undefined && collapsible.has(dir) const rule = collapsed ? `/${dir}/*` : file if (emitted.has(rule)) continue // already covered by a rule emitted for an earlier file // A `*` in a FILE name would turn that file's own rule into a wildcard. A collapsed rule's `*` is // ours and deliberate, and the file's name never appears in it, so the check applies to one case. if (!collapsed && file.includes("*")) { omitted.push(file) continue } if (include.length + exclude.length >= CF_MAX_ROUTE_RULES || rule.length > CF_MAX_RULE_LENGTH) { omitted.push(file) continue } exclude.push(rule) emitted.add(rule) } return { include, exclude, omitted } } /** The built asset map - the server reads `entry` for the client script + serves `assets`. */ /** * Copy `from` into `to`, returning the URL paths copied (sorted). * * Lives here, not beside `servePublicDir`, because it is BUILD-time: it reaches for `Bun.Glob` and * `node:fs`, and `public-dir.ts` is reachable from the client bundle graph through the package * entry - a dynamic `import("bun")` there fails the browser build outright. */ export async function copyPublicDir(from: string, to: string): Promise { const source = resolvePath(from) const sourceReal = await realpath(source) const copied: string[] = [] for await (const rel of new Glob("**/*").scan({ cwd: source, dot: true, onlyFiles: false })) { const candidate = join(source, rel) const candidateStat = await lstat(candidate) if (candidateStat.isSymbolicLink()) { throw new Error(`[nifra/web] publicDir entry escapes its root through a symlink: ${rel}`) } if (!candidateStat.isFile()) continue const candidateReal = await realpath(candidate) if (candidateReal !== sourceReal && !candidateReal.startsWith(sourceReal + sep)) { throw new Error(`[nifra/web] publicDir entry escapes its root through a symlink: ${rel}`) } const target = join(to, rel) await mkdir(join(target, ".."), { recursive: true }) await cp(candidateReal, target) copied.push(`/${rel.split(sep).map(encodePathSegment).join("/")}`) } return copied.sort() } /** * Re-emit a committed server-manifest from a freshly-discovered route tree, PRESERVING its baked * client-asset references (`clientEntry` / `styles` / `routeStyles`) and its eager-vs-lazy shape. This is * what makes `nifra sync-manifest` a route-table refresh (renamed / added / removed routes) that does NOT * need a full build. It deliberately does NOT rebuild the client bundle: a brand-new HYDRATING route still * needs a full build so its client chunk exists - this only re-syncs the server manifest's route table. * Pure: `source` + the discovered `manifest` in, new source out. */ export function resyncServerManifestSource( source: string, manifest: Parameters[0], routesPrefix: string, ): string { return generateServerManifest(manifest, { resolve: (file) => `${routesPrefix}${file}`, clientEntry: parseManifestClientEntry(source) ?? "", styles: parseManifestStyles(source), routeStyles: parseManifestRouteStyles(source), lazy: MANIFEST_IS_LAZY.test(source), }) } /** * 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. */ /** * Build the client bundle for a file-routed app. Writes the hashed assets + `manifest.json` to * `outDir` and returns the manifest. Throws (with the bundler logs) on build failure - never * silently ships a broken bundle. */ export async function buildClient(options: BuildClientOptions): Promise { const { routesDir, outDir, clientModule } = options const root = resolvePath(dirname(routesDir)) await assertIdentityParity(root) const resolve = options.resolve ?? ((file: string) => `${routesDir}/${file}`) const publicPath = options.publicPath ?? "/assets/" // outDir inside publicDir folds the build output back into the public copy on the next run (the // bundle lands at public/assets/assets/*), and the dev/prod parity assert then throws on paths no // one authored. Reject the overlap up front - no copy behavior is correct for this layout, and the // parity symptom names neither option so the cause is unrecoverable from the message. const publicRoot = options.publicDir === false ? undefined : (options.publicDir ?? "public") if (publicRoot !== undefined) { const publicResolved = resolvePath(publicRoot) const outResolved = resolvePath(outDir) if (outResolved === publicResolved || outResolved.startsWith(publicResolved + sep)) { throw new Error( `[nifra/web] outDir (${outResolved}) is inside publicDir (${publicResolved}). The public ` + `copy would fold the build output back into itself. Move outDir outside publicDir, or ` + `pass publicDir: false if the app has no hand-authored static files.`, ) } } mkdirSync(outDir, { recursive: true }) const routeManifest = discoverRoutes(routesDir) // The bootstrap's filename is `_nifra`-namespaced (not `entry.ts`) so its `[name]` can't collide with // a user route named `entry.tsx` when the CSS mapping below excludes the bootstrap's aggregate CSS. const mode = options.minify === false ? "development" : "production" // PUBLIC_*-prefixed env → client define (Vite/Next convention). Sourced from the build env (`Bun.env`, // falling back to `process.env`). Only prefixed vars are baked in; unprefixed secrets stay undefined // in the client via the bare `process.env` → `({})` define below. Caller's `options.define` wins (it's // layered after these in the `define` object). `Bun` may be absent under non-Bun typecheck - guard it. const buildEnv = (typeof Bun !== "undefined" ? Bun.env : undefined) ?? process.env const publicDefines = publicEnvDefines(options.publicEnvPrefix ?? "PUBLIC_", buildEnv) // Keep the generated source beside the project, not inside `outDir`: module resolution starts at the // importing file, so an absolute `--out /tmp/deploy` must still resolve the app's dependencies. // A unique directory also avoids clobbering a user file or colliding with parallel builds. const entryDir = mkdtempSync(resolvePath(dirname(routesDir), ".nifra-client-")) const entryFile = `${entryDir}/_nifra-entry.ts` // A client bundle has no Node `process`; provide a minimal one so a stray bare `process` reference in // an app module doesn't crash hydration (`process.env.*` reads are handled at compile time by `define`). writeFileSync( entryFile, `globalThis.process ??= { env: {} };\n${generateClientEntry(routeManifest, { clientModule, resolve })}`, ) // Every unique route/layout/`_404` file (sorted, stable), as ADDITIONAL entrypoints - Bun emits a // named chunk per file that the bootstrap's lazy `import()` dedupes to (verified), so the manifest // can map each route to its chunk URLs for matched-route preload. `resolve(file)` is the same // specifier the bootstrap imports, so the entrypoint + lazy import are the same module (dedup). const routeFiles = [ ...new Set([ ...routeManifest.routes.map((r) => r.file), ...Object.values(routeManifest.layouts).map((l) => l.file), ...(routeManifest.notFound ? [routeManifest.notFound.file] : []), ]), ].sort() // `metafile: true` asks Bun for the input/output graph - specifically `outputs[js].entryPoint` // (the source file) + `outputs[js].cssBundle` (that entry's emitted stylesheet). It's the robust // entry→CSS link for per-route splitting: keyed by the unique source path, so it survives // same-basename collisions (`index.tsx` + `blog/index.tsx`) that a filename match can't. Not yet in // `@types/bun`'s `BuildConfig`, so spread it in (spread props skip the excess-property check). const buildExtras = { metafile: true } const result = await (async () => { try { return await Bun.build({ entrypoints: [entryFile, ...routeFiles.map(resolve)], outdir: outDir, target: "browser", naming: "[name]-[hash].[ext]", publicPath, splitting: true, // one chunk per lazily-imported route; shared deps deduped into shared chunks // `import "./x.css"` in a route/component → bundled, minified, content-hashed `.css` asset (Bun // strips the import from the JS; CSS bundling is on by default since Bun 1.2). Mapped to routes // below - both the aggregate and per-route - via the metafile, for `` injection. ...buildExtras, minify: options.minify ?? true, plugins: [ ...declaredSingleCopyPlugins(root), reactDedupePlugin(routesDir), preactDedupePlugin(routesDir), svelteDedupePlugin(routesDir), serverOnlyEmptyPlugin(), serverFnStubPlugin(), ...(options.plugins ?? []), ], ...(options.conditions ? { conditions: [...options.conditions] } : {}), // Replace `process.env.*` at compile time so an app module reading config off `process.env` doesn't // hit a `process is not defined` crash in the browser. Bun does longest-match: NODE_ENV resolves to // the build mode (React's prod/dev branch); each PUBLIC_* var resolves to its baked VALUE; every // other `process.env.X` becomes undefined (the bare `process.env` → `({})` fallback - so secrets // never leak). Callers can override any of these via `options.define` (layered last). define: { "process.env": "({})", "process.env.NODE_ENV": JSON.stringify(mode), ...publicDefines, ...options.define, }, }) } finally { rmSync(entryDir, { recursive: true, force: true }) } })() if (!result.success) { throw new Error( `[nifra/web] client build failed:\n${result.logs.map((l) => String(l)).join("\n")}`, ) } // #4: a `node:` builtin (e.g. `node:crypto`) pulled into a CLIENT chunk builds fine (Bun substitutes // a browser polyfill) but breaks/leaks at runtime. Fail the build with a named, actionable error // instead - caught at build time, not by a confused user in the browser. Graph-based (the metafile's // per-output `inputs`), so it can't false-positive on a `"node:..."` string literal and survives // minification. Only the client build runs this; the server build's `node:` imports are legitimate. const clientMeta = (result as unknown as { metafile?: BunMetafile }).metafile const clientGraph = fromBunMetafile(clientMeta) // #4: a `node:` builtin (e.g. `node:crypto`) pulled into a CLIENT chunk builds fine (Bun substitutes a // browser polyfill) but breaks/leaks at runtime. Fail with the chain (entry → … → builtin), through the // SHARED formatter so the Vite pipeline's identical guard reads byte-for-byte the same. const nodeBuiltinLeak = formatNodeBuiltinLeak(detectNodeBuiltinsInClient(clientGraph)) if (nodeBuiltinLeak !== undefined) throw new Error(nodeBuiltinLeak) // §3.3/§5.1: a module that opted into the `server-only` marker yet reached a CLIENT chunk - catches // pure-server logic (a secret, a server-only API call) carrying no `node:` import and not named // `*.server`, so neither other guard fires. Same shared formatter as above. const serverOnlyLeak = formatServerOnlyLeak(detectServerOnlyInClient(clientGraph)) if (serverOnlyLeak !== undefined) throw new Error(serverOnlyLeak) // Rename any chunk whose basename isn't URL-safe (dynamic-route files become `[slug]-hash.js`) and // rewrite the references - otherwise the lazy import 404s and the route silently never hydrates. const renamed = sanitizeOutputNames(result.outputs) const toUrl = (path: string): string => `${publicPath}${renamed.get(basename(path)) ?? basename(path)}` // Entry-point outputs come back in entrypoint order: [bootstrap, ...routeFiles]. Map each route file // to its chunk URL by that order (guarded against drift), then a route's chunks = its layout chain + // own file. const entryPoints = result.outputs.filter((o) => o.kind === "entry-point") const bootstrap = entryPoints[0] if (bootstrap === undefined) throw new Error("[nifra/web] build produced no entry-point output") if (entryPoints.length !== routeFiles.length + 1) { throw new Error( `[nifra/web] expected ${routeFiles.length + 1} entry-point outputs (bootstrap + ${routeFiles.length} routes), got ${entryPoints.length}`, ) } const fileToChunk = new Map() routeFiles.forEach((file, i) => { const out = entryPoints[i + 1] // in range - length checked above if (out !== undefined) fileToChunk.set(file, toUrl(out.path)) }) const chunksFor = (chainFiles: readonly string[]): string[] => chainFiles.map((f) => fileToChunk.get(f)).filter((u): u is string => u !== undefined) const routes: Record = {} for (const route of routeManifest.routes) { routes[route.id] = chunksFor([ ...route.layoutIds.map((id) => routeManifest.layouts[id]?.file ?? ""), route.file, ]) } if (routeManifest.notFound) routes._404 = chunksFor([routeManifest.notFound.file]) // CSS - aggregate: an `import "./x.css"` anywhere → a content-hashed `.css` asset (Bun strips the // import from the JS). The bootstrap lazily imports every route, so its **aggregate** stylesheet is // the whole app's CSS - the always-safe fallback `createWebApp` links when a route has no per-route // entry below. Fallback to all CSS assets if Bun emitted no distinct aggregate. const bootstrapName = entryName(entryFile) // `_nifra-entry` const cssNameOf = (path: string): string => { const base = basename(path) return base.slice(0, base.lastIndexOf("-")) // strip `-${hash}.css` } // `css` is the union of every emitted stylesheet, matching the Vite pipeline (build-vite.ts) so the // field means the same thing on both bundlers. The bootstrap aggregate lists first, preserving the // fallback link order `createWebApp` uses (whole-app stylesheet before any route-scoped one); the // per-route `cssBundle` outputs follow. Dropping those per-route outputs (the old `aggregate`-only // shape) left them in `manifest.assets` but not `css`, so a route-scoped stylesheet normalized to // `asset:css` and tripped the module-graph parity contract - a bundler-dependent divergence. const cssAssets = result.outputs.filter((o) => o.kind === "asset" && o.path.endsWith(".css")) const css: readonly string[] = [ ...cssAssets.filter((o) => cssNameOf(o.path) === bootstrapName), ...cssAssets.filter((o) => cssNameOf(o.path) !== bootstrapName), ].map((o) => toUrl(o.path)) // CSS - per-route: each route/layout file is its own entrypoint, so the build metafile records its // `cssBundle` - exactly the CSS that file's subtree uses (shared-component CSS is inlined into each // consumer; verified). Keyed by the metafile's unique source `entryPoint`, so it survives // same-basename collisions (`index.tsx` + `blog/index.tsx`) that a filename match can't. A page then // links only its layout chain + own CSS (deduped); an empty array means the page needs no CSS at all. // Absent (→ aggregate fallback) only if Bun emits no metafile/cssBundle - never silently incomplete. const cwd = process.cwd() const cssByEntry = new Map() for (const out of Object.values(clientMeta?.outputs ?? {})) { if (out.entryPoint !== undefined && out.cssBundle !== undefined) { cssByEntry.set(resolvePath(cwd, out.entryPoint), toUrl(out.cssBundle)) } } const stylesFor = (chainFiles: readonly string[]): readonly string[] => { const urls = chainFiles .map((f) => (f ? cssByEntry.get(resolvePath(resolve(f))) : undefined)) .filter((u): u is string => u !== undefined) return [...new Set(urls)] } const routeStyles: Record = {} // A compiler plugin may attach a global stylesheet to the generated bootstrap rather than to a // route entry (StyleX's atomic CSS is one example). In that shape there is no useful per-route map: // emitting `routeId: []` would suppress the aggregate `styles` fallback in `createWebApp` and render // SSR pages unstyled. Keep routeStyles absent until at least one authored route entry owns CSS. const routeCssEntries = [...cssByEntry.keys()].filter((entry) => entry !== resolvePath(entryFile)) if (css.length > 0 && routeCssEntries.length > 0) { for (const route of routeManifest.routes) { routeStyles[route.id] = stylesFor([ ...route.layoutIds.map((id) => routeManifest.layouts[id]?.file ?? ""), route.file, ]) } if (routeManifest.notFound) routeStyles._404 = stylesFor([routeManifest.notFound.file]) } // Copy `public/` into the output next to the hashed assets. A missing directory is normal (most // apps have none) and must not fail the build. const publicDir = options.publicDir === false ? undefined : (options.publicDir ?? "public") const publicFiles = publicDir !== undefined && existsSync(publicDir) ? await copyPublicDir(publicDir, outDir) : [] const manifest: BuildManifest = { entry: toUrl(bootstrap.path), assets: result.outputs.map((o) => toUrl(o.path)), routes, ...(publicFiles.length > 0 ? { publicFiles } : {}), ...(css.length > 0 ? { css } : {}), ...(Object.keys(routeStyles).length > 0 ? { routeStyles } : {}), } assertDevelopmentProductionParity(collectDevelopmentParityInput(routesDir, publicDir), manifest) writeFileSync(`${outDir}/manifest.json`, JSON.stringify(manifest, null, 2)) return manifest } export interface BuildServerOptions { /** The `routes/` directory to discover (absolute path). */ readonly routesDir: string /** The worker entry module (absolute path) - your `worker.ts`. It imports `{ manifest, clientEntry }` * from the generated `./server-manifest`, builds `createWebApp`, and `export default toFetchHandler(app)`. */ readonly serverEntry: string /** Output directory for the bundled worker (absolute path). */ readonly outDir: string /** The content-hashed client entry URL (from `buildClient`'s manifest) - **baked** into the generated * server manifest, since a disk-less worker can't read `manifest.json` at runtime. */ readonly clientEntry: string /** The app's aggregate stylesheet URLs (`buildClient`'s `BuildManifest.css`) - baked into the generated * manifest so the server entry hands them to `createWebApp` (→ ``). Omit ⇒ no CSS * link (the built SSR page would otherwise render unstyled). */ readonly styles?: readonly string[] | undefined /** Per-route stylesheet URLs (`buildClient`'s `BuildManifest.routeStyles`) - baked alongside `styles`. */ readonly routeStyles?: Readonly> | undefined /** Route/layout file → import specifier in the generated manifest (default: a relative path from the * manifest's location - written next to `serverEntry` - to `routesDir`). */ readonly resolve?: (file: string) => string /** Filename for the generated server-manifest module, written next to `serverEntry` (default * `"server-manifest.ts"`); your `serverEntry` imports it as `./server-manifest`. */ readonly manifestFile?: string /** Adapter build plugins (e.g. `solidBunPlugin("ssr")` - Solid routes need their SSR transform at * build time; React's JSX is Bun-native and needs none). */ readonly plugins?: readonly BunPlugin[] /** `Bun.build` resolution conditions (default `["workerd", "edge-light", "browser"]`) - selects each * dependency's edge build. */ readonly conditions?: readonly string[] /** Compile-time replacements (default `{ "process.env.NODE_ENV": '"production"' }` → production * React/Solid on the edge). Pass an explicit object to override (e.g. `{}` to opt out). */ readonly define?: Readonly> /** Minify the output (default `true`). */ readonly minify?: boolean /** `Bun.build` target (default `"browser"` - the right shape for edge runtimes: Cloudflare Workers, * Vercel Edge, Deno, Deno Deploy). Use `"node"` for a `@nifrajs/node` server (so `node:*` built-ins * stay external), or `"bun"` for a Bun server. The default `conditions` + the edge resolve shims * only apply to the `"browser"` target; `"node"`/`"bun"` resolve their own renderer builds via the * matching condition. */ readonly target?: "browser" | "node" | "bun" /** **Lazy/code-split routes** (default `false`): emit `() => import(route)` loaders + bundle with * `splitting`, so each route is its own chunk loaded on first request (smaller cold-start parse) * instead of all parsed at boot. The output becomes the worker entry **+ chunk files** in `outDir` * - on Cloudflare, ship them with wrangler's `no_bundle` + `find_additional_modules` + an ESModule * `rule` (Node/Deno import the chunks natively). Eager (one self-contained file) stays the default. */ readonly lazy?: boolean } /** The built worker bundle - point your `wrangler.toml`'s `main` at `worker`. */ /** * react-dom's `exports["./server"]` maps the `bun` condition to a Bun-API server build that crashes * on workerd, and `Bun.build` always applies the `bun` condition (it wins over `workerd`/`edge-light`), * so conditions alone can't select the edge build. This shim pins `react-dom/server` to its edge build * (`server.edge.js`, which exports `renderToReadableStream`). A no-op when nothing imports react-dom * (e.g. a Solid worker) - the resolver only runs on a match. */ const reactDomEdgePlugin = (from: string): BunPlugin => ({ name: "nifra-react-dom-edge", setup(build) { build.onResolve({ filter: /^react-dom\/server$/ }, () => ({ path: Bun.resolveSync("react-dom/server.edge", from), })) }, }) /** * Dedupe React to a single copy. A `file:`-linked package can ship its OWN `react` under its own * node_modules, so the bundle ends up with two React cores - each with its own hook dispatcher - and SSR * throws the cryptic `null is not an object (evaluating '…H.useState')` (the second renderer's dispatcher * is null). This was the #1 time-sink in app builds. Pinning `react` + its JSX runtimes to ONE resolved * copy (the app's, from `from`) guarantees a single dispatcher; `react-dom`, which imports `react`, then * shares it - so the class can't occur rather than needing a named error. No-op when React isn't used * (an unresolvable spec is skipped; the resolver only fires on an exact match). React core is * condition-agnostic, so pinning it doesn't disturb the edge/browser/server conditions that select * react-dom's build. */ const REACT_DEDUPE_SPECS = dedupePolicyFor("react").bunSpecs ?? [] export const reactDedupePlugin = (from: string): BunPlugin => ({ name: "nifra-react-dedupe", setup(build) { for (const spec of REACT_DEDUPE_SPECS) { let resolved: string try { resolved = Bun.resolveSync(spec, from) } catch { continue // React (or this subpath) isn't resolvable here - nothing to dedupe } const escaped = spec.replace(/[/\\^$*+?.()|[\]{}]/g, "\\$&") build.onResolve({ filter: new RegExp(`^${escaped}$`) }, () => ({ path: resolved })) } }, }) /** * Dedupe Preact to a single copy - the Preact analogue of `reactDedupePlugin`, closing the same class of * bug for the Preact framework (which had NO build-time dedup before). A `file:`-linked package can ship * its OWN `preact`, so the bundle ends up with two Preact cores; since `preact-render-to-string` mutates * `preact`'s shared `options` global and `preact/hooks` writes the SAME global, two copies → two `options` * → SSR throws `undefined is not an object (… __H)` (the vnode's hook state was set up on the other copy). * Pinning `preact` + its hooks/compat/jsx subpaths to ONE resolved copy (the app's, from `from`) makes the * renderer and the components share one core. No-op when Preact isn't used (an unresolvable spec is * skipped). Preact core is condition-agnostic, so pinning it doesn't disturb any condition selection - and * unlike react-dom there is no edge-vs-server build to preserve, so pinning the subpaths is safe. */ const PREACT_DEDUPE_SPECS = dedupePolicyFor("preact").bunSpecs ?? [] export const preactDedupePlugin = (from: string): BunPlugin => ({ name: "nifra-preact-dedupe", setup(build) { for (const spec of PREACT_DEDUPE_SPECS) { let resolved: string try { resolved = Bun.resolveSync(spec, from) } catch { continue // Preact (or this subpath) isn't resolvable here - nothing to dedupe } const escaped = spec.replace(/[/\\^$*+?.()|[\]{}]/g, "\\$&") build.onResolve({ filter: new RegExp(`^${escaped}$`) }, () => ({ path: resolved })) } }, }) const SVELTE_DEDUPE_PATTERN = dedupePolicyFor("svelte").bunPattern ?? /^svelte($|\/internal\/)/ /** * Dedupe Svelte to a single copy - the Svelte analogue of `reactDedupePlugin`/`preactDedupePlugin`, closing * the same class of bug for Svelte (which had NO build-time dedup before). A workspace- or file-linked * `@nifrajs/web-svelte` can resolve its OWN `svelte` (e.g. a sibling repo's install store) while the app's * components resolve another - SAME version, two physical copies. Svelte 5's client runtime * (`svelte/internal/client`) holds module-level component-context state, so two copies means the compiled * components register on one runtime while `hydrate` runs on the other → hydration throws * `Cannot read properties of undefined (reading 'call')` and the server-rendered markup is wiped. * * Pin every `svelte` + `svelte/internal/*` import to the ONE copy resolvable from `from` (the app root) so * the renderer (`hydrate`/`mount`) and the compiled components share one runtime. Unlike react/preact (a * fixed subpath list), Svelte has many internal subpaths, so each matched import is resolved dynamically. * `svelte/compiler` (build-time only, not in the bundle) doesn't match the filter and is left alone. No-op * when Svelte isn't used / isn't resolvable from `from`. */ export const svelteDedupePlugin = (from: string): BunPlugin => ({ name: "nifra-svelte-dedupe", setup(build) { build.onResolve({ filter: SVELTE_DEDUPE_PATTERN }, (args) => { try { return { path: Bun.resolveSync(args.path, from) } } catch { return undefined // not resolvable from the app root - leave Bun's default resolution } }) }, }) /** * The app's declared single-copy rule, applied to the bundle. * * The three plugins above cover the framework runtimes by name, which is the case that bites hardest * and needs no configuration. This one covers what a name list cannot know: `@nifrajs/*` (two copies * of `@nifrajs/core` are two `Server` classes, and `Server` has private members, so `.merge()` stops * accepting the other's app) and whatever else the app named in * `"nifra": { "singleCopy": [...] }`. * * Nothing declared means no plugin at all - the plan does filesystem work, so it is not built for the * majority of apps that have a single copy of everything and never think about this. */ const declaredSingleCopyPlugins = (root: string): readonly BunPlugin[] => readSingleCopyDeclaration(root) === undefined ? [] : [declaredSingleCopyPlugin({ cwd: root })] /** * Remix-style `.server` convention for the CLIENT build. A module named `*.server.ts(x)` (`db.server.ts`, * `auth.server.ts`, …) is server-only - empty it in the browser bundle so its (possibly `node:` / native / * Capacitor) import subtree never reaches the client. The body is CJS-with-a-Proxy so any named OR default * import resolves to `undefined` rather than a "missing export" bundle error (verified), and the real * import subtree is gone. The complement to the node-builtin guard: when a server-only import is co-located * in a route file (so it can't be tree-shaken out and the guard fails loud), moving it into a `*.server` * module is the fix. CLIENT-only - buildServer keeps the real module, which runs server-side. */ export const serverOnlyEmptyPlugin = (): BunPlugin => ({ name: "nifra-server-only-empty", setup(build) { build.onLoad({ filter: SERVER_ONLY_MODULE }, () => ({ contents: SERVER_ONLY_REPLACEMENT, loader: "js", })) }, }) /** * Server functions in the CLIENT build: replace each `*.fn.ts` module with stubs that call the routes * the server mounted, so the function bodies - and everything they import - never reach a browser. * * The sibling of {@link serverOnlyEmptyPlugin}, and a deliberate contrast: a `*.server` module is * EMPTIED because nothing may call it from the client, while a `*.fn` module is REPLACED because the * client is supposed to call it, just over HTTP. The generation itself is in * `internal/server-boundary.ts` so the Vite pipeline emits identical stubs from the same code; two * hand-written copies would be a client that works in dev and 404s in production. * * CLIENT-only. The server build keeps the real module, which is what `serverFunctions()` mounts. */ export const serverFnStubPlugin = (): BunPlugin => ({ name: "nifra-server-fn-stub", setup(build) { build.onLoad({ filter: SERVER_FN_MODULE }, async (args) => ({ contents: generateServerFnStub( await Bun.file(args.path).text(), serverFnNamespace(args.path), ), loader: "js", })) }, }) /** * `solid-js/web` selects its **server** runtime (`renderToStream`) via the `worker` condition, but * `Bun.build` 1.3.14 **segfaults** when the `worker` condition is active (https://bun.report). And * without `worker`, `browser` (which precedes the other server conditions in solid's exports map) * wins → the *dom* runtime, which can't SSR. So this shim pins `solid-js/web` straight to its server * build, sidestepping the crashing condition. Lazy (resolved on match) + a no-op when nothing imports * `solid-js/web` (e.g. a React worker). Drop it (and use the `worker` condition) once Bun is fixed. */ const solidWebServerPlugin = (from: string): BunPlugin => ({ name: "nifra-solid-web-server", setup(build) { build.onResolve({ filter: /^solid-js\/web$/ }, () => { // solid's `worker`/`node`/`deno` conditions all map to ./web/dist/server.js (its server build). const pkg = Bun.resolveSync("solid-js/package.json", from) return { path: pkg.replace(/package\.json$/, "web/dist/server.js") } }) }, }) /** * Build a self-contained **worker bundle** for a file-routed app on a disk-less edge (Cloudflare * Workers / workerd). Discovers routes (build-time fs), codegens the static-import server manifest * (`generateServerManifest`, written next to `serverEntry`), then bundles `serverEntry` with * `Bun.build` using **edge conditions** + the adapter's SSR plugins. The output imports no `node:fs` * and does no dynamic-path import, so it runs on workerd: point `wrangler.toml`'s `main` at it and * serve the client assets via Workers Assets. Throws (with the bundler logs) on failure - never * silently ships a broken worker. */ export async function buildServer(options: BuildServerOptions): Promise { const { routesDir, serverEntry, outDir, clientEntry, styles, routeStyles } = options const entryDir = dirname(serverEntry) const manifestFile = options.manifestFile ?? "server-manifest.ts" // Default: import routes relative from the generated manifest (next to serverEntry) to routesDir. const rel = relative(entryDir, routesDir).replaceAll("\\", "/") const resolve = options.resolve ?? ((file: string) => `./${rel}/${file}`) mkdirSync(outDir, { recursive: true }) const lazy = options.lazy ?? false const target = options.target ?? "browser" // Edge (browser) target: Bun's `bun` condition contaminates react-dom's server build + the `worker` // condition segfaults Bun.build on solid, so force the edge/server builds via shims. The `node`/`bun` // targets resolve those correctly via their own condition, so the shims (and edge conditions) don't // apply - defaults become `[target]` (e.g. react-dom → server.node.js under `node`). const edge = target === "browser" const conditions = options.conditions ?? (edge ? ["workerd", "edge-light", "browser"] : [target]) const manifest = discoverRoutes(routesDir) writeFileSync( `${entryDir}/${manifestFile}`, generateServerManifest(manifest, { resolve, clientEntry, styles, routeStyles, lazy }), ) const result = await Bun.build({ entrypoints: [serverEntry], outdir: outDir, target, conditions: [...conditions], define: { ...(options.define ?? { "process.env.NODE_ENV": '"production"' }), // Tag every BUNDLED SSR output so @nifrajs/web-react's react-dom adapter takes the static // (bundled, deduped) react-dom instead of re-rooting react-dom/server to a DISK copy at runtime. // A `target:"bun"` bundle still has `Bun.resolveSync` under the Bun runtime, so without this tag the // adapter re-imports a SECOND react-dom from node_modules - a second React core whose hook dispatcher // is null for the bundled components → SSR throws `…H.useRef of null`. Always set (a structural fact // of bundling, layered after any caller `define` so it can't be overridden). Unbundled Bun runtimes // (nifra dev/start, nifra_render) never define it, so they still re-root - the dev dual-install fix. "process.env.NIFRA_SSR_BUNDLED": '"1"', "globalThis.__NIFRA_EDGE_RUNTIME__": edge ? "true" : "false", }, minify: options.minify ?? true, // Lazy → one chunk per route (loaded on first request); eager → a single self-contained file. splitting: lazy, plugins: [ ...declaredSingleCopyPlugins(resolvePath(dirname(routesDir))), ...(edge ? [reactDomEdgePlugin(entryDir), solidWebServerPlugin(entryDir)] : []), reactDedupePlugin(entryDir), preactDedupePlugin(entryDir), ...(options.plugins ?? []), ], }) if (!result.success) { throw new Error( `[nifra/web] server build failed:\n${result.logs.map((l) => String(l)).join("\n")}`, ) } const entryOutput = result.outputs.find((o) => o.kind === "entry-point") if (entryOutput === undefined) { throw new Error("[nifra/web] server build produced no entry-point output") } return { worker: entryOutput.path, outputs: result.outputs.map((o) => o.path) } } // =================================================================================================== // `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. // =================================================================================================== /** * Codegen the per-target **server entry** module (source text) for `buildServer` to bundle. It imports * the app's `adapter` (from `framework.ts`), the optional `backend` (from `backend.ts`), and the * generated `{ manifest, clientEntry }` (from `./server-manifest`), builds `createWebApp`, then wires * the right host: * - `cf-pages` / `vercel`: `export default` the fetch handler (the platform serves /assets/* itself). * - `deno`: same fetch-handler default, plus `Deno.serve` self-host when run directly. * - `bun` / `node`: a self-hosting server that ALSO serves the client bundle from disk (those * runtimes have a filesystem; the static `/assets/*` sit next to the entry). * `adapterImport`/`backendImport` are the specifiers the entry uses (relative to where it's written) - * `buildServer` writes the entry next to `serverEntry`, so they're resolved from there. Pure (string in, * string out) so the generation is unit-testable without a real build. */ export function generateServerEntry(options: { readonly target: BuildTarget /** Import specifier for the module exporting `adapter` (e.g. `"../framework.ts"`). */ readonly adapterImport: string /** Import specifier for the module exporting `backend`, or `undefined` for a frontend-only app. */ readonly backendImport?: string /** Import specifier for the module exporting `use`, or `undefined`. Must resolve to the * same edge-safe module as `adapterImport`. */ readonly useImport?: string /** Document `` passed to `createWebApp`. */ readonly title?: string /** Encoded root-relative public file paths copied into the deploy directory. */ readonly publicFiles?: readonly string[] }): string { const { target, adapterImport, backendImport, useImport, title = "nifra", publicFiles = [], } = options if (target === "static") { throw new Error("[nifra/web] generateServerEntry: `static` has no server entry (SSG only)") } const lines: string[] = ['import { createWebApp } from "@nifrajs/web"'] if (backendImport !== undefined) lines.push('import { inProcessClient } from "@nifrajs/client"') lines.push(`import { adapter } from ${JSON.stringify(adapterImport)}`) if (useImport !== undefined) lines.push(`import { use } from ${JSON.stringify(useImport)}`) if (backendImport !== undefined) { lines.push(`import { backend } from ${JSON.stringify(backendImport)}`) } lines.push('import { clientEntry, manifest, styles, routeStyles } from "./server-manifest"') // cf-pages/vercel/deno need the fetch-handler shape; bun/node call app.fetch directly. const usesToFetch = target === "cf-pages" || target === "vercel" || target === "deno" if (usesToFetch) lines.push('import { toFetchHandler } from "@nifrajs/core/server"') if (target === "node") lines.push('import { serve } from "@nifrajs/node"') lines.push( "", "const app = createWebApp({", " adapter,", ...(useImport !== undefined ? [" use,"] : []), " manifest,", " clientEntry,", " styles,", " routeStyles,", ...(backendImport !== undefined ? [" api: inProcessClient(backend),"] : []), ` title: ${JSON.stringify(title)},`, "})", "", ) if (target === "cf-pages") { // Cloudflare Pages advanced mode: `_routes.json` keeps static paths off the worker entirely, and // everything else falls through to this handler (SSR). // // That exclude list cannot always name every public file - Cloudflare caps `_routes.json` at 100 // rules - so a static request CAN arrive here. Serve it from the ASSETS binding instead of letting // the router 404 it, which makes correctness independent of how much of the list fit: the exclude // list decides only how many static requests skip the worker, never which files exist. lines.push( `const PUBLIC_FILES = new Set(${JSON.stringify(publicFiles)})`, "const handler = toFetchHandler(app)", "type Assets = { readonly ASSETS?: { fetch(request: Request): Promise<Response> } }", "export default {", " fetch(request: Request, env: Assets, ctx: ExecutionContext) {", " const { pathname } = new URL(request.url)", " // Only the build's own outputs: the hashed bundle prefix and the files it copied.", ' if (env?.ASSETS !== undefined && (pathname.startsWith("/assets/") || PUBLIC_FILES.has(pathname))) {', " return env.ASSETS.fetch(request)", " }", " return handler.fetch(request, env as never, ctx)", " },", "}", ) return `${lines.join("\n")}\n` } if (target === "vercel") { lines.push( "// Vercel Edge Function - Vercel serves /assets/* from its CDN; this only SSRs page routes.", 'export const config = { runtime: "edge" }', "export default (req: Request): Response | Promise<Response> => app.fetch(req)", ) return `${lines.join("\n")}\n` } // bun / node / deno self-host AND serve the client bundle from disk (it sits next to this entry). lines.push( "// The client bundle and public files live next to this entry; serve only manifest-approved paths.", 'const STATIC_ROOT = new URL("./", import.meta.url)', `const PUBLIC_FILES = new Set(${JSON.stringify(publicFiles)})`, "const staticPath = (pathname: string): string | undefined => {", " if (PUBLIC_FILES.has(pathname)) return '.' + pathname", ' if (!pathname.startsWith("/assets/")) return undefined', ' const segments = pathname.slice(1).split("/")', " // `.` and `..` match the character class, and the path is joined onto a base URL that resolves", " // dot segments - so they are rejected explicitly rather than relying on the request URL having", " // been normalized upstream.", " const safe = (segment: string): boolean =>", ' segment !== "." && segment !== ".." && /^[A-Za-z0-9._-]+$/.test(segment)', " return segments.every(safe) ? '.' + pathname : undefined", "}", 'const TYPES = { js: "text/javascript", css: "text/css", map: "application/json" }', ) if (target === "bun") { lines.push( "const server = Bun.serve({", " port: Number(Bun.env.PORT ?? 3000),", " async fetch(req) {", " const { pathname } = new URL(req.url)", " const filePath = staticPath(pathname)", " if (filePath !== undefined) {", " const file = Bun.file(new URL(filePath, STATIC_ROOT))", ' if (!(await file.exists())) return new Response("not found", { status: 404 })', ' const ext = pathname.slice(pathname.lastIndexOf(".") + 1)', ' return new Response(file, { headers: { "content-type": TYPES[ext] ?? "application/octet-stream" } })', " }", " return app.fetch(req)", " },", "})", // The `${...}` here is literal OUTPUT (a template in the GENERATED file), not a template in this // source - split so biome's noTemplateCurlyInString doesn't flag it; the emitted line is unchanged. `console.log(\`nifra (Bun) → http://localhost:$${"{server.port}"}\`)`, ) return `${lines.join("\n")}\n` } if (target === "node") { lines.push( 'import { readFile } from "node:fs/promises"', "await serve(", " {", " async fetch(req) {", " const { pathname } = new URL(req.url)", " const filePath = staticPath(pathname)", " if (filePath !== undefined) {", " try {", " const body = await readFile(new URL(filePath, STATIC_ROOT))", ' const ext = pathname.slice(pathname.lastIndexOf(".") + 1)', ' return new Response(body, { headers: { "content-type": TYPES[ext] ?? "application/octet-stream" } })', " } catch {", ' return new Response("not found", { status: 404 })', " }", " }", " return app.fetch(req)", " },", " },", " { port: Number(process.env.PORT ?? 3000) },", ")", ) return `${lines.join("\n")}\n` } // deno lines.push( "const handler = toFetchHandler(app)", "// @ts-ignore - Deno global is present on the Deno runtime this output targets.", 'Deno.serve({ port: Number(Deno.env.get("PORT") ?? "3000") }, async (req) => {', " const { pathname } = new URL(req.url)", " const filePath = staticPath(pathname)", " if (filePath !== undefined) {", " try {", " // @ts-ignore - Deno.readFile is present on the Deno runtime.", " const body = await Deno.readFile(new URL(filePath, STATIC_ROOT))", ' const ext = pathname.slice(pathname.lastIndexOf(".") + 1)', ' return new Response(body, { headers: { "content-type": TYPES[ext] ?? "application/octet-stream" } })', " } catch {", ' return new Response("not found", { status: 404 })', " }", " }", " return handler.fetch(req)", "})", ) return `${lines.join("\n")}\n` } /** Measure each emitted output file's raw + gzip size. Reads the file off disk and gzips it with * `Bun.gzipSync` (the over-the-wire weight). Async only because it reads files; the aggregation is the * pure {@link aggregateSizeReport}. */ async function measureOutputs(paths: readonly string[]): Promise<ChunkSize[]> { const chunks: ChunkSize[] = [] for (const path of paths) { const bytes = await Bun.file(path).bytes() chunks.push({ name: basename(path), bytes: bytes.byteLength, gzip: Bun.gzipSync(bytes).byteLength, }) } return chunks } export interface BuildTargetOptions { /** The `routes/` directory to discover (absolute path). */ readonly routesDir: string /** Output directory for the assembled deploy dir (absolute path). Cleared and recreated. */ readonly outDir: string /** A scratch directory for intermediate codegen (the generated server entry + manifest) and the * server bundle, cleaned up after. Absolute path. */ readonly workDir: string /** The adapter's client runtime module (exports `mountRouter`), e.g. `"@nifrajs/web-react/client"`. */ readonly clientModule: string /** Import specifier (resolvable from `workDir`) of the module exporting `adapter`. */ readonly adapterImport: string /** Import specifier (resolvable from `workDir`) of the module exporting `backend`, or `undefined`. */ readonly backendImport?: string /** Import specifier (resolvable from `workDir`) of the module exporting `use` (app-level middleware * applied before page routes are declared), or `undefined`. Must resolve to the same edge-safe * module as `adapterImport`. */ readonly useImport?: string /** Factory that builds the app for `static` prerendering, GIVEN the client build's manifest - so the * emitted hydration `<script src>` uses the REAL content-hashed entry (`client.entry`) plus the same * styles/route-preload the server targets use. A pre-built instance can't work here: the hash isn't known * until `buildClient` runs inside `buildTarget`, so a hardcoded entry 404s → pages render but never * hydrate. Required for `target: "static"` (SSG drives `app.fetch`); ignored otherwise. */ readonly prerenderApp?: (client: BuildManifest) => PrerenderAppLike | Promise<PrerenderAppLike> /** Client-build plugins (e.g. the MDX/Vue/Solid Bun plugins). */ readonly clientPlugins?: readonly BunPlugin[] /** Server-build plugins (e.g. the SSR variants). */ readonly serverPlugins?: readonly BunPlugin[] /** Extra Bun.build resolve conditions for the CLIENT build. */ readonly conditions?: readonly string[] /** Compile-time `define` replacements layered onto both builds. */ readonly define?: Readonly<Record<string, string>> /** Project-root static directory copied to deploy root. `false` disables it. */ readonly publicDir?: string | false /** Prefix of environment variables allowed into the client bundle (default `"PUBLIC_"`). */ readonly publicEnvPrefix?: string /** Document `<title>` for the generated server entry. */ readonly title?: string } /** Minimal app surface `buildTarget`'s static path needs - a fetch handler (a built `createWebApp`). */ export interface PrerenderAppLike { fetch(req: Request): Response | Promise<Response> } /** The result of a target build - the deploy dir + the client manifest + an optional size report. */ export interface BuildTargetResult { /** The deploy target that was built. */ readonly target: BuildTarget /** The assembled output directory. */ readonly outDir: string /** The client build's manifest (entry URL, assets, per-route chunks/styles). */ readonly client: BuildManifest /** A human-readable note on how to run/deploy the output. */ readonly run: string /** Per-chunk size report over the emitted client (+ server) outputs. Always computed; the CLI prints * it only with `--report`. */ readonly size: SizeReport } /** * Build a full deploy directory for `target` from a file-routed nifra app. Emits the client bundle to * `<outDir>/assets/*`, then per target: * - `static`: prerenders opted-in routes (`prerenderRoutes`) to `<outDir>/<path>/index.html` (+ * `_data.json`); needs `prerenderApp`. No server. * - `cf-pages`: a `_worker.js` (edge bundle) + a `_routes.json` excluding /assets/* from the worker. * - `vercel`: a `.vercel/output`-shaped function isn't emitted here - `vercel` emits the bundled edge * entry as `<outDir>/index.js` (the CLI's docs point at `vercel`'s Build Output wrapper). [see note] * - `deno`/`node`/`bun`: the self-hosting server bundle (`server.js`) next to the assets. * The server entry is GENERATED (`generateServerEntry`) and bundled (`buildServer`); the app supplies * only adapter/backend/routes. Returns the manifest + a size report. Throws on any build failure. * * Note: the heavier platform wrappers (`.vercel/output` v3 layout, wrangler ISR `find_additional_modules`) * remain app-owned scripts; this command targets the common single-bundle deploys. See the CLI docs. */ /** The default (Bun) strategy - `buildClient`/`buildServer` from this module. */ export const bunBundler: Bundler = { buildClient: (input) => buildClient({ routesDir: input.routesDir, outDir: input.outDir, clientModule: input.clientModule, ...(input.plugins ? { plugins: input.plugins as BunPlugin[] } : {}), ...(input.conditions ? { conditions: input.conditions } : {}), ...(input.define ? { define: input.define } : {}), ...(input.publicDir !== undefined ? { publicDir: input.publicDir } : {}), ...(input.publicEnvPrefix !== undefined ? { publicEnvPrefix: input.publicEnvPrefix } : {}), }), buildServer: (input) => buildServer({ routesDir: input.routesDir, serverEntry: input.serverEntry, outDir: input.outDir, clientEntry: input.clientEntry, target: input.target, ...(input.plugins ? { plugins: input.plugins as BunPlugin[] } : {}), ...(input.define ? { define: input.define } : {}), }), } /** Build a full deploy dir for `target` using the default Bun bundler. See {@link buildTargetWith}. */ export async function buildTarget( target: BuildTarget, options: BuildTargetOptions, ): Promise<BuildTargetResult> { return buildTargetWith(target, options, bunBundler) } /** * The bundler-agnostic deploy orchestrator: everything `buildTarget` does EXCEPT the two bundling steps, * which come from `bundler`. `buildTarget` passes {@link bunBundler}; `buildTargetVite` * (`@nifrajs/web/build-vite`) passes the Vite strategy. One orchestrator, so the deploy-dir shape, * server-entry codegen, prerender and size report are identical across pipelines. */ export async function buildTargetWith( target: BuildTarget, options: BuildTargetOptions, bundler: Bundler, ): Promise<BuildTargetResult> { const { routesDir, outDir, workDir } = options const targetPlan = planBuildTarget(target, outDir) const { rmSync } = await import("node:fs") rmSync(outDir, { recursive: true, force: true }) rmSync(workDir, { recursive: true, force: true }) const assetsDir = `${outDir}/assets` mkdirSync(assetsDir, { recursive: true }) mkdirSync(workDir, { recursive: true }) // (1) Client bundle → <outDir>/assets/* (every target ships the same hashed client bundle). let client = await bundler.buildClient({ routesDir, outDir: assetsDir, clientModule: options.clientModule, ...(options.clientPlugins ? { plugins: options.clientPlugins } : {}), ...(options.conditions ? { conditions: options.conditions } : {}), define: { "process.env.NODE_ENV": '"production"', ...(options.define ?? {}) }, publicDir: false, ...(options.publicEnvPrefix !== undefined ? { publicEnvPrefix: options.publicEnvPrefix } : {}), root: resolvePath(dirname(routesDir)), }) const publicDir = options.publicDir === false ? undefined : resolvePath(options.publicDir ?? join(dirname(routesDir), "public")) const publicFiles = publicDir !== undefined && existsSync(publicDir) ? await copyPublicDir(publicDir, outDir) : [] if (publicFiles.length > 0) { client = { ...client, publicFiles } writeFileSync(`${assetsDir}/manifest.json`, JSON.stringify(client, null, 2)) } if (targetPlan.kind === "static") { if (options.prerenderApp === undefined) { throw new Error( "[nifra/web] buildTarget(static) requires `prerenderApp` (a factory `(client) => createWebApp`)", ) } const manifest = discoverRoutes(routesDir) // Build the prerender app with the REAL content-hashed client entry (+ styles/preload) from the client // build above, so the hydration `<script src>` the prerendered HTML emits matches the emitted bundle. // A stale/placeholder entry here 404s → the pages render but never hydrate (inert controls). const app = await options.prerenderApp(client) const result = await prerenderRoutes({ app, routes: manifest.routes, outDir, }) if (result.prerendered.length === 0) { // A static build that renders nothing is almost always a misconfig (no `prerender = true` / no // getStaticPaths) - fail loudly rather than ship an empty dir the dev thinks is their site. throw new Error( "[nifra/web] buildTarget(static): no routes were prerendered - opt routes in with " + "`export const prerender = true` (static) or `getStaticPaths` (dynamic).", ) } rmSync(workDir, { recursive: true, force: true }) const size = aggregateSizeReport( await measureOutputs(client.assets.map((u) => assetUrlToPath(u, assetsDir))), ) return { target, outDir, client, run: targetPlan.run, size, } } // (2) Generate + bundle the server entry. It's written into workDir; the generated server-manifest // lands next to it (buildServer writes it there). The adapter/backend specifiers are resolved from // workDir, so the caller passes paths relative to it (or absolute). const serverEntryPath = `${workDir}/server-entry.ts` writeFileSync( serverEntryPath, generateServerEntry({ target, adapterImport: options.adapterImport, ...(options.useImport !== undefined ? { useImport: options.useImport } : {}), ...(options.backendImport !== undefined ? { backendImport: options.backendImport } : {}), ...(options.title !== undefined ? { title: options.title } : {}), ...(publicFiles.length > 0 ? { publicFiles } : {}), }), ) const { worker } = await bundler.buildServer({ routesDir, serverEntry: serverEntryPath, outDir: `${workDir}/server`, clientEntry: client.entry, target: targetPlan.serverTarget, ...(options.serverPlugins ? { plugins: options.serverPlugins } : {}), define: { "process.env.NODE_ENV": '"production"', ...(options.define ?? {}) }, root: resolvePath(dirname(routesDir)), }) // (3) Assemble the deploy dir for the target. const { cpSync } = await import("node:fs") if (targetPlan.target === "cf-pages") { cpSync(worker, `${outDir}/${targetPlan.outputFile}`) // The app's real patterns, so a directory is only collapsed into a glob once the route table // proves nothing can be served beneath it. const rules = cloudflareRouteRules( publicFiles, discoverRoutes(routesDir).routes.map((route) => route.pattern), ) writeFileSync( `${outDir}/_routes.json`, `${JSON.stringify({ version: 1, include: rules.include, exclude: rules.exclude }, null, 2)}\n`, ) // Never cap silently: an omitted file still serves (the worker's ASSETS fallback), but it costs a // worker invocation, and that trade should be visible rather than inferred from a latency graph. if (rules.omitted.length > 0) { console.log( `[nifra/web] _routes.json holds ${rules.exclude.length} of ${publicFiles.length + 1} static rules ` + `(Cloudflare allows ${CF_MAX_ROUTE_RULES}). The other ${rules.omitted.length} file(s) are served by the ` + "worker via ASSETS instead of the CDN directly - correct, just one invocation each.", ) } } else if (targetPlan.target === "vercel") { cpSync(worker, `${outDir}/${targetPlan.outputFile}`) } else { cpSync(worker, `${outDir}/${targetPlan.outputFile}`) } rmSync(workDir, { recursive: true, force: true }) // Size report over the client assets + the server bundle (its parse cost matters on the edge). const clientPaths = client.assets.map((u) => assetUrlToPath(u, assetsDir)) const serverPath = `${outDir}/${targetPlan.outputFile}` const size = aggregateSizeReport(await measureOutputs([...clientPaths, serverPath])) return { target, outDir, client, run: targetPlan.run, size } } /** Map a client asset URL (`/assets/x-hash.js`) back to its on-disk path under `assetsDir`. The * `publicPath` prefix is always `/assets/` for these builds, so strip it and rejoin. */ const assetUrlToPath = (url: string, assetsDir: string): string => `${assetsDir}/${url.slice(url.lastIndexOf("/") + 1)}`