@nifrajs/web 2.2.0 → 2.3.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 (163) 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-error.d.ts +2 -2
  23. package/dist/dev-error.js +3 -3
  24. package/dist/dev-port.d.ts +2 -2
  25. package/dist/dev-port.js +1 -1
  26. package/dist/dev.d.ts +3 -3
  27. package/dist/dev.js +9 -9
  28. package/dist/draft.d.ts +12 -12
  29. package/dist/draft.js +11 -11
  30. package/dist/fn-state.d.ts +56 -0
  31. package/dist/fn-state.d.ts.map +1 -0
  32. package/dist/fn-state.js +77 -0
  33. package/dist/fn-state.js.map +1 -0
  34. package/dist/fn.d.ts +111 -0
  35. package/dist/fn.d.ts.map +1 -0
  36. package/dist/fn.js +125 -0
  37. package/dist/fn.js.map +1 -0
  38. package/dist/fonts-google.d.ts +9 -9
  39. package/dist/fonts-google.js +10 -10
  40. package/dist/fonts.d.ts +7 -7
  41. package/dist/fonts.js +1 -1
  42. package/dist/forms.d.ts +8 -8
  43. package/dist/fs.d.ts +1 -1
  44. package/dist/fs.js +1 -1
  45. package/dist/index.d.ts +151 -70
  46. package/dist/index.d.ts.map +1 -1
  47. package/dist/index.js +317 -139
  48. package/dist/index.js.map +1 -1
  49. package/dist/internal/head-attributes.d.ts +15 -0
  50. package/dist/internal/head-attributes.d.ts.map +1 -0
  51. package/dist/internal/head-attributes.js +99 -0
  52. package/dist/internal/head-attributes.js.map +1 -0
  53. package/dist/internal/script-types.d.ts +14 -0
  54. package/dist/internal/script-types.d.ts.map +1 -0
  55. package/dist/internal/script-types.js +17 -0
  56. package/dist/internal/script-types.js.map +1 -0
  57. package/dist/internal/server-boundary.d.ts +60 -0
  58. package/dist/internal/server-boundary.d.ts.map +1 -0
  59. package/dist/internal/server-boundary.js +174 -0
  60. package/dist/internal/server-boundary.js.map +1 -0
  61. package/dist/internal/timing-safe-equal.d.ts +1 -1
  62. package/dist/internal/timing-safe-equal.js +1 -1
  63. package/dist/internal/vite-import.d.ts +63 -0
  64. package/dist/internal/vite-import.d.ts.map +1 -0
  65. package/dist/internal/vite-import.js +94 -0
  66. package/dist/internal/vite-import.js.map +1 -0
  67. package/dist/islands.d.ts +5 -5
  68. package/dist/islands.js +5 -5
  69. package/dist/isr.d.ts +15 -15
  70. package/dist/isr.js +7 -7
  71. package/dist/manifest.d.ts +73 -35
  72. package/dist/manifest.d.ts.map +1 -1
  73. package/dist/manifest.js +10 -16
  74. package/dist/manifest.js.map +1 -1
  75. package/dist/module-graph.d.ts +5 -5
  76. package/dist/module-graph.js +4 -4
  77. package/dist/navigation.d.ts +120 -5
  78. package/dist/navigation.d.ts.map +1 -1
  79. package/dist/navigation.js +37 -5
  80. package/dist/navigation.js.map +1 -1
  81. package/dist/plugins/css-modules.d.ts +3 -3
  82. package/dist/plugins/css-modules.js +0 -0
  83. package/dist/plugins/kit.d.ts +8 -8
  84. package/dist/plugins/kit.js +9 -9
  85. package/dist/plugins/scss.js +3 -3
  86. package/dist/plugins/vite-leak-guard.d.ts +1 -1
  87. package/dist/plugins/vite-leak-guard.js +1 -1
  88. package/dist/plugins/vite-server-fn.d.ts +23 -0
  89. package/dist/plugins/vite-server-fn.d.ts.map +1 -0
  90. package/dist/plugins/vite-server-fn.js +51 -0
  91. package/dist/plugins/vite-server-fn.js.map +1 -0
  92. package/dist/plugins/vite-server-only.d.ts +49 -0
  93. package/dist/plugins/vite-server-only.d.ts.map +1 -0
  94. package/dist/plugins/vite-server-only.js +57 -0
  95. package/dist/plugins/vite-server-only.js.map +1 -0
  96. package/dist/prerender.d.ts +5 -5
  97. package/dist/prerender.js +7 -7
  98. package/dist/query.d.ts +10 -10
  99. package/dist/query.js +15 -15
  100. package/dist/route-manifest.d.ts +1 -1
  101. package/dist/route-manifest.js +2 -2
  102. package/dist/router.d.ts +30 -21
  103. package/dist/router.d.ts.map +1 -1
  104. package/dist/router.js +57 -28
  105. package/dist/router.js.map +1 -1
  106. package/dist/search.d.ts +94 -0
  107. package/dist/search.d.ts.map +1 -0
  108. package/dist/search.js +232 -0
  109. package/dist/search.js.map +1 -0
  110. package/dist/server-only.d.ts +6 -6
  111. package/dist/server-only.js +7 -7
  112. package/dist/service-worker.d.ts +56 -0
  113. package/dist/service-worker.d.ts.map +1 -0
  114. package/dist/service-worker.js +127 -0
  115. package/dist/service-worker.js.map +1 -0
  116. package/dist/vite.d.ts +15 -13
  117. package/dist/vite.d.ts.map +1 -1
  118. package/dist/vite.js +31 -21
  119. package/dist/vite.js.map +1 -1
  120. package/package.json +33 -4
  121. package/server-manifest.d.ts +31 -0
  122. package/src/build-vite.ts +24 -77
  123. package/src/build.ts +137 -105
  124. package/src/bun-dev-entry.ts +1 -1
  125. package/src/chunk-names.ts +1 -1
  126. package/src/client.ts +215 -32
  127. package/src/conformance.ts +3 -3
  128. package/src/deferred.ts +17 -17
  129. package/src/dev-error.ts +3 -3
  130. package/src/dev-port.ts +2 -2
  131. package/src/dev.ts +12 -12
  132. package/src/draft.ts +15 -15
  133. package/src/fn-state.ts +107 -0
  134. package/src/fn.ts +199 -0
  135. package/src/fonts-google.ts +13 -13
  136. package/src/fonts.ts +7 -7
  137. package/src/forms.ts +8 -8
  138. package/src/fs.ts +2 -2
  139. package/src/index.ts +460 -181
  140. package/src/internal/head-attributes.ts +109 -0
  141. package/src/internal/script-types.ts +18 -0
  142. package/src/internal/server-boundary.ts +187 -0
  143. package/src/internal/timing-safe-equal.ts +1 -1
  144. package/src/internal/vite-import.ts +92 -0
  145. package/src/islands.ts +7 -7
  146. package/src/isr.ts +17 -17
  147. package/src/manifest.ts +83 -42
  148. package/src/module-graph.ts +6 -6
  149. package/src/navigation.ts +151 -5
  150. package/src/plugins/css-modules.ts +0 -0
  151. package/src/plugins/kit.ts +11 -11
  152. package/src/plugins/scss.ts +3 -3
  153. package/src/plugins/vite-leak-guard.ts +2 -2
  154. package/src/plugins/vite-server-fn.ts +67 -0
  155. package/src/plugins/vite-server-only.ts +75 -0
  156. package/src/prerender.ts +10 -10
  157. package/src/query.ts +22 -22
  158. package/src/route-manifest.ts +3 -3
  159. package/src/router.ts +91 -41
  160. package/src/search.ts +297 -0
  161. package/src/server-only.ts +7 -7
  162. package/src/service-worker.ts +165 -0
  163. package/src/vite.ts +41 -29
