@nifrajs/web 2.2.0 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (179) hide show
  1. package/README.md +3 -3
  2. package/dist/build-vite.d.ts +4 -4
  3. package/dist/build-vite.d.ts.map +1 -1
  4. package/dist/build-vite.js +20 -74
  5. package/dist/build-vite.js.map +1 -1
  6. package/dist/build.d.ts +64 -42
  7. package/dist/build.d.ts.map +1 -1
  8. package/dist/build.js +100 -78
  9. package/dist/build.js.map +1 -1
  10. package/dist/bun-dev-entry.d.ts +1 -1
  11. package/dist/bun-dev-entry.js +1 -1
  12. package/dist/chunk-names.js +1 -1
  13. package/dist/client.d.ts +3 -9
  14. package/dist/client.d.ts.map +1 -1
  15. package/dist/client.js +201 -29
  16. package/dist/client.js.map +1 -1
  17. package/dist/conformance.d.ts +2 -2
  18. package/dist/conformance.js +3 -3
  19. package/dist/deferred.d.ts +11 -11
  20. package/dist/deferred.d.ts.map +1 -1
  21. package/dist/deferred.js +16 -16
  22. package/dist/dev-diagnostics.d.ts +20 -0
  23. package/dist/dev-diagnostics.d.ts.map +1 -0
  24. package/dist/dev-diagnostics.js +34 -0
  25. package/dist/dev-diagnostics.js.map +1 -0
  26. package/dist/dev-error.d.ts +10 -13
  27. package/dist/dev-error.d.ts.map +1 -1
  28. package/dist/dev-error.js +51 -37
  29. package/dist/dev-error.js.map +1 -1
  30. package/dist/dev-port.d.ts +3 -3
  31. package/dist/dev-port.d.ts.map +1 -1
  32. package/dist/dev-port.js +10 -4
  33. package/dist/dev-port.js.map +1 -1
  34. package/dist/dev.d.ts +4 -4
  35. package/dist/dev.d.ts.map +1 -1
  36. package/dist/dev.js +28 -12
  37. package/dist/dev.js.map +1 -1
  38. package/dist/diagnostic.d.ts +93 -0
  39. package/dist/diagnostic.d.ts.map +1 -0
  40. package/dist/diagnostic.js +205 -0
  41. package/dist/diagnostic.js.map +1 -0
  42. package/dist/draft.d.ts +12 -12
  43. package/dist/draft.js +11 -11
  44. package/dist/fn-state.d.ts +56 -0
  45. package/dist/fn-state.d.ts.map +1 -0
  46. package/dist/fn-state.js +77 -0
  47. package/dist/fn-state.js.map +1 -0
  48. package/dist/fn.d.ts +111 -0
  49. package/dist/fn.d.ts.map +1 -0
  50. package/dist/fn.js +125 -0
  51. package/dist/fn.js.map +1 -0
  52. package/dist/fonts-google.d.ts +9 -9
  53. package/dist/fonts-google.js +10 -10
  54. package/dist/fonts.d.ts +7 -7
  55. package/dist/fonts.js +1 -1
  56. package/dist/forms.d.ts +8 -8
  57. package/dist/fs.d.ts +1 -1
  58. package/dist/fs.js +1 -1
  59. package/dist/index.d.ts +151 -70
  60. package/dist/index.d.ts.map +1 -1
  61. package/dist/index.js +317 -139
  62. package/dist/index.js.map +1 -1
  63. package/dist/internal/head-attributes.d.ts +15 -0
  64. package/dist/internal/head-attributes.d.ts.map +1 -0
  65. package/dist/internal/head-attributes.js +99 -0
  66. package/dist/internal/head-attributes.js.map +1 -0
  67. package/dist/internal/script-types.d.ts +14 -0
  68. package/dist/internal/script-types.d.ts.map +1 -0
  69. package/dist/internal/script-types.js +17 -0
  70. package/dist/internal/script-types.js.map +1 -0
  71. package/dist/internal/server-boundary.d.ts +60 -0
  72. package/dist/internal/server-boundary.d.ts.map +1 -0
  73. package/dist/internal/server-boundary.js +174 -0
  74. package/dist/internal/server-boundary.js.map +1 -0
  75. package/dist/internal/timing-safe-equal.d.ts +1 -1
  76. package/dist/internal/timing-safe-equal.js +1 -1
  77. package/dist/internal/vite-import.d.ts +63 -0
  78. package/dist/internal/vite-import.d.ts.map +1 -0
  79. package/dist/internal/vite-import.js +94 -0
  80. package/dist/internal/vite-import.js.map +1 -0
  81. package/dist/islands.d.ts +5 -5
  82. package/dist/islands.js +5 -5
  83. package/dist/isr.d.ts +15 -15
  84. package/dist/isr.js +7 -7
  85. package/dist/manifest.d.ts +73 -35
  86. package/dist/manifest.d.ts.map +1 -1
  87. package/dist/manifest.js +10 -16
  88. package/dist/manifest.js.map +1 -1
  89. package/dist/module-graph.d.ts +5 -5
  90. package/dist/module-graph.js +4 -4
  91. package/dist/navigation.d.ts +120 -5
  92. package/dist/navigation.d.ts.map +1 -1
  93. package/dist/navigation.js +37 -5
  94. package/dist/navigation.js.map +1 -1
  95. package/dist/plugins/css-modules.d.ts +3 -3
  96. package/dist/plugins/css-modules.js +0 -0
  97. package/dist/plugins/kit.d.ts +8 -8
  98. package/dist/plugins/kit.js +9 -9
  99. package/dist/plugins/scss.js +3 -3
  100. package/dist/plugins/vite-leak-guard.d.ts +1 -1
  101. package/dist/plugins/vite-leak-guard.js +1 -1
  102. package/dist/plugins/vite-server-fn.d.ts +23 -0
  103. package/dist/plugins/vite-server-fn.d.ts.map +1 -0
  104. package/dist/plugins/vite-server-fn.js +51 -0
  105. package/dist/plugins/vite-server-fn.js.map +1 -0
  106. package/dist/plugins/vite-server-only.d.ts +49 -0
  107. package/dist/plugins/vite-server-only.d.ts.map +1 -0
  108. package/dist/plugins/vite-server-only.js +57 -0
  109. package/dist/plugins/vite-server-only.js.map +1 -0
  110. package/dist/prerender.d.ts +5 -5
  111. package/dist/prerender.js +7 -7
  112. package/dist/query.d.ts +10 -10
  113. package/dist/query.js +15 -15
  114. package/dist/route-manifest.d.ts +1 -1
  115. package/dist/route-manifest.js +2 -2
  116. package/dist/router.d.ts +30 -21
  117. package/dist/router.d.ts.map +1 -1
  118. package/dist/router.js +62 -32
  119. package/dist/router.js.map +1 -1
  120. package/dist/search.d.ts +94 -0
  121. package/dist/search.d.ts.map +1 -0
  122. package/dist/search.js +232 -0
  123. package/dist/search.js.map +1 -0
  124. package/dist/server-only.d.ts +6 -6
  125. package/dist/server-only.js +7 -7
  126. package/dist/service-worker.d.ts +56 -0
  127. package/dist/service-worker.d.ts.map +1 -0
  128. package/dist/service-worker.js +127 -0
  129. package/dist/service-worker.js.map +1 -0
  130. package/dist/vite.d.ts +16 -14
  131. package/dist/vite.d.ts.map +1 -1
  132. package/dist/vite.js +51 -24
  133. package/dist/vite.js.map +1 -1
  134. package/package.json +38 -4
  135. package/server-manifest.d.ts +31 -0
  136. package/src/build-vite.ts +24 -77
  137. package/src/build.ts +137 -105
  138. package/src/bun-dev-entry.ts +1 -1
  139. package/src/chunk-names.ts +1 -1
  140. package/src/client.ts +215 -32
  141. package/src/conformance.ts +3 -3
  142. package/src/deferred.ts +17 -17
  143. package/src/dev-diagnostics.ts +47 -0
  144. package/src/dev-error.ts +64 -33
  145. package/src/dev-port.ts +17 -5
  146. package/src/dev.ts +36 -20
  147. package/src/diagnostic.ts +284 -0
  148. package/src/draft.ts +15 -15
  149. package/src/fn-state.ts +107 -0
  150. package/src/fn.ts +199 -0
  151. package/src/fonts-google.ts +13 -13
  152. package/src/fonts.ts +7 -7
  153. package/src/forms.ts +8 -8
  154. package/src/fs.ts +2 -2
  155. package/src/index.ts +460 -181
  156. package/src/internal/head-attributes.ts +109 -0
  157. package/src/internal/script-types.ts +18 -0
  158. package/src/internal/server-boundary.ts +187 -0
  159. package/src/internal/timing-safe-equal.ts +1 -1
  160. package/src/internal/vite-import.ts +92 -0
  161. package/src/islands.ts +7 -7
  162. package/src/isr.ts +17 -17
  163. package/src/manifest.ts +83 -42
  164. package/src/module-graph.ts +6 -6
  165. package/src/navigation.ts +151 -5
  166. package/src/plugins/css-modules.ts +0 -0
  167. package/src/plugins/kit.ts +11 -11
  168. package/src/plugins/scss.ts +3 -3
  169. package/src/plugins/vite-leak-guard.ts +2 -2
  170. package/src/plugins/vite-server-fn.ts +67 -0
  171. package/src/plugins/vite-server-only.ts +75 -0
  172. package/src/prerender.ts +10 -10
  173. package/src/query.ts +22 -22
  174. package/src/route-manifest.ts +3 -3
  175. package/src/router.ts +98 -45
  176. package/src/search.ts +297 -0
  177. package/src/server-only.ts +7 -7
  178. package/src/service-worker.ts +165 -0
  179. package/src/vite.ts +61 -32