package/dist/build.js 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,7 @@ import { Glob } from "bun";
12
12
  import { sanitizeOutputNames } from "./chunk-names.js";
13
13
  import { discoverRoutes } from "./fs.js";
14
14
  import { generateClientEntry, generateServerManifest } from "./index.js";
15
+ import { generateServerFnStub, SERVER_FN_MODULE, SERVER_ONLY_MODULE, SERVER_ONLY_REPLACEMENT, serverFnNamespace, } from "./internal/server-boundary.js";
15
16
  // `buildTarget(static)` drives the SSG prerender engine directly (it's also re-exported below).
16
17
  import { fromBunMetafile } from "./module-graph.js";
17
18
  import { prerenderRoutes } from "./prerender.js";
@@ -156,7 +157,7 @@ routePatterns) {
156
157
  }
157
158
  return { include, exclude, omitted };
158
159
  }
159
- /** The built asset map — the server reads `entry` for the client script + serves `assets`. */
160
+ /** The built asset map - the server reads `entry` for the client script + serves `assets`. */
160
161
  /**
161
162
  * Copy `from` into `to`, returning the URL paths copied (sorted).
162
163
  *
@@ -201,10 +202,10 @@ const nodeBuiltinOf = (im) => {
201
202
  * BFS the metafile import graph for the SHORTEST user-module path that pulls `builtin` into the
202
203
  * bundle, returning it as display labels `[entryFile, …as-written specifiers…, builtin]`. The frontier
203
204
  * starts at every `entryInput` (the route/user entrypoints) so the reported chain begins where the dev
204
- * actually wrote `import` — the actionable root, not an arbitrary internal module. Traversal crosses
205
+ * actually wrote `import` - the actionable root, not an arbitrary internal module. Traversal crosses
205
206
  * only NON-`node:` edges (so Bun's polyfill chain never extends the path) and stops at the first edge
206
207
  * whose target is the builtin. Returns `[builtin]` if no entry reaches it (defensive; a flagged builtin
207
- * is reachable by construction). Pure — operates on the graph, never the emitted text.
208
+ * is reachable by construction). Pure - operates on the graph, never the emitted text.
208
209
  */
209
210
  function shortestBuiltinChain(inputs, entryInputs, builtin) {
210
211
  // Each queue item carries the resolved-path node + the display chain to reach it (entry + the
@@ -225,7 +226,7 @@ function shortestBuiltinChain(inputs, entryInputs, builtin) {
225
226
  return [...chain, builtin];
226
227
  const target = im.path;
227
228
  // Only follow edges into user (non-`node:`) modules that exist in the input graph and haven't
228
- // been visited — so the polyfill subtree can't lengthen the path and there are no cycles.
229
+ // been visited - so the polyfill subtree can't lengthen the path and there are no cycles.
229
230
  if (target === undefined ||
230
231
  target.startsWith("node:") ||
231
232
  inputs[target] === undefined ||
@@ -234,23 +235,23 @@ function shortestBuiltinChain(inputs, entryInputs, builtin) {
234
235
  }
235
236
  seen.add(target);
236
237
  // Display the hop by its as-written specifier (`../db/client.ts`, `postgres`), falling back to
237
- // the resolved path — that's what the dev recognizes in their source, not the resolved path.
238
+ // the resolved path - that's what the dev recognizes in their source, not the resolved path.
238
239
  next.push({ node: target, chain: [...chain, im.original ?? target] });
239
240
  }
240
241
  }
241
242
  frontier = next;
242
243
  }
243
- return [builtin]; // unreachable from any entry — degrade to just the builtin (shouldn't happen)
244
+ return [builtin]; // unreachable from any entry - degrade to just the builtin (shouldn't happen)
244
245
  }
245
246
  /**
246
247
  * Scan a build's metafile for any `node:` builtin that a USER module pulled into a CLIENT output
247
248
  * chunk, returning a sorted, deduped list of {@link NodeBuiltinFinding}s. Three graph facts combine so
248
249
  * the report is precise AND actionable:
249
- * 1. **What the user wrote** — only builtins imported by a NON-`node:` input count, so Bun's own
250
+ * 1. **What the user wrote** - only builtins imported by a NON-`node:` input count, so Bun's own
250
251
  * polyfill chain (`node:crypto` → `node:buffer`/`node:stream`/…) doesn't bury the real cause.
251
- * 2. **Where it landed** — the chunk is read from the per-output `inputs`, so the error names the
252
+ * 2. **Where it landed** - the chunk is read from the per-output `inputs`, so the error names the
252
253
  * emitted file to look at.
253
- * 3. **How it got there** — the shortest import chain from a user entry to the builtin
254
+ * 3. **How it got there** - the shortest import chain from a user entry to the builtin
254
255
  * (`shortestBuiltinChain`), so the error points straight at the offending `import` line instead of
255
256
  * leaving the dev to grep the dependency tree (the DX gap this closes).
256
257
  * Graph-based (never the emitted text), so it survives minification and can't be fooled by a string
@@ -258,18 +259,18 @@ function shortestBuiltinChain(inputs, entryInputs, builtin) {
258
259
  */