package/src/build.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * `@nifrajs/web/build` — the production build (Bun-only, build-time). `buildClient` codegens + bundles
2
+ * `@nifrajs/web/build` - the production build (Bun-only, build-time). `buildClient` codegens + bundles
3
3
  * the client entry (content-hashed, code-split); `buildServer` codegens the static-import server
4
4
  * manifest + bundles a self-contained **worker** for the disk-less edge (Cloudflare Workers). Both
5
5
  * are Bun-specific and never on the request path (own subpath, like `@nifrajs/web/fs`); the *output*
@@ -12,6 +12,13 @@ import { type BunPlugin, Glob } from "bun"
12
12
  import { sanitizeOutputNames } from "./chunk-names.ts"
13
13
  import { discoverRoutes } from "./fs.ts"
14
14
  import { generateClientEntry, generateServerManifest } from "./index.ts"
15
+ import {
16
+ generateServerFnStub,
17
+ SERVER_FN_MODULE,
18
+ SERVER_ONLY_MODULE,
19
+ SERVER_ONLY_REPLACEMENT,
20
+ serverFnNamespace,
21
+ } from "./internal/server-boundary.ts"
15
22
  // `buildTarget(static)` drives the SSG prerender engine directly (it's also re-exported below).
16
23
  import { type ClientModuleGraph, fromBunMetafile } from "./module-graph.ts"
17
24
  import { prerenderRoutes } from "./prerender.ts"
@@ -63,7 +70,7 @@ export interface BuildClientOptions {
63
70
  * Prefix that opts an environment variable into the **client** bundle (Vite/Next convention; default
64
71
  * `"PUBLIC_"`). Every var in the build environment whose name starts with this prefix is baked into
65
72
  * the client `define` as `"process.env.NAME": JSON.stringify(value)`, so `process.env.PUBLIC_API_URL`
66
- * compiles to its literal value in the browser. Vars WITHOUT the prefix are never exposed — the bare
73
+ * compiles to its literal value in the browser. Vars WITHOUT the prefix are never exposed - the bare
67
74
  * `process.env` define resolves them to `undefined`, so server secrets can't leak into the client
68
75
  * bundle. Set to `""` to disable auto-exposure entirely (no var is baked in). `options.define` still
69
76
  * wins over an auto-exposed var (it's layered last). Sourced from `Bun.env` (falls back to
@@ -242,7 +249,7 @@ export function cloudflareRouteRules(
242
249
  return { include, exclude, omitted }
243
250
  }
244
251
 
245
- /** The built asset map — the server reads `entry` for the client script + serves `assets`. */
252
+ /** The built asset map - the server reads `entry` for the client script + serves `assets`. */
246
253
  /**
247
254
  * Copy `from` into `to`, returning the URL paths copied (sorted).
248
255
  *
@@ -276,9 +283,9 @@ export async function copyPublicDir(from: string, to: string): Promise<string[]>
276
283
  export interface BuildManifest {
277
284
  /** URL of the client entry module (content-hashed). */
278
285
  readonly entry: string
279
- /** URLs of every emitted asset (entry + chunks) — for serving + preloading. */
286
+ /** URLs of every emitted asset (entry + chunks) - for serving + preloading. */
280
287
  readonly assets: readonly string[]
281
- /** `routeId → [layout chunk URLs…, own chunk URL]` — the chunks a route needs, for `createWebApp`'s
288
+ /** `routeId → [layout chunk URLs…, own chunk URL]` - the chunks a route needs, for `createWebApp`'s
282
289
  * `routePreload` (`<link rel="modulepreload">` the matched route alongside the entry). Each route +
283
290
  * layout is also a build entrypoint, so it gets a named chunk the bootstrap's lazy import dedupes to. */
284
291
  readonly routes: Readonly<Record<string, readonly string[]>>
@@ -286,16 +293,16 @@ export interface BuildManifest {
286
293
  * directory per request, and lets an adapter that needs a file list (CDN upload, platform static
287
294
  * assets) consume one. Omitted when there is no `public/`. */
288
295
  readonly publicFiles?: readonly string[]
289
- /** The app's bundled, content-hashed stylesheet(s) — the bootstrap's **aggregate** CSS (every
296
+ /** The app's bundled, content-hashed stylesheet(s) - the bootstrap's **aggregate** CSS (every
290
297
  * `import './x.css'` reachable from the app). The complete stylesheet regardless of which file
291
298
  * imported the CSS; the always-safe fallback `createWebApp` links when a route has no per-route entry
292
299
  * in {@link routeStyles}. Omitted when the app imports no CSS. */
293
300
  readonly css?: readonly string[]
294
- /** `routeId → [chain CSS URLs]` — only the stylesheets the matched route's layout chain + own file
301
+ /** `routeId → [chain CSS URLs]` - only the stylesheets the matched route's layout chain + own file
295
302
  * actually use (Bun emits a per-entrypoint CSS bundle per route/layout, with shared-component CSS
296
303
  * inlined into each consumer). `createWebApp` links these instead of the aggregate, so a page ships
297
304
  * only its own CSS. A route is omitted (→ aggregate fallback) when its `[name]` collides with another
298
- * route's basename (ambiguous CSS↔route) or the build emitted orphan shared-chunk CSS — correctness
305
+ * route's basename (ambiguous CSS↔route) or the build emitted orphan shared-chunk CSS - correctness
299
306
  * over minimality. Absent entirely when the app imports no CSS. */
300
307
  readonly routeStyles?: Readonly<Record<string, readonly string[]>>
301
308
  }
@@ -348,10 +355,10 @@ export interface NodeBuiltinFinding {
348
355
  * BFS the metafile import graph for the SHORTEST user-module path that pulls `builtin` into the
349
356
  * bundle, returning it as display labels `[entryFile, …as-written specifiers…, builtin]`. The frontier
350
357
  * starts at every `entryInput` (the route/user entrypoints) so the reported chain begins where the dev
351
- * actually wrote `import` — the actionable root, not an arbitrary internal module. Traversal crosses
358
+ * actually wrote `import` - the actionable root, not an arbitrary internal module. Traversal crosses
352
359
  * only NON-`node:` edges (so Bun's polyfill chain never extends the path) and stops at the first edge
353
360
  * whose target is the builtin. Returns `[builtin]` if no entry reaches it (defensive; a flagged builtin
354
- * is reachable by construction). Pure — operates on the graph, never the emitted text.
361
+ * is reachable by construction). Pure - operates on the graph, never the emitted text.
355
362
  */
356
363
  function shortestBuiltinChain(
357
364
  inputs: Readonly<Record<string, { readonly imports?: readonly BunMetafileImport[] }>>,
@@ -375,7 +382,7 @@ function shortestBuiltinChain(
375
382
  if (nodeBuiltinOf(im) === builtin) return [...chain, builtin]
376
383
  const target = im.path
377
384
  // Only follow edges into user (non-`node:`) modules that exist in the input graph and haven't
378
- // been visited — so the polyfill subtree can't lengthen the path and there are no cycles.
385
+ // been visited - so the polyfill subtree can't lengthen the path and there are no cycles.
379
386
  if (
380
387
  target === undefined ||
381
388
  target.startsWith("node:") ||
@@ -386,24 +393,24 @@ function shortestBuiltinChain(
386
393
  }
387
394
  seen.add(target)
388
395
  // Display the hop by its as-written specifier (`../db/client.ts`, `postgres`), falling back to
389
- // the resolved path — that's what the dev recognizes in their source, not the resolved path.
396
+ // the resolved path - that's what the dev recognizes in their source, not the resolved path.
390
397
  next.push({ node: target, chain: [...chain, im.original ?? target] })
391
398
  }
392
399
  }
393
400
  frontier = next
394
401
  }
395
- return [builtin] // unreachable from any entry — degrade to just the builtin (shouldn't happen)
402
+ return [builtin] // unreachable from any entry - degrade to just the builtin (shouldn't happen)
396
403
  }
397
404
 
398
405
  /**
399
406
  * Scan a build's metafile for any `node:` builtin that a USER module pulled into a CLIENT output
400
407
  * chunk, returning a sorted, deduped list of {@link NodeBuiltinFinding}s. Three graph facts combine so
401
408
  * the report is precise AND actionable:
402
- * 1. **What the user wrote** — only builtins imported by a NON-`node:` input count, so Bun's own
409
+ * 1. **What the user wrote** - only builtins imported by a NON-`node:` input count, so Bun's own
403
410
  * polyfill chain (`node:crypto` → `node:buffer`/`node:stream`/…) doesn't bury the real cause.
404
- * 2. **Where it landed** — the chunk is read from the per-output `inputs`, so the error names the
411
+ * 2. **Where it landed** - the chunk is read from the per-output `inputs`, so the error names the
405
412
  * emitted file to look at.
406
- * 3. **How it got there** — the shortest import chain from a user entry to the builtin
413
+ * 3. **How it got there** - the shortest import chain from a user entry to the builtin
407
414
  * (`shortestBuiltinChain`), so the error points straight at the offending `import` line instead of
408
415
  * leaving the dev to grep the dependency tree (the DX gap this closes).
409
416
  * Graph-based (never the emitted text), so it survives minification and can't be fooled by a string
@@ -413,16 +420,16 @@ export function detectNodeBuiltinsInClient(
413
420
  graph: ClientModuleGraph,
414
421
  ): ReadonlyArray<NodeBuiltinFinding> {
415
422
  const inputs = graph.modules
416
- // (1) The builtins a user (non-polyfill) module imports directly — the ones the author controls.
423
+ // (1) The builtins a user (non-polyfill) module imports directly - the ones the author controls.
417
424
  const userImported = new Set<string>()
418
425
  for (const [inputKey, input] of Object.entries(inputs)) {
419
- if (inputKey.startsWith("node:")) continue // a polyfill importing another builtin — not the cause
426
+ if (inputKey.startsWith("node:")) continue // a polyfill importing another builtin - not the cause
420
427
  for (const im of input.imports ?? []) {
421
428
  const builtin = nodeBuiltinOf(im)
422
429
  if (builtin !== undefined) userImported.add(builtin)
423
430
  }
424
431
  }
425
- // The entry inputs (route/user entrypoints) — the chain BFS starts here so the reported path begins
432
+ // The entry inputs (route/user entrypoints) - the chain BFS starts here so the reported path begins
426
433
  // at the file the dev wrote an `import` in. Each output's `entryPoint` is one such input.
427
434
  const entryInputs = [
428
435
  ...new Set(
@@ -464,8 +471,8 @@ export function detectNodeBuiltinsInClient(
464
471
  // node-builtin guard: a module of PURE server logic with NO `node:` import (a secret-bearing constant,
465
472
  // a server-only API call) that an author wants to FAIL LOUD if it reaches the client opts in with a
466
473
  // side-effect `import "@nifrajs/web/server-only"` (Next's `import "server-only"`). On the SERVER build
467
- // the marker is an empty no-op; the CLIENT build detects — via the SAME Bun metafile graph the
468
- // node-builtin guard walks — any module that imports the marker AND lands in a client chunk, and fails
474
+ // the marker is an empty no-op; the CLIENT build detects - via the SAME Bun metafile graph the
475
+ // node-builtin guard walks - any module that imports the marker AND lands in a client chunk, and fails
469
476
  // the build with the import chain. Graph-based (never the emitted text), so it survives minification.
470
477
  // ---------------------------------------------------------------------------------------------------
471
478
 
@@ -476,7 +483,7 @@ export const SERVER_ONLY_MARKER = "@nifrajs/web/server-only"
476
483
 
477
484
  /** True when an import edge is the `server-only` marker import. Reads the as-written `original` (the
478
485
  * specifier the author typed); falls back to the resolved `path`'s basename so a pre-resolved edge
479
- * (no `original`) — or a workspace-relative resolution — is still recognised. */
486
+ * (no `original`) - or a workspace-relative resolution - is still recognised. */
480
487
  const isServerOnlyMarkerImport = (im: BunMetafileImport): boolean => {
481
488
  if (im.original === SERVER_ONLY_MARKER) return true
482
489
  // Defensive fallback: an edge that lost its `original` but resolved to the marker module file. The
@@ -504,8 +511,8 @@ export interface ServerOnlyFinding {
504
511
  * "<module> (marked server-only)"]`. Mirrors {@link shortestBuiltinChain}: the frontier starts at
505
512
  * every `entryInput` so the chain begins at the file the dev wrote an `import` in; traversal crosses
506
513
  * only NON-`node:` user edges (no cycles via `seen`); it stops at the first node whose import set
507
- * contains the marker (the marked module — the actionable tail), labelling that node by the as-written
508
- * specifier the previous hop used to reach it. Pure — operates on the graph, never the emitted text.
514
+ * contains the marker (the marked module - the actionable tail), labelling that node by the as-written
515
+ * specifier the previous hop used to reach it. Pure - operates on the graph, never the emitted text.
509
516
  */
510
517
  function shortestServerOnlyChain(
511
518
  inputs: Readonly<Record<string, { readonly imports?: readonly BunMetafileImport[] }>>,
@@ -516,7 +523,7 @@ function shortestServerOnlyChain(
516
523
  // The label for the marked module's tail: its as-written specifier (filled when we cross the edge
517
524
  // that reaches it) suffixed with `(marked server-only)`; the entry case uses the entry key itself.
518
525
  const tail = (label: string): string => `${label} (marked server-only)`
519
- // An entry that is ITSELF the marked module — the chain is just that one node.
526
+ // An entry that is ITSELF the marked module - the chain is just that one node.
520
527
  if (entryInputs.includes(markedModule)) return [tail(markedModule)]
521
528
  const seen = new Set<string>(entryInputs)
522
529
  let frontier: Array<{ node: string; chain: string[] }> = entryInputs.map((node) => ({
@@ -527,7 +534,7 @@ function shortestServerOnlyChain(
527
534
  const next: Array<{ node: string; chain: string[] }> = []
528
535
  for (const { node, chain } of frontier) {
529
536
  for (const im of inputs[node]?.imports ?? []) {
530
- // Resolve the edge's `path` to the matching INPUT-GRAPH KEY — in a real build the edge `path`
537
+ // Resolve the edge's `path` to the matching INPUT-GRAPH KEY - in a real build the edge `path`
531
538
  // is absolute while the input keys are cwd-relative, so a raw equality/lookup would miss every
532
539
  // multi-hop user edge (and the chain would degrade to just the tail). The resolver maps both.
533
540
  const target = resolveTarget(im)
@@ -574,16 +581,16 @@ function inputKeyResolver(
574
581
  * Scan a build's metafile for any module that opts into the `server-only` marker (a side-effect
575
582
  * `import "@nifrajs/web/server-only"`) yet landed in a CLIENT output chunk, returning a sorted, deduped
576
583
  * list of {@link ServerOnlyFinding}s. Mirrors {@link detectNodeBuiltinsInClient}: it reads the SAME
577
- * graph facts — which inputs import the marker (the "marked" modules), which chunk each landed in (the
584
+ * graph facts - which inputs import the marker (the "marked" modules), which chunk each landed in (the
578
585
  * per-output `inputs`), and the shortest import chain from a user entry to it. The marker module ITSELF
579
- * (which imports nothing) is excluded — only the modules that *opt in* are flagged. Pure + exported for
586
+ * (which imports nothing) is excluded - only the modules that *opt in* are flagged. Pure + exported for
580
587
  * unit testing. Empty ⇒ clean.
581
588
  */
582
589
  export function detectServerOnlyInClient(
583
590
  graph: ClientModuleGraph,
584
591
  ): ReadonlyArray<ServerOnlyFinding> {
585
592
  const inputs = graph.modules
586
- // (1) The modules that import the marker — the ones the author opted into the guard. The marker
593
+ // (1) The modules that import the marker - the ones the author opted into the guard. The marker
587
594
  // module itself is skipped: it's the import TARGET, not an opt-in (it imports nothing of its own).
588
595
  const marked = new Set<string>()
589
596
  for (const [inputKey, input] of Object.entries(inputs)) {
@@ -591,7 +598,7 @@ export function detectServerOnlyInClient(
591
598
  if ((input.imports ?? []).some(isServerOnlyMarkerImport)) marked.add(inputKey)
592
599
  }
593
600
  if (marked.size === 0) return []
594
- // The entry inputs — the chain BFS starts here, so the reported path begins at the file the dev
601
+ // The entry inputs - the chain BFS starts here, so the reported path begins at the file the dev
595
602
  // wrote an `import` in. Same derivation as the node-builtin guard.
596
603
  const entryInputs = [
597
604
  ...new Set(
@@ -627,12 +634,12 @@ export function detectServerOnlyInClient(
627
634
  }
628
635
 
629
636
  /** True when an INPUT graph key is the marker module file itself (`…/server-only.{ts,js}` under web).
630
- * Excluded from the "marked" set — the marker is the import target, not an opt-in module. */
637
+ * Excluded from the "marked" set - the marker is the import target, not an opt-in module. */
631
638
  const isServerOnlyMarkerModule = (inputKey: string): boolean =>
632
639
  /(^|\/)server-only\.[cm]?[jt]s$/.test(inputKey)
633
640
 
634
641
  // ---------------------------------------------------------------------------------------------------
635
- // Guard MESSAGES — one owner for both production pipelines. The Bun build (below) and the Vite/Rollup
642
+ // Guard MESSAGES - one owner for both production pipelines. The Bun build (below) and the Vite/Rollup
636
643
  // leak-guard plugin (plugins/vite-leak-guard.ts) both call these, so a leak reads IDENTICALLY whichever
637
644
  // bundler produced it. A second bundler must not grow a second, subtly-different wording of a security
638
645
  // error; that is exactly the "mostly ported" outcome the neutral graph seam exists to prevent.
@@ -649,7 +656,7 @@ export function formatNodeBuiltinLeak(
649
656
  : ` - ${f.builtin} reached the client bundle via ${f.chunk}`,
650
657
  )
651
658
  return (
652
- `[nifra/web] Node built-in(s) in the client bundle — move them behind a server-only path ` +
659
+ `[nifra/web] Node built-in(s) in the client bundle - move them behind a server-only path ` +
653
660
  `(a loader/action runs on the server; import the \`node:\` module there, not at a route's ` +
654
661
  `top level):\n${lines.join("\n")}`
655
662
  )
@@ -666,7 +673,7 @@ export function formatServerOnlyLeak(
666
673
  : ` - server-only module reached the client bundle via ${f.chunk}`,
667
674
  )
668
675
  return (
669
- `[nifra/web] server-only module(s) in the client bundle — a module marked ` +
676
+ `[nifra/web] server-only module(s) in the client bundle - a module marked ` +
670
677
  `\`import "${SERVER_ONLY_MARKER}"\` reached the browser. Move it behind a server-only path ` +
671
678
  `(reach it via a loader/action, or rename it \`*.server.ts\`), so its server logic never ships ` +
672
679
  `to the client:\n${lines.join("\n")}`
@@ -697,7 +704,7 @@ export interface ChunkSize {
697
704
  readonly gzip: number
698
705
  }
699
706
 
700
- /** A whole build's size report — every chunk (largest first) + the totals. */
707
+ /** A whole build's size report - every chunk (largest first) + the totals. */
701
708
  export interface SizeReport {
702
709
  /** Per-chunk sizes, sorted biggest gzip first (the regression you want to see at the top). */
703
710
  readonly chunks: readonly ChunkSize[]
@@ -709,7 +716,7 @@ export interface SizeReport {
709
716
 
710
717
  /**
711
718
  * Aggregate a list of measured chunks into a {@link SizeReport}: sort biggest-gzip-first (ties broken
712
- * by raw bytes, then name for stable output) and sum the totals. Pure — the measurement (reading the
719
+ * by raw bytes, then name for stable output) and sum the totals. Pure - the measurement (reading the
713
720
  * file + `Bun.gzipSync`) happens in the orchestrator; this is the deterministic, unit-testable core.
714
721
  */
715
722
  export function aggregateSizeReport(chunks: readonly ChunkSize[]): SizeReport {
@@ -733,7 +740,7 @@ export function formatBytes(bytes: number): string {
733
740
  }
734
741
 
735
742
  /**
736
- * Render a {@link SizeReport} as a terse aligned table (biggest first) with a totals row — the text
743
+ * Render a {@link SizeReport} as a terse aligned table (biggest first) with a totals row - the text
737
744
  * `nifra build --report` prints. Pure (string in, string out) so the formatting is unit-testable.
738
745
  */
739
746
  export function renderSizeReport(report: SizeReport): string {
@@ -762,18 +769,18 @@ export function renderSizeReport(report: SizeReport): string {
762
769
  // ---------------------------------------------------------------------------------------------------
763
770
  // Server-manifest drift detection (#7). `server-manifest.ts` is a committed generated file: it bakes
764
771
  // the route list + the client-entry hash for a disk-less worker (`generateServerManifest`). If `routes/`
765
- // changes but the manifest isn't regenerated, the worker serves a stale route table — a silent edge
772
+ // changes but the manifest isn't regenerated, the worker serves a stale route table - a silent edge
766
773
  // break. These pure helpers diff the COMMITTED manifest source against the freshly-discovered routes so
767
774
  // `nifra check` (and `buildServer`) can fail with a named, actionable error before the drift ships.
768
775
  // ---------------------------------------------------------------------------------------------------
769
776
 
770
777
  /** A drift finding between a committed server-manifest and the live `routes/` tree. */
771
778
  export interface ManifestDrift {
772
- /** Route files present in `routes/` but ABSENT from the committed manifest (the manifest is stale —
779
+ /** Route files present in `routes/` but ABSENT from the committed manifest (the manifest is stale -
773
780
  * the new route won't be served by the worker). */
774
781
  readonly missing: readonly string[]
775
782
  /** Route files the committed manifest imports that no longer exist in `routes/` (a deleted/renamed
776
- * route still wired into the worker — a build/runtime break). */
783
+ * route still wired into the worker - a build/runtime break). */
777
784
  readonly extra: readonly string[]
778
785
  }
779
786
 
@@ -789,7 +796,7 @@ const MANIFEST_CLIENT_ENTRY = /export\s+const\s+clientEntry\s*=\s*["']([^"']+)["
789
796
  * `routes/`-relative keys `discoverRoutes` produces (e.g. `docs/index.tsx`). `routesPrefix` is the
790
797
  * specifier prefix the manifest used for the routes dir (default `./routes/`, what `buildServer`'s
791
798
  * default `resolve` emits). Only import specifiers under that prefix are route files; the
792
- * `@nifrajs/web` import (and any other bare specifier) is ignored. Pure — operates on source text.
799
+ * `@nifrajs/web` import (and any other bare specifier) is ignored. Pure - operates on source text.
793
800
  */
794
801
  export function parseManifestRouteFiles(source: string, routesPrefix = "./routes/"): string[] {
795
802
  const files = new Set<string>()
@@ -871,8 +878,8 @@ export function resyncServerManifestSource(
871
878
 
872
879
  /**
873
880
  * Diff the route files a committed server-manifest imports against the files freshly discovered in
874
- * `routes/`. Returns the `missing` (in routes/, not in manifest — stale manifest) and `extra` (in
875
- * manifest, gone from routes/ — dangling import) sets. Empty arrays ⇒ in sync. Pure — the caller
881
+ * `routes/`. Returns the `missing` (in routes/, not in manifest - stale manifest) and `extra` (in
882
+ * manifest, gone from routes/ - dangling import) sets. Empty arrays ⇒ in sync. Pure - the caller
876
883
  * supplies both file lists (the committed source is parsed via {@link parseManifestRouteFiles}; the
877
884
  * fresh list comes from `discoverRoutes`). Lists need not be pre-sorted; the result is sorted.
878
885
  */
@@ -903,16 +910,16 @@ export function formatManifestDrift(
903
910
  ): string | undefined {
904
911
  if (isManifestInSync(drift)) return undefined
905
912
  const lines: string[] = [
906
- `[nifra/web] server-manifest drift — \`${manifestPath}\` is out of sync with routes/.`,
913
+ `[nifra/web] server-manifest drift - \`${manifestPath}\` is out of sync with routes/.`,
907
914
  ]
908
915
  if (drift.missing.length > 0) {
909
916
  lines.push(
910
- ` Missing (in routes/, not in the manifest — these routes won't be served): ${drift.missing.join(", ")}`,
917
+ ` Missing (in routes/, not in the manifest - these routes won't be served): ${drift.missing.join(", ")}`,
911
918
  )
912
919
  }
913
920
  if (drift.extra.length > 0) {
914
921
  lines.push(
915
- ` Extra (imported by the manifest, gone from routes/ — a dangling import): ${drift.extra.join(", ")}`,
922
+ ` Extra (imported by the manifest, gone from routes/ - a dangling import): ${drift.extra.join(", ")}`,
916
923
  )
917
924
  }
918
925
  lines.push(" Fix: re-run the build to regenerate the server manifest, then commit it.")
@@ -921,7 +928,7 @@ export function formatManifestDrift(
921
928
 
922
929
  /**
923
930
  * Build the client bundle for a file-routed app. Writes the hashed assets + `manifest.json` to
924
- * `outDir` and returns the manifest. Throws (with the bundler logs) on build failure — never
931
+ * `outDir` and returns the manifest. Throws (with the bundler logs) on build failure - never
925
932
  * silently ships a broken bundle.
926
933
  */
927
934
  export async function buildClient(options: BuildClientOptions): Promise<BuildManifest> {
@@ -937,7 +944,7 @@ export async function buildClient(options: BuildClientOptions): Promise<BuildMan
937
944
  // PUBLIC_*-prefixed env → client define (Vite/Next convention). Sourced from the build env (`Bun.env`,
938
945
  // falling back to `process.env`). Only prefixed vars are baked in; unprefixed secrets stay undefined
939
946
  // in the client via the bare `process.env` → `({})` define below. Caller's `options.define` wins (it's
940
- // layered after these in the `define` object). `Bun` may be absent under non-Bun typecheck — guard it.
947
+ // layered after these in the `define` object). `Bun` may be absent under non-Bun typecheck - guard it.
941
948
  const buildEnv = (typeof Bun !== "undefined" ? Bun.env : undefined) ?? process.env
942
949
  const publicDefines = publicEnvDefines(options.publicEnvPrefix ?? "PUBLIC_", buildEnv)
943
950
  // Keep the generated source beside the project, not inside `outDir`: module resolution starts at the
@@ -952,7 +959,7 @@ export async function buildClient(options: BuildClientOptions): Promise<BuildMan
952
959
  `globalThis.process ??= { env: {} };\n${generateClientEntry(routeManifest, { clientModule, resolve })}`,
953
960
  )
954
961
 
955
- // Every unique route/layout/`_404` file (sorted, stable), as ADDITIONAL entrypoints — Bun emits a
962
+ // Every unique route/layout/`_404` file (sorted, stable), as ADDITIONAL entrypoints - Bun emits a
956
963
  // named chunk per file that the bootstrap's lazy `import()` dedupes to (verified), so the manifest
957
964
  // can map each route to its chunk URLs for matched-route preload. `resolve(file)` is the same
958
965
  // specifier the bootstrap imports, so the entrypoint + lazy import are the same module (dedup).
@@ -964,7 +971,7 @@ export async function buildClient(options: BuildClientOptions): Promise<BuildMan
964
971
  ]),
965
972
  ].sort()
966
973
 
967
- // `metafile: true` asks Bun for the input/output graph — specifically `outputs[js].entryPoint`
974
+ // `metafile: true` asks Bun for the input/output graph - specifically `outputs[js].entryPoint`
968
975
  // (the source file) + `outputs[js].cssBundle` (that entry's emitted stylesheet). It's the robust
969
976
  // entry→CSS link for per-route splitting: keyed by the unique source path, so it survives
970
977
  // same-basename collisions (`index.tsx` + `blog/index.tsx`) that a filename match can't. Not yet in
@@ -981,7 +988,7 @@ export async function buildClient(options: BuildClientOptions): Promise<BuildMan
981
988
  splitting: true, // one chunk per lazily-imported route; shared deps deduped into shared chunks
982
989
  // `import "./x.css"` in a route/component → bundled, minified, content-hashed `.css` asset (Bun
983
990
  // strips the import from the JS; CSS bundling is on by default since Bun 1.2). Mapped to routes
984
- // below — both the aggregate and per-route — via the metafile, for `<link>` injection.
991
+ // below - both the aggregate and per-route - via the metafile, for `<link>` injection.
985
992
  ...buildExtras,
986
993
  minify: options.minify ?? true,
987
994
  plugins: [
@@ -989,13 +996,14 @@ export async function buildClient(options: BuildClientOptions): Promise<BuildMan
989
996
  preactDedupePlugin(routesDir),
990
997
  svelteDedupePlugin(routesDir),
991
998
  serverOnlyEmptyPlugin(),
999
+ serverFnStubPlugin(),
992
1000
  ...(options.plugins ?? []),
993
1001
  ],
994
1002
  ...(options.conditions ? { conditions: [...options.conditions] } : {}),
995
1003
  // Replace `process.env.*` at compile time so an app module reading config off `process.env` doesn't
996
1004
  // hit a `process is not defined` crash in the browser. Bun does longest-match: NODE_ENV resolves to
997
1005
  // the build mode (React's prod/dev branch); each PUBLIC_* var resolves to its baked VALUE; every
998
- // other `process.env.X` becomes undefined (the bare `process.env` → `({})` fallback — so secrets
1006
+ // other `process.env.X` becomes undefined (the bare `process.env` → `({})` fallback - so secrets
999
1007
  // never leak). Callers can override any of these via `options.define` (layered last).
1000
1008
  define: {
1001
1009
  "process.env": "({})",
@@ -1016,7 +1024,7 @@ export async function buildClient(options: BuildClientOptions): Promise<BuildMan
1016
1024
 
1017
1025
  // #4: a `node:` builtin (e.g. `node:crypto`) pulled into a CLIENT chunk builds fine (Bun substitutes
1018
1026
  // a browser polyfill) but breaks/leaks at runtime. Fail the build with a named, actionable error
1019
- // instead — caught at build time, not by a confused user in the browser. Graph-based (the metafile's
1027
+ // instead - caught at build time, not by a confused user in the browser. Graph-based (the metafile's
1020
1028
  // per-output `inputs`), so it can't false-positive on a `"node:..."` string literal and survives
1021
1029
  // minification. Only the client build runs this; the server build's `node:` imports are legitimate.
1022
1030
  const clientMeta = (result as unknown as { metafile?: BunMetafile }).metafile
@@ -1027,14 +1035,14 @@ export async function buildClient(options: BuildClientOptions): Promise<BuildMan
1027
1035
  const nodeBuiltinLeak = formatNodeBuiltinLeak(detectNodeBuiltinsInClient(clientGraph))
1028
1036
  if (nodeBuiltinLeak !== undefined) throw new Error(nodeBuiltinLeak)
1029
1037
 
1030
- // §3.3/§5.1: a module that opted into the `server-only` marker yet reached a CLIENT chunk — catches
1038
+ // §3.3/§5.1: a module that opted into the `server-only` marker yet reached a CLIENT chunk - catches
1031
1039
  // pure-server logic (a secret, a server-only API call) carrying no `node:` import and not named
1032
1040
  // `*.server`, so neither other guard fires. Same shared formatter as above.
1033
1041
  const serverOnlyLeak = formatServerOnlyLeak(detectServerOnlyInClient(clientGraph))
1034
1042
  if (serverOnlyLeak !== undefined) throw new Error(serverOnlyLeak)
1035
1043
 
1036
1044
  // Rename any chunk whose basename isn't URL-safe (dynamic-route files become `[slug]-hash.js`) and
1037
- // rewrite the references — otherwise the lazy import 404s and the route silently never hydrates.
1045
+ // rewrite the references - otherwise the lazy import 404s and the route silently never hydrates.
1038
1046
  const renamed = sanitizeOutputNames(result.outputs)
1039
1047
  const toUrl = (path: string): string =>
1040
1048
  `${publicPath}${renamed.get(basename(path)) ?? basename(path)}`
@@ -1051,7 +1059,7 @@ export async function buildClient(options: BuildClientOptions): Promise<BuildMan
1051
1059
  }
1052
1060
  const fileToChunk = new Map<string, string>()
1053
1061
  routeFiles.forEach((file, i) => {
1054
- const out = entryPoints[i + 1] // in range — length checked above
1062
+ const out = entryPoints[i + 1] // in range - length checked above
1055
1063
  if (out !== undefined) fileToChunk.set(file, toUrl(out.path))
1056
1064
  })
1057
1065
  const chunksFor = (chainFiles: readonly string[]): string[] =>
@@ -1065,9 +1073,9 @@ export async function buildClient(options: BuildClientOptions): Promise<BuildMan
1065
1073
  }
1066
1074
  if (routeManifest.notFound) routes._404 = chunksFor([routeManifest.notFound.file])
1067
1075
 
1068
- // CSS — aggregate: an `import "./x.css"` anywhere → a content-hashed `.css` asset (Bun strips the
1076
+ // CSS - aggregate: an `import "./x.css"` anywhere → a content-hashed `.css` asset (Bun strips the
1069
1077
  // import from the JS). The bootstrap lazily imports every route, so its **aggregate** stylesheet is
1070
- // the whole app's CSS — the always-safe fallback `createWebApp` links when a route has no per-route
1078
+ // the whole app's CSS - the always-safe fallback `createWebApp` links when a route has no per-route
1071
1079
  // entry below. Fallback to all CSS assets if Bun emitted no distinct aggregate.
1072
1080
  const bootstrapName = entryName(entryFile) // `_nifra-entry`
1073
1081
  const cssNameOf = (path: string): string => {
@@ -1081,12 +1089,12 @@ export async function buildClient(options: BuildClientOptions): Promise<BuildMan
1081
1089
  const css: readonly string[] =
1082
1090
  aggregate.length > 0 ? aggregate : cssAssets.map((o) => toUrl(o.path))
1083
1091
 
1084
- // CSS — per-route: each route/layout file is its own entrypoint, so the build metafile records its
1085
- // `cssBundle` — exactly the CSS that file's subtree uses (shared-component CSS is inlined into each
1092
+ // CSS - per-route: each route/layout file is its own entrypoint, so the build metafile records its
1093
+ // `cssBundle` - exactly the CSS that file's subtree uses (shared-component CSS is inlined into each
1086
1094
  // consumer; verified). Keyed by the metafile's unique source `entryPoint`, so it survives
1087
1095
  // same-basename collisions (`index.tsx` + `blog/index.tsx`) that a filename match can't. A page then
1088
1096
  // links only its layout chain + own CSS (deduped); an empty array means the page needs no CSS at all.
1089
- // Absent (→ aggregate fallback) only if Bun emits no metafile/cssBundle — never silently incomplete.
1097
+ // Absent (→ aggregate fallback) only if Bun emits no metafile/cssBundle - never silently incomplete.
1090
1098
  const cwd = process.cwd()
1091
1099
  const cssByEntry = new Map<string, string>()
1092
1100
  for (const out of Object.values(clientMeta?.outputs ?? {})) {
@@ -1132,30 +1140,30 @@ export async function buildClient(options: BuildClientOptions): Promise<BuildMan
1132
1140
  export interface BuildServerOptions {
1133
1141
  /** The `routes/` directory to discover (absolute path). */
1134
1142
  readonly routesDir: string
1135
- /** The worker entry module (absolute path) — your `worker.ts`. It imports `{ manifest, clientEntry }`
1143
+ /** The worker entry module (absolute path) - your `worker.ts`. It imports `{ manifest, clientEntry }`
1136
1144
  * from the generated `./server-manifest`, builds `createWebApp`, and `export default toFetchHandler(app)`. */
1137
1145
  readonly serverEntry: string
1138
1146
  /** Output directory for the bundled worker (absolute path). */
1139
1147
  readonly outDir: string
1140
- /** The content-hashed client entry URL (from `buildClient`'s manifest) — **baked** into the generated
1148
+ /** The content-hashed client entry URL (from `buildClient`'s manifest) - **baked** into the generated
1141
1149
  * server manifest, since a disk-less worker can't read `manifest.json` at runtime. */
1142
1150
  readonly clientEntry: string
1143
- /** The app's aggregate stylesheet URLs (`buildClient`'s `BuildManifest.css`) — baked into the generated
1151
+ /** The app's aggregate stylesheet URLs (`buildClient`'s `BuildManifest.css`) - baked into the generated
1144
1152
  * manifest so the server entry hands them to `createWebApp` (→ `<link rel="stylesheet">`). Omit ⇒ no CSS
1145
1153
  * link (the built SSR page would otherwise render unstyled). */
1146
1154
  readonly styles?: readonly string[] | undefined
1147
- /** Per-route stylesheet URLs (`buildClient`'s `BuildManifest.routeStyles`) — baked alongside `styles`. */
1155
+ /** Per-route stylesheet URLs (`buildClient`'s `BuildManifest.routeStyles`) - baked alongside `styles`. */
1148
1156
  readonly routeStyles?: Readonly<Record<string, readonly string[]>> | undefined
1149
1157
  /** Route/layout file → import specifier in the generated manifest (default: a relative path from the
1150
- * manifest's location — written next to `serverEntry` — to `routesDir`). */
1158
+ * manifest's location - written next to `serverEntry` - to `routesDir`). */
1151
1159
  readonly resolve?: (file: string) => string
1152
1160
  /** Filename for the generated server-manifest module, written next to `serverEntry` (default
1153
1161
  * `"server-manifest.ts"`); your `serverEntry` imports it as `./server-manifest`. */
1154
1162
  readonly manifestFile?: string
1155
- /** Adapter build plugins (e.g. `solidBunPlugin("ssr")` — Solid routes need their SSR transform at
1163
+ /** Adapter build plugins (e.g. `solidBunPlugin("ssr")` - Solid routes need their SSR transform at
1156
1164
  * build time; React's JSX is Bun-native and needs none). */
1157
1165
  readonly plugins?: readonly BunPlugin[]
1158
- /** `Bun.build` resolution conditions (default `["workerd", "edge-light", "browser"]`) — selects each
1166
+ /** `Bun.build` resolution conditions (default `["workerd", "edge-light", "browser"]`) - selects each
1159
1167
  * dependency's edge build. */
1160
1168
  readonly conditions?: readonly string[]
1161
1169
  /** Compile-time replacements (default `{ "process.env.NODE_ENV": '"production"' }` → production
@@ -1163,7 +1171,7 @@ export interface BuildServerOptions {
1163
1171
  readonly define?: Readonly<Record<string, string>>
1164
1172
  /** Minify the output (default `true`). */
1165
1173
  readonly minify?: boolean
1166
- /** `Bun.build` target (default `"browser"` — the right shape for edge runtimes: Cloudflare Workers,
1174
+ /** `Bun.build` target (default `"browser"` - the right shape for edge runtimes: Cloudflare Workers,
1167
1175
  * Vercel Edge, Deno, Deno Deploy). Use `"node"` for a `@nifrajs/node` server (so `node:*` built-ins
1168
1176
  * stay external), or `"bun"` for a Bun server. The default `conditions` + the edge resolve shims
1169
1177
  * only apply to the `"browser"` target; `"node"`/`"bun"` resolve their own renderer builds via the
@@ -1172,16 +1180,16 @@ export interface BuildServerOptions {
1172
1180
  /** **Lazy/code-split routes** (default `false`): emit `() => import(route)` loaders + bundle with
1173
1181
  * `splitting`, so each route is its own chunk loaded on first request (smaller cold-start parse)
1174
1182
  * instead of all parsed at boot. The output becomes the worker entry **+ chunk files** in `outDir`
1175
- * — on Cloudflare, ship them with wrangler's `no_bundle` + `find_additional_modules` + an ESModule
1183
+ * - on Cloudflare, ship them with wrangler's `no_bundle` + `find_additional_modules` + an ESModule
1176
1184
  * `rule` (Node/Deno import the chunks natively). Eager (one self-contained file) stays the default. */
1177
1185
  readonly lazy?: boolean
1178
1186
  }
1179
1187
 
1180
- /** The built worker bundle — point your `wrangler.toml`'s `main` at `worker`. */
1188
+ /** The built worker bundle - point your `wrangler.toml`'s `main` at `worker`. */
1181
1189
  export interface ServerBuild {
1182
1190
  /** Path to the bundled, self-contained worker entry. */
1183
1191
  readonly worker: string
1184
- /** Paths of every emitted output (entry + any code-split chunks) — what to ship to the platform. */
1192
+ /** Paths of every emitted output (entry + any code-split chunks) - what to ship to the platform. */
1185
1193
  readonly outputs: readonly string[]
1186
1194
  }
1187
1195
 
@@ -1190,7 +1198,7 @@ export interface ServerBuild {
1190
1198
  * on workerd, and `Bun.build` always applies the `bun` condition (it wins over `workerd`/`edge-light`),
1191
1199
  * so conditions alone can't select the edge build. This shim pins `react-dom/server` to its edge build
1192
1200
  * (`server.edge.js`, which exports `renderToReadableStream`). A no-op when nothing imports react-dom
1193
- * (e.g. a Solid worker) — the resolver only runs on a match.
1201
+ * (e.g. a Solid worker) - the resolver only runs on a match.
1194
1202
  */
1195
1203
  const reactDomEdgePlugin = (from: string): BunPlugin => ({
1196
1204
  name: "nifra-react-dom-edge",
@@ -1203,11 +1211,11 @@ const reactDomEdgePlugin = (from: string): BunPlugin => ({
1203
1211
 
1204
1212
  /**
1205
1213
  * Dedupe React to a single copy. A `file:`-linked package can ship its OWN `react` under its own
1206
- * node_modules, so the bundle ends up with two React cores — each with its own hook dispatcher — and SSR
1214
+ * node_modules, so the bundle ends up with two React cores - each with its own hook dispatcher - and SSR
1207
1215
  * throws the cryptic `null is not an object (evaluating '…H.useState')` (the second renderer's dispatcher
1208
1216
  * is null). This was the #1 time-sink in app builds. Pinning `react` + its JSX runtimes to ONE resolved
1209
1217
  * copy (the app's, from `from`) guarantees a single dispatcher; `react-dom`, which imports `react`, then
1210
- * shares it — so the class can't occur rather than needing a named error. No-op when React isn't used
1218
+ * shares it - so the class can't occur rather than needing a named error. No-op when React isn't used
1211
1219
  * (an unresolvable spec is skipped; the resolver only fires on an exact match). React core is
1212
1220
  * condition-agnostic, so pinning it doesn't disturb the edge/browser/server conditions that select
1213
1221
  * react-dom's build.
@@ -1221,7 +1229,7 @@ export const reactDedupePlugin = (from: string): BunPlugin => ({
1221
1229
  try {
1222
1230
  resolved = Bun.resolveSync(spec, from)
1223
1231
  } catch {
1224
- continue // React (or this subpath) isn't resolvable here — nothing to dedupe
1232
+ continue // React (or this subpath) isn't resolvable here - nothing to dedupe
1225
1233
  }
1226
1234
  const escaped = spec.replace(/[/\\^$*+?.()|[\]{}]/g, "\\$&")
1227
1235
  build.onResolve({ filter: new RegExp(`^${escaped}$`) }, () => ({ path: resolved }))
@@ -1230,14 +1238,14 @@ export const reactDedupePlugin = (from: string): BunPlugin => ({
1230
1238
  })
1231
1239
 
1232
1240
  /**
1233
- * Dedupe Preact to a single copy — the Preact analogue of `reactDedupePlugin`, closing the same class of
1241
+ * Dedupe Preact to a single copy - the Preact analogue of `reactDedupePlugin`, closing the same class of
1234
1242
  * bug for the Preact framework (which had NO build-time dedup before). A `file:`-linked package can ship
1235
1243
  * its OWN `preact`, so the bundle ends up with two Preact cores; since `preact-render-to-string` mutates
1236
1244
  * `preact`'s shared `options` global and `preact/hooks` writes the SAME global, two copies → two `options`
1237
1245
  * → SSR throws `undefined is not an object (… __H)` (the vnode's hook state was set up on the other copy).
1238
1246
  * Pinning `preact` + its hooks/compat/jsx subpaths to ONE resolved copy (the app's, from `from`) makes the
1239
1247
  * renderer and the components share one core. No-op when Preact isn't used (an unresolvable spec is
1240
- * skipped). Preact core is condition-agnostic, so pinning it doesn't disturb any condition selection — and
1248
+ * skipped). Preact core is condition-agnostic, so pinning it doesn't disturb any condition selection - and
1241
1249
  * unlike react-dom there is no edge-vs-server build to preserve, so pinning the subpaths is safe.
1242
1250
  */
1243
1251
  const PREACT_DEDUPE_SPECS = [
@@ -1255,7 +1263,7 @@ export const preactDedupePlugin = (from: string): BunPlugin => ({
1255
1263
  try {
1256
1264
  resolved = Bun.resolveSync(spec, from)
1257
1265
  } catch {
1258
- continue // Preact (or this subpath) isn't resolvable here — nothing to dedupe
1266
+ continue // Preact (or this subpath) isn't resolvable here - nothing to dedupe
1259
1267
  }
1260
1268
  const escaped = spec.replace(/[/\\^$*+?.()|[\]{}]/g, "\\$&")
1261
1269
  build.onResolve({ filter: new RegExp(`^${escaped}$`) }, () => ({ path: resolved }))
@@ -1264,10 +1272,10 @@ export const preactDedupePlugin = (from: string): BunPlugin => ({
1264
1272
  })
1265
1273
 
1266
1274
  /**
1267
- * Dedupe Svelte to a single copy — the Svelte analogue of `reactDedupePlugin`/`preactDedupePlugin`, closing
1275
+ * Dedupe Svelte to a single copy - the Svelte analogue of `reactDedupePlugin`/`preactDedupePlugin`, closing
1268
1276
  * the same class of bug for Svelte (which had NO build-time dedup before). A workspace- or file-linked
1269
1277
  * `@nifrajs/web-svelte` can resolve its OWN `svelte` (e.g. a sibling repo's install store) while the app's
1270
- * components resolve another — SAME version, two physical copies. Svelte 5's client runtime
1278
+ * components resolve another - SAME version, two physical copies. Svelte 5's client runtime
1271
1279
  * (`svelte/internal/client`) holds module-level component-context state, so two copies means the compiled
1272
1280
  * components register on one runtime while `hydrate` runs on the other → hydration throws
1273
1281
  * `Cannot read properties of undefined (reading 'call')` and the server-rendered markup is wiped.
@@ -1285,7 +1293,7 @@ export const svelteDedupePlugin = (from: string): BunPlugin => ({
1285
1293
  try {
1286
1294
  return { path: Bun.resolveSync(args.path, from) }
1287
1295
  } catch {
1288
- return undefined // not resolvable from the app root — leave Bun's default resolution
1296
+ return undefined // not resolvable from the app root - leave Bun's default resolution
1289
1297
  }
1290
1298
  })
1291
1299
  },
@@ -1293,19 +1301,43 @@ export const svelteDedupePlugin = (from: string): BunPlugin => ({
1293
1301
 
1294
1302
  /**
1295
1303
  * Remix-style `.server` convention for the CLIENT build. A module named `*.server.ts(x)` (`db.server.ts`,
1296
- * `auth.server.ts`, …) is server-only — empty it in the browser bundle so its (possibly `node:` / native /
1304
+ * `auth.server.ts`, …) is server-only - empty it in the browser bundle so its (possibly `node:` / native /
1297
1305
  * Capacitor) import subtree never reaches the client. The body is CJS-with-a-Proxy so any named OR default
1298
1306
  * import resolves to `undefined` rather than a "missing export" bundle error (verified), and the real
1299
1307
  * import subtree is gone. The complement to the node-builtin guard: when a server-only import is co-located
1300
1308
  * in a route file (so it can't be tree-shaken out and the guard fails loud), moving it into a `*.server`
1301
- * module is the fix. CLIENT-only — buildServer keeps the real module, which runs server-side.
1309
+ * module is the fix. CLIENT-only - buildServer keeps the real module, which runs server-side.
1302
1310
  */
1303
- const SERVER_ONLY_MODULE = /\.server(\.[cm]?[jt]sx?)?$/
1304
1311
  export const serverOnlyEmptyPlugin = (): BunPlugin => ({
1305
1312
  name: "nifra-server-only-empty",
1306
1313
  setup(build) {
1307
1314
  build.onLoad({ filter: SERVER_ONLY_MODULE }, () => ({
1308
- contents: "module.exports = new Proxy({}, { get: () => undefined })",
1315
+ contents: SERVER_ONLY_REPLACEMENT,
1316
+ loader: "js",
1317
+ }))
1318
+ },
1319
+ })
1320
+
1321
+ /**
1322
+ * Server functions in the CLIENT build: replace each `*.fn.ts` module with stubs that call the routes
1323
+ * the server mounted, so the function bodies - and everything they import - never reach a browser.
1324
+ *
1325
+ * The sibling of {@link serverOnlyEmptyPlugin}, and a deliberate contrast: a `*.server` module is
1326
+ * EMPTIED because nothing may call it from the client, while a `*.fn` module is REPLACED because the
1327
+ * client is supposed to call it, just over HTTP. The generation itself is in
1328
+ * `internal/server-boundary.ts` so the Vite pipeline emits identical stubs from the same code; two
1329
+ * hand-written copies would be a client that works in dev and 404s in production.
1330
+ *
1331
+ * CLIENT-only. The server build keeps the real module, which is what `serverFunctions()` mounts.
1332
+ */
1333
+ export const serverFnStubPlugin = (): BunPlugin => ({
1334
+ name: "nifra-server-fn-stub",
1335
+ setup(build) {
1336
+ build.onLoad({ filter: SERVER_FN_MODULE }, async (args) => ({
1337
+ contents: generateServerFnStub(
1338
+ await Bun.file(args.path).text(),
1339
+ serverFnNamespace(args.path),
1340
+ ),
1309
1341
  loader: "js",
1310
1342
  }))
1311
1343
  },
@@ -1336,7 +1368,7 @@ const solidWebServerPlugin = (from: string): BunPlugin => ({
1336
1368
  * (`generateServerManifest`, written next to `serverEntry`), then bundles `serverEntry` with
1337
1369
  * `Bun.build` using **edge conditions** + the adapter's SSR plugins. The output imports no `node:fs`
1338
1370
  * and does no dynamic-path import, so it runs on workerd: point `wrangler.toml`'s `main` at it and
1339
- * serve the client assets via Workers Assets. Throws (with the bundler logs) on failure — never
1371
+ * serve the client assets via Workers Assets. Throws (with the bundler logs) on failure - never
1340
1372
  * silently ships a broken worker.
1341
1373
  */
1342
1374
  export async function buildServer(options: BuildServerOptions): Promise<ServerBuild> {
@@ -1353,7 +1385,7 @@ export async function buildServer(options: BuildServerOptions): Promise<ServerBu
1353
1385
  // Edge (browser) target: Bun's `bun` condition contaminates react-dom's server build + the `worker`
1354
1386
  // condition segfaults Bun.build on solid, so force the edge/server builds via shims. The `node`/`bun`
1355
1387
  // targets resolve those correctly via their own condition, so the shims (and edge conditions) don't
1356
- // apply — defaults become `[target]` (e.g. react-dom → server.node.js under `node`).
1388
+ // apply - defaults become `[target]` (e.g. react-dom → server.node.js under `node`).
1357
1389
  const edge = target === "browser"
1358
1390
  const conditions = options.conditions ?? (edge ? ["workerd", "edge-light", "browser"] : [target])
1359
1391
  const manifest = discoverRoutes(routesDir)
@@ -1372,10 +1404,10 @@ export async function buildServer(options: BuildServerOptions): Promise<ServerBu
1372
1404
  // Tag every BUNDLED SSR output so @nifrajs/web-react's react-dom adapter takes the static
1373
1405
  // (bundled, deduped) react-dom instead of re-rooting react-dom/server to a DISK copy at runtime.
1374
1406
  // A `target:"bun"` bundle still has `Bun.resolveSync` under the Bun runtime, so without this tag the
1375
- // adapter re-imports a SECOND react-dom from node_modules — a second React core whose hook dispatcher
1407
+ // adapter re-imports a SECOND react-dom from node_modules - a second React core whose hook dispatcher
1376
1408
  // is null for the bundled components → SSR throws `…H.useRef of null`. Always set (a structural fact
1377
1409
  // of bundling, layered after any caller `define` so it can't be overridden). Unbundled Bun runtimes
1378
- // (nifra dev/start, nifra_render) never define it, so they still re-root — the dev dual-install fix.
1410
+ // (nifra dev/start, nifra_render) never define it, so they still re-root - the dev dual-install fix.
1379
1411
  "process.env.NIFRA_SSR_BUNDLED": '"1"',
1380
1412
  "globalThis.__NIFRA_EDGE_RUNTIME__": edge ? "true" : "false",
1381
1413
  },
@@ -1402,12 +1434,12 @@ export async function buildServer(options: BuildServerOptions): Promise<ServerBu
1402
1434
  }
1403
1435
 
1404
1436
  // ===================================================================================================
1405
- // `nifra build --target` — package the engine above into one command that emits a full deploy dir.
1437
+ // `nifra build --target` - package the engine above into one command that emits a full deploy dir.
1406
1438
  //
1407
1439
  // An app already declares everything the build needs through nifra's conventions: `adapter` +
1408
1440
  // `clientModule` (nifra.config.ts / framework.ts), an optional `backend` (backend.ts), and `routes/`.
1409
1441
  // The ONLY thing apps used to hand-write per target was the server entry (`_worker.ts`, `server-bun.ts`,
1410
- // …) — so we GENERATE it here (per target) instead of asking each app to ship five near-identical files.
1442
+ // …) - so we GENERATE it here (per target) instead of asking each app to ship five near-identical files.
1411
1443
  // ===================================================================================================
1412
1444
 
1413
1445
  /** A deploy target `nifra build --target <t>` can emit. `static` is pure SSG (no server). */
@@ -1428,7 +1460,7 @@ export function isBuildTarget(value: string): value is BuildTarget {
1428
1460
  * - `deno`: same fetch-handler default, plus `Deno.serve` self-host when run directly.
1429
1461
  * - `bun` / `node`: a self-hosting server that ALSO serves the client bundle from disk (those
1430
1462
  * runtimes have a filesystem; the static `/assets/*` sit next to the entry).
1431
- * `adapterImport`/`backendImport` are the specifiers the entry uses (relative to where it's written) —
1463
+ * `adapterImport`/`backendImport` are the specifiers the entry uses (relative to where it's written) -
1432
1464
  * `buildServer` writes the entry next to `serverEntry`, so they're resolved from there. Pure (string in,
1433
1465
  * string out) so the generation is unit-testable without a real build.
1434
1466
  */
@@ -1499,7 +1531,7 @@ export function generateServerEntry(options: {
1499
1531
  }
1500
1532
  if (target === "vercel") {
1501
1533
  lines.push(
1502
- "// Vercel Edge Function — Vercel serves /assets/* from its CDN; this only SSRs page routes.",
1534
+ "// Vercel Edge Function - Vercel serves /assets/* from its CDN; this only SSRs page routes.",
1503
1535
  'export const config = { runtime: "edge" }',
1504
1536
  "export default (req: Request): Response | Promise<Response> => app.fetch(req)",
1505
1537
  )
@@ -1541,7 +1573,7 @@ export function generateServerEntry(options: {
1541
1573
  " },",
1542
1574
  "})",
1543
1575
  // The `${...}` here is literal OUTPUT (a template in the GENERATED file), not a template in this
1544
- // source — split so biome's noTemplateCurlyInString doesn't flag it; the emitted line is unchanged.
1576
+ // source - split so biome's noTemplateCurlyInString doesn't flag it; the emitted line is unchanged.
1545
1577
  `console.log(\`nifra (Bun) → http://localhost:$${"{server.port}"}\`)`,
1546
1578
  )
1547
1579
  return `${lines.join("\n")}\n`
@@ -1574,13 +1606,13 @@ export function generateServerEntry(options: {
1574
1606
  // deno
1575
1607
  lines.push(
1576
1608
  "const handler = toFetchHandler(app)",
1577
- "// @ts-ignore — Deno global is present on the Deno runtime this output targets.",
1609
+ "// @ts-ignore - Deno global is present on the Deno runtime this output targets.",
1578
1610
  'Deno.serve({ port: Number(Deno.env.get("PORT") ?? "3000") }, async (req) => {',
1579
1611
  " const { pathname } = new URL(req.url)",
1580
1612
  " const filePath = staticPath(pathname)",
1581
1613
  " if (filePath !== undefined) {",
1582
1614
  " try {",
1583
- " // @ts-ignore — Deno.readFile is present on the Deno runtime.",
1615
+ " // @ts-ignore - Deno.readFile is present on the Deno runtime.",
1584
1616
  " const body = await Deno.readFile(new URL(filePath, STATIC_ROOT))",
1585
1617
  ' const ext = pathname.slice(pathname.lastIndexOf(".") + 1)',
1586
1618
  ' return new Response(body, { headers: { "content-type": TYPES[ext] ?? "application/octet-stream" } })',
@@ -1633,7 +1665,7 @@ export interface BuildTargetOptions {
1633
1665
  readonly adapterImport: string
1634
1666
  /** Import specifier (resolvable from `workDir`) of the module exporting `backend`, or `undefined`. */
1635
1667
  readonly backendImport?: string
1636
- /** Factory that builds the app for `static` prerendering, GIVEN the client build's manifest — so the
1668
+ /** Factory that builds the app for `static` prerendering, GIVEN the client build's manifest - so the
1637
1669
  * emitted hydration `<script src>` uses the REAL content-hashed entry (`client.entry`) plus the same
1638
1670
  * styles/route-preload the server targets use. A pre-built instance can't work here: the hash isn't known
1639
1671
  * until `buildClient` runs inside `buildTarget`, so a hardcoded entry 404s → pages render but never
@@ -1655,12 +1687,12 @@ export interface BuildTargetOptions {
1655
1687
  readonly title?: string
1656
1688
  }
1657
1689
 
1658
- /** Minimal app surface `buildTarget`'s static path needs — a fetch handler (a built `createWebApp`). */
1690
+ /** Minimal app surface `buildTarget`'s static path needs - a fetch handler (a built `createWebApp`). */
1659
1691
  export interface PrerenderAppLike {
1660
1692
  fetch(req: Request): Response | Promise<Response>
1661
1693
  }
1662
1694
 
1663
- /** The result of a target build — the deploy dir + the client manifest + an optional size report. */
1695
+ /** The result of a target build - the deploy dir + the client manifest + an optional size report. */
1664
1696
  export interface BuildTargetResult {
1665
1697
  /** The deploy target that was built. */
1666
1698
  readonly target: BuildTarget
@@ -1681,7 +1713,7 @@ export interface BuildTargetResult {
1681
1713
  * - `static`: prerenders opted-in routes (`prerenderRoutes`) to `<outDir>/<path>/index.html` (+
1682
1714
  * `_data.json`); needs `prerenderApp`. No server.
1683
1715
  * - `cf-pages`: a `_worker.js` (edge bundle) + a `_routes.json` excluding /assets/* from the worker.
1684
- * - `vercel`: a `.vercel/output`-shaped function isn't emitted here — `vercel` emits the bundled edge
1716
+ * - `vercel`: a `.vercel/output`-shaped function isn't emitted here - `vercel` emits the bundled edge
1685
1717
  * entry as `<outDir>/index.js` (the CLI's docs point at `vercel`'s Build Output wrapper). [see note]
1686
1718
  * - `deno`/`node`/`bun`: the self-hosting server bundle (`server.js`) next to the assets.
1687
1719
  * The server entry is GENERATED (`generateServerEntry`) and bundled (`buildServer`); the app supplies
@@ -1691,7 +1723,7 @@ export interface BuildTargetResult {
1691
1723
  * remain app-owned scripts; this command targets the common single-bundle deploys. See the CLI docs.
1692
1724
  */
1693
1725
  /**
1694
- * A build-tool STRATEGY for {@link buildTargetWith} — the two bundling steps, and nothing else. Everything
1726
+ * A build-tool STRATEGY for {@link buildTargetWith} - the two bundling steps, and nothing else. Everything
1695
1727
  * around them (server-entry codegen, deploy assembly, prerender, size report) is bundler-agnostic and
1696
1728
  * lives in `buildTargetWith`, so a second bundler (Vite) is this interface, not a second orchestrator.
1697
1729
  *
@@ -1725,7 +1757,7 @@ export interface Bundler {
1725
1757
  }): Promise<ServerBuild>
1726
1758
  }
1727
1759
 
1728
- /** The default (Bun) strategy — `buildClient`/`buildServer` from this module. */
1760
+ /** The default (Bun) strategy - `buildClient`/`buildServer` from this module. */
1729
1761
  export const bunBundler: Bundler = {
1730
1762
  buildClient: (input) =>
1731
1763
  buildClient({
@@ -1818,9 +1850,9 @@ export async function buildTargetWith(
1818
1850
  })
1819
1851
  if (result.prerendered.length === 0) {
1820
1852
  // A static build that renders nothing is almost always a misconfig (no `prerender = true` / no
1821
- // getStaticPaths) — fail loudly rather than ship an empty dir the dev thinks is their site.
1853
+ // getStaticPaths) - fail loudly rather than ship an empty dir the dev thinks is their site.
1822
1854
  throw new Error(
1823
- "[nifra/web] buildTarget(static): no routes were prerendered — opt routes in with " +
1855
+ "[nifra/web] buildTarget(static): no routes were prerendered - opt routes in with " +
1824
1856
  "`export const prerender = true` (static) or `getStaticPaths` (dynamic).",
1825
1857
  )
1826
1858
  }