259
260
  export function detectNodeBuiltinsInClient(graph) {
260
261
  const inputs = graph.modules;
261
- // (1) The builtins a user (non-polyfill) module imports directly — the ones the author controls.
262
+ // (1) The builtins a user (non-polyfill) module imports directly - the ones the author controls.
262
263
  const userImported = new Set();
263
264
  for (const [inputKey, input] of Object.entries(inputs)) {
264
265
  if (inputKey.startsWith("node:"))
265
- continue; // a polyfill importing another builtin — not the cause
266
+ continue; // a polyfill importing another builtin - not the cause
266
267
  for (const im of input.imports ?? []) {
267
268
  const builtin = nodeBuiltinOf(im);
268
269
  if (builtin !== undefined)
269
270
  userImported.add(builtin);
270
271
  }
271
272
  }
272
- // The entry inputs (route/user entrypoints) — the chain BFS starts here so the reported path begins
273
+ // The entry inputs (route/user entrypoints) - the chain BFS starts here so the reported path begins
273
274
  // at the file the dev wrote an `import` in. Each output's `entryPoint` is one such input.
274
275
  const entryInputs = [
275
276
  ...new Set(Object.values(graph.chunks)
@@ -307,8 +308,8 @@ export function detectNodeBuiltinsInClient(graph) {
307
308
  // node-builtin guard: a module of PURE server logic with NO `node:` import (a secret-bearing constant,
308
309
  // a server-only API call) that an author wants to FAIL LOUD if it reaches the client opts in with a
309
310
  // side-effect `import "@nifrajs/web/server-only"` (Next's `import "server-only"`). On the SERVER build
310
- // the marker is an empty no-op; the CLIENT build detects — via the SAME Bun metafile graph the
311
- // node-builtin guard walks — any module that imports the marker AND lands in a client chunk, and fails
311
+ // the marker is an empty no-op; the CLIENT build detects - via the SAME Bun metafile graph the
312
+ // node-builtin guard walks - any module that imports the marker AND lands in a client chunk, and fails
312
313
  // the build with the import chain. Graph-based (never the emitted text), so it survives minification.
313
314
  // ---------------------------------------------------------------------------------------------------
314
315
  /** The marker specifier an author imports to opt a module into the client-leak guard. Matched on the
@@ -317,7 +318,7 @@ export function detectNodeBuiltinsInClient(graph) {
317
318
  export const SERVER_ONLY_MARKER = "@nifrajs/web/server-only";
318
319
  /** True when an import edge is the `server-only` marker import. Reads the as-written `original` (the
319
320
  * specifier the author typed); falls back to the resolved `path`'s basename so a pre-resolved edge
320
- * (no `original`) — or a workspace-relative resolution — is still recognised. */
321
+ * (no `original`) - or a workspace-relative resolution - is still recognised. */
321
322
  const isServerOnlyMarkerImport = (im) => {
322
323
  if (im.original === SERVER_ONLY_MARKER)
323
324
  return true;
@@ -332,14 +333,14 @@ const isServerOnlyMarkerImport = (im) => {
332
333
  * "<module> (marked server-only)"]`. Mirrors {@link shortestBuiltinChain}: the frontier starts at
333
334
  * every `entryInput` so the chain begins at the file the dev wrote an `import` in; traversal crosses
334
335
  * only NON-`node:` user edges (no cycles via `seen`); it stops at the first node whose import set
335
- * contains the marker (the marked module — the actionable tail), labelling that node by the as-written
336
- * specifier the previous hop used to reach it. Pure — operates on the graph, never the emitted text.
336
+ * contains the marker (the marked module - the actionable tail), labelling that node by the as-written
337
+ * specifier the previous hop used to reach it. Pure - operates on the graph, never the emitted text.
337
338
  */
338
339
  function shortestServerOnlyChain(inputs, entryInputs, markedModule, resolveTarget) {
339
340
  // The label for the marked module's tail: its as-written specifier (filled when we cross the edge
340
341
  // that reaches it) suffixed with `(marked server-only)`; the entry case uses the entry key itself.
341
342
  const tail = (label) => `${label} (marked server-only)`;
342
- // An entry that is ITSELF the marked module — the chain is just that one node.
343
+ // An entry that is ITSELF the marked module - the chain is just that one node.
343
344
  if (entryInputs.includes(markedModule))
344
345
  return [tail(markedModule)];
345
346
  const seen = new Set(entryInputs);
@@ -351,7 +352,7 @@ function shortestServerOnlyChain(inputs, entryInputs, markedModule, resolveTarge
351
352
  const next = [];
352
353
  for (const { node, chain } of frontier) {
353
354
  for (const im of inputs[node]?.imports ?? []) {
354
- // Resolve the edge's `path` to the matching INPUT-GRAPH KEY — in a real build the edge `path`
355
+ // Resolve the edge's `path` to the matching INPUT-GRAPH KEY - in a real build the edge `path`
355
356
  // is absolute while the input keys are cwd-relative, so a raw equality/lookup would miss every
356
357
  // multi-hop user edge (and the chain would degrade to just the tail). The resolver maps both.
357
358
  const target = resolveTarget(im);
@@ -400,14 +401,14 @@ function inputKeyResolver(inputs) {
400
401
  * Scan a build's metafile for any module that opts into the `server-only` marker (a side-effect
401
402
  * `import "@nifrajs/web/server-only"`) yet landed in a CLIENT output chunk, returning a sorted, deduped
402
403
  * list of {@link ServerOnlyFinding}s. Mirrors {@link detectNodeBuiltinsInClient}: it reads the SAME
403
- * graph facts — which inputs import the marker (the "marked" modules), which chunk each landed in (the
404
+ * graph facts - which inputs import the marker (the "marked" modules), which chunk each landed in (the
404
405
  * per-output `inputs`), and the shortest import chain from a user entry to it. The marker module ITSELF
405
- * (which imports nothing) is excluded — only the modules that *opt in* are flagged. Pure + exported for
406
+ * (which imports nothing) is excluded - only the modules that *opt in* are flagged. Pure + exported for
406
407
  * unit testing. Empty ⇒ clean.
407
408
  */
408
409
  export function detectServerOnlyInClient(graph) {
409
410
  const inputs = graph.modules;
410
- // (1) The modules that import the marker — the ones the author opted into the guard. The marker
411
+ // (1) The modules that import the marker - the ones the author opted into the guard. The marker
411
412
  // module itself is skipped: it's the import TARGET, not an opt-in (it imports nothing of its own).
412
413
  const marked = new Set();
413
414
  for (const [inputKey, input] of Object.entries(inputs)) {
@@ -418,7 +419,7 @@ export function detectServerOnlyInClient(graph) {
418
419
  }
419
420
  if (marked.size === 0)
420
421
  return [];
421
- // The entry inputs — the chain BFS starts here, so the reported path begins at the file the dev
422
+ // The entry inputs - the chain BFS starts here, so the reported path begins at the file the dev
422
423
  // wrote an `import` in. Same derivation as the node-builtin guard.
423
424
  const entryInputs = [
424
425
  ...new Set(Object.values(graph.chunks)
@@ -452,10 +453,10 @@ export function detectServerOnlyInClient(graph) {
452
453
  });
453
454
  }
454
455
  /** True when an INPUT graph key is the marker module file itself (`…/server-only.{ts,js}` under web).
455
- * Excluded from the "marked" set — the marker is the import target, not an opt-in module. */
456
+ * Excluded from the "marked" set - the marker is the import target, not an opt-in module. */
456
457
  const isServerOnlyMarkerModule = (inputKey) => /(^|\/)server-only\.[cm]?[jt]s$/.test(inputKey);
457
458
  // ---------------------------------------------------------------------------------------------------
458
- // Guard MESSAGES — one owner for both production pipelines. The Bun build (below) and the Vite/Rollup
459
+ // Guard MESSAGES - one owner for both production pipelines. The Bun build (below) and the Vite/Rollup
459
460
  // leak-guard plugin (plugins/vite-leak-guard.ts) both call these, so a leak reads IDENTICALLY whichever
460
461
  // bundler produced it. A second bundler must not grow a second, subtly-different wording of a security
461
462
  // error; that is exactly the "mostly ported" outcome the neutral graph seam exists to prevent.
@@ -467,7 +468,7 @@ export function formatNodeBuiltinLeak(findings) {
467
468
  const lines = findings.map((f) => f.chain.length > 1
468
469
  ? ` - ${f.builtin} reached the client bundle via ${f.chain.join(" → ")} (chunk: ${f.chunk})`
469
470
  : ` - ${f.builtin} reached the client bundle via ${f.chunk}`);
470
- return (`[nifra/web] Node built-in(s) in the client bundle — move them behind a server-only path ` +
471
+ return (`[nifra/web] Node built-in(s) in the client bundle - move them behind a server-only path ` +
471
472
  `(a loader/action runs on the server; import the \`node:\` module there, not at a route's ` +
472
473
  `top level):\n${lines.join("\n")}`);
473
474
  }
@@ -478,7 +479,7 @@ export function formatServerOnlyLeak(findings) {
478
479
  const lines = findings.map((f) => f.chain.length > 1
479
480
  ? ` - server-only module reached the client bundle via ${f.chain.join(" → ")} (chunk: ${f.chunk})`
480
481
  : ` - server-only module reached the client bundle via ${f.chunk}`);
481
- return (`[nifra/web] server-only module(s) in the client bundle — a module marked ` +
482
+ return (`[nifra/web] server-only module(s) in the client bundle - a module marked ` +
482
483
  `\`import "${SERVER_ONLY_MARKER}"\` reached the browser. Move it behind a server-only path ` +
483
484
  `(reach it via a loader/action, or rename it \`*.server.ts\`), so its server logic never ships ` +
484
485
  `to the client:\n${lines.join("\n")}`);
@@ -492,7 +493,7 @@ const entryName = (file) => {
492
493
  };
493
494
  /**
494
495
  * Aggregate a list of measured chunks into a {@link SizeReport}: sort biggest-gzip-first (ties broken
495
- * by raw bytes, then name for stable output) and sum the totals. Pure — the measurement (reading the
496
+ * by raw bytes, then name for stable output) and sum the totals. Pure - the measurement (reading the
496
497
  * file + `Bun.gzipSync`) happens in the orchestrator; this is the deterministic, unit-testable core.
497
498
  */
498
499
  export function aggregateSizeReport(chunks) {
@@ -514,7 +515,7 @@ export function formatBytes(bytes) {
514
515
  return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
515
516
  }
516
517
  /**
517
- * Render a {@link SizeReport} as a terse aligned table (biggest first) with a totals row — the text
518
+ * Render a {@link SizeReport} as a terse aligned table (biggest first) with a totals row - the text
518
519
  * `nifra build --report` prints. Pure (string in, string out) so the formatting is unit-testable.
519
520
  */
520
521
  export function renderSizeReport(report) {
@@ -549,7 +550,7 @@ const MANIFEST_CLIENT_ENTRY = /export\s+const\s+clientEntry\s*=\s*["']([^"']+)["
549
550
  * `routes/`-relative keys `discoverRoutes` produces (e.g. `docs/index.tsx`). `routesPrefix` is the
550
551
  * specifier prefix the manifest used for the routes dir (default `./routes/`, what `buildServer`'s
551
552
  * default `resolve` emits). Only import specifiers under that prefix are route files; the
552
- * `@nifrajs/web` import (and any other bare specifier) is ignored. Pure — operates on source text.
553
+ * `@nifrajs/web` import (and any other bare specifier) is ignored. Pure - operates on source text.
553
554
  */
554
555
  export function parseManifestRouteFiles(source, routesPrefix = "./routes/") {
555
556
  const files = new Set();
@@ -625,8 +626,8 @@ export function resyncServerManifestSource(source, manifest, routesPrefix) {
625
626
  }
626
627
  /**
627
628
  * Diff the route files a committed server-manifest imports against the files freshly discovered in
628
- * `routes/`. Returns the `missing` (in routes/, not in manifest — stale manifest) and `extra` (in
629
- * manifest, gone from routes/ — dangling import) sets. Empty arrays ⇒ in sync. Pure — the caller
629
+ * `routes/`. Returns the `missing` (in routes/, not in manifest - stale manifest) and `extra` (in
630
+ * manifest, gone from routes/ - dangling import) sets. Empty arrays ⇒ in sync. Pure - the caller
630
631
  * supplies both file lists (the committed source is parsed via {@link parseManifestRouteFiles}; the
631
632
  * fresh list comes from `discoverRoutes`). Lists need not be pre-sorted; the result is sorted.
632
633
  */
@@ -650,20 +651,20 @@ export function formatManifestDrift(drift, manifestPath = "server-manifest.ts")
650
651
  if (isManifestInSync(drift))
651
652
  return undefined;
652
653
  const lines = [
653
- `[nifra/web] server-manifest drift — \`${manifestPath}\` is out of sync with routes/.`,
654
+ `[nifra/web] server-manifest drift - \`${manifestPath}\` is out of sync with routes/.`,
654
655
  ];
655
656
  if (drift.missing.length > 0) {
656
- lines.push(` Missing (in routes/, not in the manifest — these routes won't be served): ${drift.missing.join(", ")}`);
657
+ lines.push(` Missing (in routes/, not in the manifest - these routes won't be served): ${drift.missing.join(", ")}`);
657
658
  }
658
659
  if (drift.extra.length > 0) {
659
- lines.push(` Extra (imported by the manifest, gone from routes/ — a dangling import): ${drift.extra.join(", ")}`);
660
+ lines.push(` Extra (imported by the manifest, gone from routes/ - a dangling import): ${drift.extra.join(", ")}`);
660
661
  }
661
662
  lines.push(" Fix: re-run the build to regenerate the server manifest, then commit it.");
662
663
  return lines.join("\n");
663
664
  }
664
665
  /**
665
666
  * Build the client bundle for a file-routed app. Writes the hashed assets + `manifest.json` to
666
- * `outDir` and returns the manifest. Throws (with the bundler logs) on build failure — never
667
+ * `outDir` and returns the manifest. Throws (with the bundler logs) on build failure - never
667
668
  * silently ships a broken bundle.
668
669
  */
669
670
  export async function buildClient(options) {
@@ -678,7 +679,7 @@ export async function buildClient(options) {
678
679
  // PUBLIC_*-prefixed env → client define (Vite/Next convention). Sourced from the build env (`Bun.env`,
679
680
  // falling back to `process.env`). Only prefixed vars are baked in; unprefixed secrets stay undefined
680
681
  // in the client via the bare `process.env` → `({})` define below. Caller's `options.define` wins (it's
681
- // layered after these in the `define` object). `Bun` may be absent under non-Bun typecheck — guard it.
682
+ // layered after these in the `define` object). `Bun` may be absent under non-Bun typecheck - guard it.
682
683
  const buildEnv = (typeof Bun !== "undefined" ? Bun.env : undefined) ?? process.env;
683
684
  const publicDefines = publicEnvDefines(options.publicEnvPrefix ?? "PUBLIC_", buildEnv);
684
685
  // Keep the generated source beside the project, not inside `outDir`: module resolution starts at the
@@ -689,7 +690,7 @@ export async function buildClient(options) {
689
690
  // A client bundle has no Node `process`; provide a minimal one so a stray bare `process` reference in
690
691
  // an app module doesn't crash hydration (`process.env.*` reads are handled at compile time by `define`).
691
692
  writeFileSync(entryFile, `globalThis.process ??= { env: {} };\n${generateClientEntry(routeManifest, { clientModule, resolve })}`);
692
- // Every unique route/layout/`_404` file (sorted, stable), as ADDITIONAL entrypoints — Bun emits a
693
+ // Every unique route/layout/`_404` file (sorted, stable), as ADDITIONAL entrypoints - Bun emits a
693
694
  // named chunk per file that the bootstrap's lazy `import()` dedupes to (verified), so the manifest
694
695
  // can map each route to its chunk URLs for matched-route preload. `resolve(file)` is the same
695
696
  // specifier the bootstrap imports, so the entrypoint + lazy import are the same module (dedup).
@@ -700,7 +701,7 @@ export async function buildClient(options) {
700
701
  ...(routeManifest.notFound ? [routeManifest.notFound.file] : []),
701
702
  ]),
702
703
  ].sort();
703
- // `metafile: true` asks Bun for the input/output graph — specifically `outputs[js].entryPoint`
704
+ // `metafile: true` asks Bun for the input/output graph - specifically `outputs[js].entryPoint`
704
705
  // (the source file) + `outputs[js].cssBundle` (that entry's emitted stylesheet). It's the robust
705
706
  // entry→CSS link for per-route splitting: keyed by the unique source path, so it survives
706
707
  // same-basename collisions (`index.tsx` + `blog/index.tsx`) that a filename match can't. Not yet in
@@ -717,7 +718,7 @@ export async function buildClient(options) {
717
718
  splitting: true, // one chunk per lazily-imported route; shared deps deduped into shared chunks
718
719
  // `import "./x.css"` in a route/component → bundled, minified, content-hashed `.css` asset (Bun
719
720
  // strips the import from the JS; CSS bundling is on by default since Bun 1.2). Mapped to routes
720
- // below — both the aggregate and per-route — via the metafile, for `<link>` injection.
721
+ // below - both the aggregate and per-route - via the metafile, for `<link>` injection.
721
722
  ...buildExtras,
722
723
  minify: options.minify ?? true,
723
724
  plugins: [
@@ -725,13 +726,14 @@ export async function buildClient(options) {
725
726
  preactDedupePlugin(routesDir),
726
727
  svelteDedupePlugin(routesDir),
727
728
  serverOnlyEmptyPlugin(),
729
+ serverFnStubPlugin(),
728
730
  ...(options.plugins ?? []),
729
731
  ],
730
732
  ...(options.conditions ? { conditions: [...options.conditions] } : {}),
731
733
  // Replace `process.env.*` at compile time so an app module reading config off `process.env` doesn't
732
734
  // hit a `process is not defined` crash in the browser. Bun does longest-match: NODE_ENV resolves to
733
735
  // the build mode (React's prod/dev branch); each PUBLIC_* var resolves to its baked VALUE; every
734
- // other `process.env.X` becomes undefined (the bare `process.env` → `({})` fallback — so secrets
736
+ // other `process.env.X` becomes undefined (the bare `process.env` → `({})` fallback - so secrets
735
737
  // never leak). Callers can override any of these via `options.define` (layered last).
736
738
  define: {
737
739
  "process.env": "({})",
@@ -750,7 +752,7 @@ export async function buildClient(options) {
750
752
  }
751
753
  // #4: a `node:` builtin (e.g. `node:crypto`) pulled into a CLIENT chunk builds fine (Bun substitutes
752
754
  // a browser polyfill) but breaks/leaks at runtime. Fail the build with a named, actionable error
753
- // instead — caught at build time, not by a confused user in the browser. Graph-based (the metafile's
755
+ // instead - caught at build time, not by a confused user in the browser. Graph-based (the metafile's
754
756
  // per-output `inputs`), so it can't false-positive on a `"node:..."` string literal and survives
755
757
  // minification. Only the client build runs this; the server build's `node:` imports are legitimate.
756
758
  const clientMeta = result.metafile;
@@ -761,14 +763,14 @@ export async function buildClient(options) {
761
763
  const nodeBuiltinLeak = formatNodeBuiltinLeak(detectNodeBuiltinsInClient(clientGraph));
762
764
  if (nodeBuiltinLeak !== undefined)
763
765
  throw new Error(nodeBuiltinLeak);
764
- // §3.3/§5.1: a module that opted into the `server-only` marker yet reached a CLIENT chunk — catches
766
+ // §3.3/§5.1: a module that opted into the `server-only` marker yet reached a CLIENT chunk - catches
765
767
  // pure-server logic (a secret, a server-only API call) carrying no `node:` import and not named
766
768
  // `*.server`, so neither other guard fires. Same shared formatter as above.
767
769
  const serverOnlyLeak = formatServerOnlyLeak(detectServerOnlyInClient(clientGraph));
768
770
  if (serverOnlyLeak !== undefined)
769
771
  throw new Error(serverOnlyLeak);
770
772
  // Rename any chunk whose basename isn't URL-safe (dynamic-route files become `[slug]-hash.js`) and
771
- // rewrite the references — otherwise the lazy import 404s and the route silently never hydrates.
773
+ // rewrite the references - otherwise the lazy import 404s and the route silently never hydrates.
772
774
  const renamed = sanitizeOutputNames(result.outputs);
773
775
  const toUrl = (path) => `${publicPath}${renamed.get(basename(path)) ?? basename(path)}`;
774
776
  // Entry-point outputs come back in entrypoint order: [bootstrap, ...routeFiles]. Map each route file
@@ -783,7 +785,7 @@ export async function buildClient(options) {
783
785
  }
784
786
  const fileToChunk = new Map();
785
787
  routeFiles.forEach((file, i) => {
786
- const out = entryPoints[i + 1]; // in range — length checked above
788
+ const out = entryPoints[i + 1]; // in range - length checked above
787
789
  if (out !== undefined)
788
790
  fileToChunk.set(file, toUrl(out.path));
789
791
  });
@@ -797,9 +799,9 @@ export async function buildClient(options) {
797
799
  }
798
800
  if (routeManifest.notFound)
799
801
  routes._404 = chunksFor([routeManifest.notFound.file]);
800
- // CSS — aggregate: an `import "./x.css"` anywhere → a content-hashed `.css` asset (Bun strips the
802
+ // CSS - aggregate: an `import "./x.css"` anywhere → a content-hashed `.css` asset (Bun strips the
801
803
  // import from the JS). The bootstrap lazily imports every route, so its **aggregate** stylesheet is
802
- // the whole app's CSS — the always-safe fallback `createWebApp` links when a route has no per-route
804
+ // the whole app's CSS - the always-safe fallback `createWebApp` links when a route has no per-route
803
805
  // entry below. Fallback to all CSS assets if Bun emitted no distinct aggregate.
804
806
  const bootstrapName = entryName(entryFile); // `_nifra-entry`
805
807
  const cssNameOf = (path) => {
@@ -811,12 +813,12 @@ export async function buildClient(options) {
811
813
  .filter((o) => cssNameOf(o.path) === bootstrapName)
812
814
  .map((o) => toUrl(o.path));
813
815
  const css = aggregate.length > 0 ? aggregate : cssAssets.map((o) => toUrl(o.path));
814
- // CSS — per-route: each route/layout file is its own entrypoint, so the build metafile records its
815
- // `cssBundle` — exactly the CSS that file's subtree uses (shared-component CSS is inlined into each
816
+ // CSS - per-route: each route/layout file is its own entrypoint, so the build metafile records its
817
+ // `cssBundle` - exactly the CSS that file's subtree uses (shared-component CSS is inlined into each
816
818
  // consumer; verified). Keyed by the metafile's unique source `entryPoint`, so it survives
817
819
  // same-basename collisions (`index.tsx` + `blog/index.tsx`) that a filename match can't. A page then
818
820
  // links only its layout chain + own CSS (deduped); an empty array means the page needs no CSS at all.
819
- // Absent (→ aggregate fallback) only if Bun emits no metafile/cssBundle — never silently incomplete.
821
+ // Absent (→ aggregate fallback) only if Bun emits no metafile/cssBundle - never silently incomplete.
820
822
  const cwd = process.cwd();
821
823
  const cssByEntry = new Map();
822
824
  for (const out of Object.values(clientMeta?.outputs ?? {})) {
@@ -861,7 +863,7 @@ export async function buildClient(options) {
861
863
  * on workerd, and `Bun.build` always applies the `bun` condition (it wins over `workerd`/`edge-light`),
862
864
  * so conditions alone can't select the edge build. This shim pins `react-dom/server` to its edge build
863
865
  * (`server.edge.js`, which exports `renderToReadableStream`). A no-op when nothing imports react-dom
864
- * (e.g. a Solid worker) — the resolver only runs on a match.
866
+ * (e.g. a Solid worker) - the resolver only runs on a match.
865
867
  */
866
868
  const reactDomEdgePlugin = (from) => ({
867
869
  name: "nifra-react-dom-edge",
@@ -873,11 +875,11 @@ const reactDomEdgePlugin = (from) => ({
873
875
  });
874
876
  /**
875
877
  * Dedupe React to a single copy. A `file:`-linked package can ship its OWN `react` under its own
876
- * node_modules, so the bundle ends up with two React cores — each with its own hook dispatcher — and SSR
878
+ * node_modules, so the bundle ends up with two React cores - each with its own hook dispatcher - and SSR
877
879
  * throws the cryptic `null is not an object (evaluating '…H.useState')` (the second renderer's dispatcher
878
880
  * is null). This was the #1 time-sink in app builds. Pinning `react` + its JSX runtimes to ONE resolved
879
881
  * copy (the app's, from `from`) guarantees a single dispatcher; `react-dom`, which imports `react`, then
880
- * shares it — so the class can't occur rather than needing a named error. No-op when React isn't used
882
+ * shares it - so the class can't occur rather than needing a named error. No-op when React isn't used
881
883
  * (an unresolvable spec is skipped; the resolver only fires on an exact match). React core is
882
884
  * condition-agnostic, so pinning it doesn't disturb the edge/browser/server conditions that select
883
885
  * react-dom's build.
@@ -892,7 +894,7 @@ export const reactDedupePlugin = (from) => ({
892
894
  resolved = Bun.resolveSync(spec, from);
893
895
  }
894
896
  catch {
895
- continue; // React (or this subpath) isn't resolvable here — nothing to dedupe
897
+ continue; // React (or this subpath) isn't resolvable here - nothing to dedupe
896
898
  }
897
899
  const escaped = spec.replace(/[/\\^$*+?.()|[\]{}]/g, "\\$&");
898
900
  build.onResolve({ filter: new RegExp(`^${escaped}$`) }, () => ({ path: resolved }));
@@ -900,14 +902,14 @@ export const reactDedupePlugin = (from) => ({
900
902
  },
901
903
  });
902
904
  /**
903
- * Dedupe Preact to a single copy — the Preact analogue of `reactDedupePlugin`, closing the same class of
905
+ * Dedupe Preact to a single copy - the Preact analogue of `reactDedupePlugin`, closing the same class of
904
906
  * bug for the Preact framework (which had NO build-time dedup before). A `file:`-linked package can ship
905
907
  * its OWN `preact`, so the bundle ends up with two Preact cores; since `preact-render-to-string` mutates
906
908
  * `preact`'s shared `options` global and `preact/hooks` writes the SAME global, two copies → two `options`
907
909
  * → SSR throws `undefined is not an object (… __H)` (the vnode's hook state was set up on the other copy).
908
910
  * Pinning `preact` + its hooks/compat/jsx subpaths to ONE resolved copy (the app's, from `from`) makes the
909
911
  * renderer and the components share one core. No-op when Preact isn't used (an unresolvable spec is
910
- * skipped). Preact core is condition-agnostic, so pinning it doesn't disturb any condition selection — and
912
+ * skipped). Preact core is condition-agnostic, so pinning it doesn't disturb any condition selection - and
911
913
  * unlike react-dom there is no edge-vs-server build to preserve, so pinning the subpaths is safe.
912
914
  */
913
915
  const PREACT_DEDUPE_SPECS = [
@@ -926,7 +928,7 @@ export const preactDedupePlugin = (from) => ({
926
928
  resolved = Bun.resolveSync(spec, from);
927
929
  }
928
930
  catch {
929
- continue; // Preact (or this subpath) isn't resolvable here — nothing to dedupe
931
+ continue; // Preact (or this subpath) isn't resolvable here - nothing to dedupe
930
932
  }
931
933
  const escaped = spec.replace(/[/\\^$*+?.()|[\]{}]/g, "\\$&");
932
934
  build.onResolve({ filter: new RegExp(`^${escaped}$`) }, () => ({ path: resolved }));
@@ -934,10 +936,10 @@ export const preactDedupePlugin = (from) => ({
934
936
  },
935
937
  });
936
938
  /**
937
- * Dedupe Svelte to a single copy — the Svelte analogue of `reactDedupePlugin`/`preactDedupePlugin`, closing
939
+ * Dedupe Svelte to a single copy - the Svelte analogue of `reactDedupePlugin`/`preactDedupePlugin`, closing
938
940
  * the same class of bug for Svelte (which had NO build-time dedup before). A workspace- or file-linked
939
941
  * `@nifrajs/web-svelte` can resolve its OWN `svelte` (e.g. a sibling repo's install store) while the app's
940
- * components resolve another — SAME version, two physical copies. Svelte 5's client runtime
942
+ * components resolve another - SAME version, two physical copies. Svelte 5's client runtime
941
943
  * (`svelte/internal/client`) holds module-level component-context state, so two copies means the compiled
942
944
  * components register on one runtime while `hydrate` runs on the other → hydration throws
943
945
  * `Cannot read properties of undefined (reading 'call')` and the server-rendered markup is wiped.
@@ -956,26 +958,46 @@ export const svelteDedupePlugin = (from) => ({
956
958
  return { path: Bun.resolveSync(args.path, from) };
957
959
  }
958
960
  catch {
959
- return undefined; // not resolvable from the app root — leave Bun's default resolution
961
+ return undefined; // not resolvable from the app root - leave Bun's default resolution
960
962
  }
961
963
  });
962
964
  },
963
965
  });
964
966
  /**
965
967
  * Remix-style `.server` convention for the CLIENT build. A module named `*.server.ts(x)` (`db.server.ts`,
966
- * `auth.server.ts`, …) is server-only — empty it in the browser bundle so its (possibly `node:` / native /
968
+ * `auth.server.ts`, …) is server-only - empty it in the browser bundle so its (possibly `node:` / native /
967
969
  * Capacitor) import subtree never reaches the client. The body is CJS-with-a-Proxy so any named OR default
968
970
  * import resolves to `undefined` rather than a "missing export" bundle error (verified), and the real
969
971
  * import subtree is gone. The complement to the node-builtin guard: when a server-only import is co-located
970
972
  * in a route file (so it can't be tree-shaken out and the guard fails loud), moving it into a `*.server`
971
- * module is the fix. CLIENT-only — buildServer keeps the real module, which runs server-side.
973
+ * module is the fix. CLIENT-only - buildServer keeps the real module, which runs server-side.
972
974
  */
973
- const SERVER_ONLY_MODULE = /\.server(\.[cm]?[jt]sx?)?$/;
974
975
  export const serverOnlyEmptyPlugin = () => ({
975
976
  name: "nifra-server-only-empty",
976
977
  setup(build) {
977
978
  build.onLoad({ filter: SERVER_ONLY_MODULE }, () => ({
978
- contents: "module.exports = new Proxy({}, { get: () => undefined })",
979
+ contents: SERVER_ONLY_REPLACEMENT,
980
+ loader: "js",
981
+ }));
982
+ },
983
+ });
984
+ /**
985
+ * Server functions in the CLIENT build: replace each `*.fn.ts` module with stubs that call the routes
986
+ * the server mounted, so the function bodies - and everything they import - never reach a browser.
987
+ *
988
+ * The sibling of {@link serverOnlyEmptyPlugin}, and a deliberate contrast: a `*.server` module is
989
+ * EMPTIED because nothing may call it from the client, while a `*.fn` module is REPLACED because the
990
+ * client is supposed to call it, just over HTTP. The generation itself is in
991
+ * `internal/server-boundary.ts` so the Vite pipeline emits identical stubs from the same code; two
992
+ * hand-written copies would be a client that works in dev and 404s in production.
993
+ *
994
+ * CLIENT-only. The server build keeps the real module, which is what `serverFunctions()` mounts.
995
+ */
996
+ export const serverFnStubPlugin = () => ({
997
+ name: "nifra-server-fn-stub",
998
+ setup(build) {
999
+ build.onLoad({ filter: SERVER_FN_MODULE }, async (args) => ({
1000
+ contents: generateServerFnStub(await Bun.file(args.path).text(), serverFnNamespace(args.path)),
979
1001
  loader: "js",
980
1002
  }));
981
1003
  },
@@ -1004,7 +1026,7 @@ const solidWebServerPlugin = (from) => ({
1004
1026
  * (`generateServerManifest`, written next to `serverEntry`), then bundles `serverEntry` with
1005
1027
  * `Bun.build` using **edge conditions** + the adapter's SSR plugins. The output imports no `node:fs`
1006
1028
  * and does no dynamic-path import, so it runs on workerd: point `wrangler.toml`'s `main` at it and
1007
- * serve the client assets via Workers Assets. Throws (with the bundler logs) on failure — never
1029
+ * serve the client assets via Workers Assets. Throws (with the bundler logs) on failure - never
1008
1030
  * silently ships a broken worker.
1009
1031
  */
1010
1032
  export async function buildServer(options) {
@@ -1020,7 +1042,7 @@ export async function buildServer(options) {
1020
1042
  // Edge (browser) target: Bun's `bun` condition contaminates react-dom's server build + the `worker`
1021
1043
  // condition segfaults Bun.build on solid, so force the edge/server builds via shims. The `node`/`bun`
1022
1044
  // targets resolve those correctly via their own condition, so the shims (and edge conditions) don't
1023
- // apply — defaults become `[target]` (e.g. react-dom → server.node.js under `node`).
1045
+ // apply - defaults become `[target]` (e.g. react-dom → server.node.js under `node`).
1024
1046
  const edge = target === "browser";
1025
1047
  const conditions = options.conditions ?? (edge ? ["workerd", "edge-light", "browser"] : [target]);
1026
1048
  const manifest = discoverRoutes(routesDir);
@@ -1035,10 +1057,10 @@ export async function buildServer(options) {
1035
1057
  // Tag every BUNDLED SSR output so @nifrajs/web-react's react-dom adapter takes the static
1036
1058
  // (bundled, deduped) react-dom instead of re-rooting react-dom/server to a DISK copy at runtime.
1037
1059
  // A `target:"bun"` bundle still has `Bun.resolveSync` under the Bun runtime, so without this tag the
1038
- // adapter re-imports a SECOND react-dom from node_modules — a second React core whose hook dispatcher
1060
+ // adapter re-imports a SECOND react-dom from node_modules - a second React core whose hook dispatcher
1039
1061
  // is null for the bundled components → SSR throws `…H.useRef of null`. Always set (a structural fact
1040
1062
  // of bundling, layered after any caller `define` so it can't be overridden). Unbundled Bun runtimes
1041
- // (nifra dev/start, nifra_render) never define it, so they still re-root — the dev dual-install fix.
1063
+ // (nifra dev/start, nifra_render) never define it, so they still re-root - the dev dual-install fix.
1042
1064
  "process.env.NIFRA_SSR_BUNDLED": '"1"',
1043
1065
  "globalThis.__NIFRA_EDGE_RUNTIME__": edge ? "true" : "false",
1044
1066
  },
@@ -1062,12 +1084,12 @@ export async function buildServer(options) {
1062
1084
  return { worker: entryOutput.path, outputs: result.outputs.map((o) => o.path) };
1063
1085
  }
1064
1086
  // ===================================================================================================
1065
- // `nifra build --target` — package the engine above into one command that emits a full deploy dir.
1087
+ // `nifra build --target` - package the engine above into one command that emits a full deploy dir.
1066
1088
  //
1067
1089
  // An app already declares everything the build needs through nifra's conventions: `adapter` +
1068
1090
  // `clientModule` (nifra.config.ts / framework.ts), an optional `backend` (backend.ts), and `routes/`.
1069
1091
  // The ONLY thing apps used to hand-write per target was the server entry (`_worker.ts`, `server-bun.ts`,
1070
- // …) — so we GENERATE it here (per target) instead of asking each app to ship five near-identical files.
1092
+ // …) - so we GENERATE it here (per target) instead of asking each app to ship five near-identical files.
1071
1093
  // ===================================================================================================
1072
1094
  /** A deploy target `nifra build --target <t>` can emit. `static` is pure SSG (no server). */
1073
1095
  export const BUILD_TARGETS = ["bun", "node", "deno", "cf-pages", "vercel", "static"];
@@ -1084,7 +1106,7 @@ export function isBuildTarget(value) {
1084
1106
  * - `deno`: same fetch-handler default, plus `Deno.serve` self-host when run directly.
1085
1107
  * - `bun` / `node`: a self-hosting server that ALSO serves the client bundle from disk (those
1086
1108
  * runtimes have a filesystem; the static `/assets/*` sit next to the entry).
1087
- * `adapterImport`/`backendImport` are the specifiers the entry uses (relative to where it's written) —
1109
+ * `adapterImport`/`backendImport` are the specifiers the entry uses (relative to where it's written) -
1088
1110
  * `buildServer` writes the entry next to `serverEntry`, so they're resolved from there. Pure (string in,
1089
1111
  * string out) so the generation is unit-testable without a real build.
1090
1112
  */
@@ -1120,7 +1142,7 @@ export function generateServerEntry(options) {
1120
1142
  return `${lines.join("\n")}\n`;
1121
1143
  }
1122
1144
  if (target === "vercel") {
1123
- 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)");
1145
+ 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)");
1124
1146
  return `${lines.join("\n")}\n`;
1125
1147
  }
1126
1148
  // bun / node / deno self-host AND serve the client bundle from disk (it sits next to this entry).
@@ -1128,7 +1150,7 @@ export function generateServerEntry(options) {
1128
1150
  if (target === "bun") {
1129
1151
  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)", " },", "})",
1130
1152
  // The `${...}` here is literal OUTPUT (a template in the GENERATED file), not a template in this
1131
- // source — split so biome's noTemplateCurlyInString doesn't flag it; the emitted line is unchanged.
1153
+ // source - split so biome's noTemplateCurlyInString doesn't flag it; the emitted line is unchanged.
1132
1154
  `console.log(\`nifra (Bun) → http://localhost:$${"{server.port}"}\`)`);
1133
1155
  return `${lines.join("\n")}\n`;
1134
1156
  }
@@ -1137,7 +1159,7 @@ export function generateServerEntry(options) {
1137
1159
  return `${lines.join("\n")}\n`;
1138
1160
  }
1139
1161
  // deno
1140
- 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)", "})");
1162
+ 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)", "})");
1141
1163
  return `${lines.join("\n")}\n`;
1142
1164
  }
1143
1165
  /** The Bun.build `target` each deploy target compiles its server with (mirrors `buildServer`'s docs). */
@@ -1163,7 +1185,7 @@ async function measureOutputs(paths) {
1163
1185
  }
1164
1186
  return chunks;
1165
1187
  }
1166
- /** The default (Bun) strategy — `buildClient`/`buildServer` from this module. */
1188
+ /** The default (Bun) strategy - `buildClient`/`buildServer` from this module. */
1167
1189
  export const bunBundler = {
1168
1190
  buildClient: (input) => buildClient({
1169
1191
  routesDir: input.routesDir,
@@ -1239,8 +1261,8 @@ export async function buildTargetWith(target, options, bundler) {
1239
1261
  });
1240
1262
  if (result.prerendered.length === 0) {
1241
1263
  // A static build that renders nothing is almost always a misconfig (no `prerender = true` / no
1242
- // getStaticPaths) — fail loudly rather than ship an empty dir the dev thinks is their site.
1243
- throw new Error("[nifra/web] buildTarget(static): no routes were prerendered — opt routes in with " +
1264
+ // getStaticPaths) - fail loudly rather than ship an empty dir the dev thinks is their site.
1265
+ throw new Error("[nifra/web] buildTarget(static): no routes were prerendered - opt routes in with " +
1244
1266
  "`export const prerender = true` (static) or `getStaticPaths` (dynamic).");
1245
1267
  }
1246
1268
  rmSync(workDir, { recursive: true, force: true });