@nifrajs/web 2.1.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 (194) hide show
  1. package/README.md +3 -3
  2. package/dist/build-vite.d.ts +68 -0
  3. package/dist/build-vite.d.ts.map +1 -0
  4. package/dist/build-vite.js +433 -0
  5. package/dist/build-vite.js.map +1 -0
  6. package/dist/build.d.ts +174 -65
  7. package/dist/build.d.ts.map +1 -1
  8. package/dist/build.js +376 -135
  9. package/dist/build.js.map +1 -1
  10. package/dist/bun-dev-entry.d.ts +83 -0
  11. package/dist/bun-dev-entry.d.ts.map +1 -0
  12. package/dist/bun-dev-entry.js +136 -0
  13. package/dist/bun-dev-entry.js.map +1 -0
  14. package/dist/chunk-names.js +1 -1
  15. package/dist/client.d.ts +3 -9
  16. package/dist/client.d.ts.map +1 -1
  17. package/dist/client.js +209 -27
  18. package/dist/client.js.map +1 -1
  19. package/dist/conformance.d.ts +2 -2
  20. package/dist/conformance.js +3 -3
  21. package/dist/deferred.d.ts +11 -11
  22. package/dist/deferred.d.ts.map +1 -1
  23. package/dist/deferred.js +16 -16
  24. package/dist/dev-error.d.ts +2 -2
  25. package/dist/dev-error.js +3 -3
  26. package/dist/dev-port.d.ts +36 -0
  27. package/dist/dev-port.d.ts.map +1 -0
  28. package/dist/dev-port.js +48 -0
  29. package/dist/dev-port.js.map +1 -0
  30. package/dist/dev.d.ts +62 -5
  31. package/dist/dev.d.ts.map +1 -1
  32. package/dist/dev.js +419 -60
  33. package/dist/dev.js.map +1 -1
  34. package/dist/draft.d.ts +44 -7
  35. package/dist/draft.d.ts.map +1 -1
  36. package/dist/draft.js +92 -9
  37. package/dist/draft.js.map +1 -1
  38. package/dist/fn-state.d.ts +56 -0
  39. package/dist/fn-state.d.ts.map +1 -0
  40. package/dist/fn-state.js +77 -0
  41. package/dist/fn-state.js.map +1 -0
  42. package/dist/fn.d.ts +111 -0
  43. package/dist/fn.d.ts.map +1 -0
  44. package/dist/fn.js +125 -0
  45. package/dist/fn.js.map +1 -0
  46. package/dist/fonts-google.d.ts +9 -9
  47. package/dist/fonts-google.js +10 -10
  48. package/dist/fonts.d.ts +7 -7
  49. package/dist/fonts.js +1 -1
  50. package/dist/forms.d.ts +8 -8
  51. package/dist/fs.d.ts +15 -2
  52. package/dist/fs.d.ts.map +1 -1
  53. package/dist/fs.js +8 -2
  54. package/dist/fs.js.map +1 -1
  55. package/dist/index.d.ts +266 -74
  56. package/dist/index.d.ts.map +1 -1
  57. package/dist/index.js +869 -179
  58. package/dist/index.js.map +1 -1
  59. package/dist/internal/head-attributes.d.ts +15 -0
  60. package/dist/internal/head-attributes.d.ts.map +1 -0
  61. package/dist/internal/head-attributes.js +99 -0
  62. package/dist/internal/head-attributes.js.map +1 -0
  63. package/dist/internal/script-types.d.ts +14 -0
  64. package/dist/internal/script-types.d.ts.map +1 -0
  65. package/dist/internal/script-types.js +17 -0
  66. package/dist/internal/script-types.js.map +1 -0
  67. package/dist/internal/server-boundary.d.ts +60 -0
  68. package/dist/internal/server-boundary.d.ts.map +1 -0
  69. package/dist/internal/server-boundary.js +174 -0
  70. package/dist/internal/server-boundary.js.map +1 -0
  71. package/dist/internal/timing-safe-equal.d.ts +16 -0
  72. package/dist/internal/timing-safe-equal.d.ts.map +1 -0
  73. package/dist/internal/timing-safe-equal.js +26 -0
  74. package/dist/internal/timing-safe-equal.js.map +1 -0
  75. package/dist/internal/vite-import.d.ts +63 -0
  76. package/dist/internal/vite-import.d.ts.map +1 -0
  77. package/dist/internal/vite-import.js +94 -0
  78. package/dist/internal/vite-import.js.map +1 -0
  79. package/dist/islands.d.ts +5 -5
  80. package/dist/islands.js +5 -5
  81. package/dist/isr.d.ts +15 -15
  82. package/dist/isr.d.ts.map +1 -1
  83. package/dist/isr.js +8 -21
  84. package/dist/isr.js.map +1 -1
  85. package/dist/llms-txt.d.ts.map +1 -1
  86. package/dist/llms-txt.js +12 -6
  87. package/dist/llms-txt.js.map +1 -1
  88. package/dist/manifest.d.ts +121 -40
  89. package/dist/manifest.d.ts.map +1 -1
  90. package/dist/manifest.js +136 -26
  91. package/dist/manifest.js.map +1 -1
  92. package/dist/module-graph.d.ts +91 -0
  93. package/dist/module-graph.d.ts.map +1 -0
  94. package/dist/module-graph.js +92 -0
  95. package/dist/module-graph.js.map +1 -0
  96. package/dist/navigation.d.ts +120 -5
  97. package/dist/navigation.d.ts.map +1 -1
  98. package/dist/navigation.js +37 -5
  99. package/dist/navigation.js.map +1 -1
  100. package/dist/plugins/css-modules.d.ts +3 -3
  101. package/dist/plugins/css-modules.d.ts.map +1 -1
  102. package/dist/plugins/css-modules.js +0 -0
  103. package/dist/plugins/css-modules.js.map +1 -1
  104. package/dist/plugins/kit.d.ts +8 -8
  105. package/dist/plugins/kit.js +9 -9
  106. package/dist/plugins/scss.js +3 -3
  107. package/dist/plugins/vite-leak-guard.d.ts +45 -0
  108. package/dist/plugins/vite-leak-guard.d.ts.map +1 -0
  109. package/dist/plugins/vite-leak-guard.js +67 -0
  110. package/dist/plugins/vite-leak-guard.js.map +1 -0
  111. package/dist/plugins/vite-server-fn.d.ts +23 -0
  112. package/dist/plugins/vite-server-fn.d.ts.map +1 -0
  113. package/dist/plugins/vite-server-fn.js +51 -0
  114. package/dist/plugins/vite-server-fn.js.map +1 -0
  115. package/dist/plugins/vite-server-only.d.ts +49 -0
  116. package/dist/plugins/vite-server-only.d.ts.map +1 -0
  117. package/dist/plugins/vite-server-only.js +57 -0
  118. package/dist/plugins/vite-server-only.js.map +1 -0
  119. package/dist/prerender.d.ts +5 -5
  120. package/dist/prerender.js +7 -7
  121. package/dist/public-dir.d.ts +36 -0
  122. package/dist/public-dir.d.ts.map +1 -0
  123. package/dist/public-dir.js +89 -0
  124. package/dist/public-dir.js.map +1 -0
  125. package/dist/query.d.ts +10 -10
  126. package/dist/query.js +15 -15
  127. package/dist/route-manifest.d.ts +91 -0
  128. package/dist/route-manifest.d.ts.map +1 -0
  129. package/dist/route-manifest.js +128 -0
  130. package/dist/route-manifest.js.map +1 -0
  131. package/dist/router.d.ts +94 -22
  132. package/dist/router.d.ts.map +1 -1
  133. package/dist/router.js +170 -58
  134. package/dist/router.js.map +1 -1
  135. package/dist/search.d.ts +94 -0
  136. package/dist/search.d.ts.map +1 -0
  137. package/dist/search.js +232 -0
  138. package/dist/search.js.map +1 -0
  139. package/dist/server-only.d.ts +6 -6
  140. package/dist/server-only.js +7 -7
  141. package/dist/service-worker.d.ts +56 -0
  142. package/dist/service-worker.d.ts.map +1 -0
  143. package/dist/service-worker.js +127 -0
  144. package/dist/service-worker.js.map +1 -0
  145. package/dist/vite.d.ts +25 -17
  146. package/dist/vite.d.ts.map +1 -1
  147. package/dist/vite.js +75 -33
  148. package/dist/vite.js.map +1 -1
  149. package/package.json +48 -4
  150. package/server-manifest.d.ts +31 -0
  151. package/src/build-vite.ts +553 -0
  152. package/src/build.ts +563 -172
  153. package/src/bun-dev-entry.ts +184 -0
  154. package/src/chunk-names.ts +1 -1
  155. package/src/client.ts +221 -30
  156. package/src/conformance.ts +3 -3
  157. package/src/deferred.ts +17 -17
  158. package/src/dev-error.ts +3 -3
  159. package/src/dev-port.ts +63 -0
  160. package/src/dev.ts +468 -66
  161. package/src/draft.ts +124 -12
  162. package/src/fn-state.ts +107 -0
  163. package/src/fn.ts +199 -0
  164. package/src/fonts-google.ts +13 -13
  165. package/src/fonts.ts +7 -7
  166. package/src/forms.ts +8 -8
  167. package/src/fs.ts +24 -6
  168. package/src/index.ts +1174 -225
  169. package/src/internal/head-attributes.ts +109 -0
  170. package/src/internal/script-types.ts +18 -0
  171. package/src/internal/server-boundary.ts +187 -0
  172. package/src/internal/timing-safe-equal.ts +24 -0
  173. package/src/internal/vite-import.ts +92 -0
  174. package/src/islands.ts +7 -7
  175. package/src/isr.ts +18 -30
  176. package/src/llms-txt.ts +13 -5
  177. package/src/manifest.ts +264 -48
  178. package/src/module-graph.ts +146 -0
  179. package/src/navigation.ts +151 -5
  180. package/src/plugins/css-modules.ts +0 -0
  181. package/src/plugins/kit.ts +11 -11
  182. package/src/plugins/scss.ts +3 -3
  183. package/src/plugins/vite-leak-guard.ts +110 -0
  184. package/src/plugins/vite-server-fn.ts +67 -0
  185. package/src/plugins/vite-server-only.ts +75 -0
  186. package/src/prerender.ts +10 -10
  187. package/src/public-dir.ts +108 -0
  188. package/src/query.ts +22 -22
  189. package/src/route-manifest.ts +240 -0
  190. package/src/router.ts +288 -74
  191. package/src/search.ts +297 -0
  192. package/src/server-only.ts +7 -7
  193. package/src/service-worker.ts +165 -0
  194. package/src/vite.ts +103 -49
package/dist/build.js CHANGED
@@ -1,16 +1,20 @@
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*
6
6
  * runs on any runtime.
7
7
  */
8
- import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
9
- import { dirname, relative, resolve as resolvePath } from "node:path";
8
+ import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
9
+ import { cp, lstat, mkdir, realpath } from "node:fs/promises";
10
+ import { dirname, join, relative, resolve as resolvePath, sep } from "node:path";
11
+ import { Glob } from "bun";
10
12
  import { sanitizeOutputNames } from "./chunk-names.js";
11
13
  import { discoverRoutes } from "./fs.js";
12
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";
13
16
  // `buildTarget(static)` drives the SSG prerender engine directly (it's also re-exported below).
17
+ import { fromBunMetafile } from "./module-graph.js";
14
18
  import { prerenderRoutes } from "./prerender.js";
15
19
  // Build-time SSG: prerender opted-in static + dynamic routes to `index.html` (+ static `_data.json`),
16
20
  // run after `buildClient`.
@@ -34,6 +38,156 @@ export function publicEnvDefines(prefix, env) {
34
38
  }
35
39
  return defines;
36
40
  }
41
+ /**
42
+ * Percent-encode one path segment exactly the way a browser encodes it into a request URL.
43
+ *
44
+ * NOT `encodeURIComponent`. That escapes the sub-delimiters `, @ + = & ; $`, which a browser sends raw -
45
+ * so a file named `report,2026.csv` would be recorded as `/report%2C2026.csv` while the request arrives
46
+ * as `/report,2026.csv`, the allowlist lookup misses, and the file 404s in production only. `encodeURI`
47
+ * agrees with `URL.pathname` on every character except `?` and `#`, which terminate a path and so must
48
+ * be escaped explicitly here.
49
+ */
50
+ function encodePathSegment(segment) {
51
+ return encodeURI(segment).replace(/[?#]/g, (char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`);
52
+ }
53
+ /**
54
+ * Cloudflare Pages rejects a `_routes.json` with more than 100 include+exclude rules combined, or any
55
+ * single rule longer than 100 characters.
56
+ */
57
+ const CF_MAX_ROUTE_RULES = 100;
58
+ const CF_MAX_RULE_LENGTH = 100;
59
+ function firstSegmentOf(pattern) {
60
+ const first = pattern.split("/").find((segment) => segment !== "");
61
+ if (first === undefined)
62
+ return { kind: "root" };
63
+ return /^[A-Za-z0-9._-]+$/.test(first) ? { kind: "literal", value: first } : { kind: "dynamic" };
64
+ }
65
+ /**
66
+ * Directories under `public/` whose every file can be replaced by one `/<dir>/*` rule.
67
+ *
68
+ * Safe only when no route can ever be served beneath that directory, because the glob does not merely
69
+ * describe today's files - it hands Pages every future path under the prefix, and the worker never sees
70
+ * them. A `public/blog/hero.png` next to a `/blog/:slug` route is the ordinary case that breaks: the
71
+ * glob would send `/blog/my-post` to the CDN, which has no such file, and the page 404s in production
72
+ * only. So a directory collapses only when every route pattern starts with a literal segment that is
73
+ * something else. One dynamic first segment anywhere (`/:locale/…`) disables collapsing entirely, which
74
+ * is correct - such a route can match under any directory name.
75
+ *
76
+ * A collapsed directory also gives up the app's own 404 page for a missing file beneath it, since the
77
+ * request never reaches the worker. That is the right trade for a directory that holds only static
78
+ * files: a missing image should fail as a fast CDN 404, not as an HTML error page.
79
+ */
80
+ function collapsibleDirs(publicFiles, routePatterns) {
81
+ const reserved = new Set();
82
+ for (const pattern of routePatterns) {
83
+ const first = firstSegmentOf(pattern);
84
+ // A dynamic first segment can match anywhere, so nothing is collapsible at all.
85
+ if (first.kind === "dynamic")
86
+ return new Set();
87
+ // `root` reserves nothing: `/` cannot match a path below a directory.
88
+ if (first.kind === "literal")
89
+ reserved.add(first.value);
90
+ }
91
+ const dirs = new Set();
92
+ for (const file of publicFiles) {
93
+ const slash = file.indexOf("/", 1);
94
+ if (slash === -1)
95
+ continue; // a root-level file has no directory to collapse into
96
+ const dir = file.slice(1, slash);
97
+ if (!reserved.has(dir))
98
+ dirs.add(dir);
99
+ }
100
+ return dirs;
101
+ }
102
+ /**
103
+ * Build the cf-pages `_routes.json` rules for a set of copied public files, within Cloudflare's budget.
104
+ *
105
+ * `exclude` is what Pages serves straight from the CDN instead of invoking the worker, so naming every
106
+ * public file is ideal - and impossible past ~99 of them. A `public/` of icons, fonts and share images
107
+ * clears 100 easily, and the rejection lands at `wrangler pages deploy`, long after the build reported
108
+ * success.
109
+ *
110
+ * A glob is emitted only where it cannot be wrong. `/assets/*` always is: the build owns that prefix
111
+ * outright, since it is where the hashed bundle is written. A `public/` subdirectory is different - the
112
+ * name is the author's, and a `/blog/*` rule would hand Pages every future path under `/blog/`, so a
113
+ * `/blog/:slug` route would 404 from the CDN in production only. {@link collapsibleDirs} therefore
114
+ * collapses a directory only after checking it against the app's real route patterns, which is what
115
+ * makes the compaction safe rather than merely smaller. Everything else stays an exact path, and a name
116
+ * containing `*` is dropped rather than escaped, since the rule would become a wildcard.
117
+ *
118
+ * What still does not fit is dropped, which is safe because the list is only an optimization: the
119
+ * generated worker serves any allowlisted path it receives through the ASSETS binding, so an omitted
120
+ * file costs one worker invocation, not a 404.
121
+ */
122
+ export function cloudflareRouteRules(publicFiles,
123
+ /**
124
+ * Every route pattern the app serves. Required, not defaulted: an empty list means "this app serves
125
+ * no routes", which makes every directory collapsible - a defensible answer to state deliberately and
126
+ * a dangerous one to arrive at by forgetting the argument.
127
+ */
128
+ routePatterns) {
129
+ const include = ["/*"];
130
+ const exclude = ["/assets/*"];
131
+ const omitted = [];
132
+ // Collapse only where the route table proves nothing can be served beneath the directory, and only
133
+ // when the per-file list would not have fit anyway - a smaller `_routes.json` is worth nothing on its
134
+ // own, and exact paths keep the app's own 404 for a missing file under that directory.
135
+ const perFileFits = publicFiles.length + include.length + exclude.length <= CF_MAX_ROUTE_RULES;
136
+ const collapsible = perFileFits ? new Set() : collapsibleDirs(publicFiles, routePatterns);
137
+ const emitted = new Set();
138
+ for (const file of publicFiles) {
139
+ const slash = file.indexOf("/", 1);
140
+ const dir = slash === -1 ? undefined : file.slice(1, slash);
141
+ const collapsed = dir !== undefined && collapsible.has(dir);
142
+ const rule = collapsed ? `/${dir}/*` : file;
143
+ if (emitted.has(rule))
144
+ continue; // already covered by a rule emitted for an earlier file
145
+ // A `*` in a FILE name would turn that file's own rule into a wildcard. A collapsed rule's `*` is
146
+ // ours and deliberate, and the file's name never appears in it, so the check applies to one case.
147
+ if (!collapsed && file.includes("*")) {
148
+ omitted.push(file);
149
+ continue;
150
+ }
151
+ if (include.length + exclude.length >= CF_MAX_ROUTE_RULES || rule.length > CF_MAX_RULE_LENGTH) {
152
+ omitted.push(file);
153
+ continue;
154
+ }
155
+ exclude.push(rule);
156
+ emitted.add(rule);
157
+ }
158
+ return { include, exclude, omitted };
159
+ }
160
+ /** The built asset map - the server reads `entry` for the client script + serves `assets`. */
161
+ /**
162
+ * Copy `from` into `to`, returning the URL paths copied (sorted).
163
+ *
164
+ * Lives here, not beside `servePublicDir`, because it is BUILD-time: it reaches for `Bun.Glob` and
165
+ * `node:fs`, and `public-dir.ts` is reachable from the client bundle graph through the package
166
+ * entry - a dynamic `import("bun")` there fails the browser build outright.
167
+ */
168
+ export async function copyPublicDir(from, to) {
169
+ const source = resolvePath(from);
170
+ const sourceReal = await realpath(source);
171
+ const copied = [];
172
+ for await (const rel of new Glob("**/*").scan({ cwd: source, dot: true, onlyFiles: false })) {
173
+ const candidate = join(source, rel);
174
+ const candidateStat = await lstat(candidate);
175
+ if (candidateStat.isSymbolicLink()) {
176
+ throw new Error(`[nifra/web] publicDir entry escapes its root through a symlink: ${rel}`);
177
+ }
178
+ if (!candidateStat.isFile())
179
+ continue;
180
+ const candidateReal = await realpath(candidate);
181
+ if (candidateReal !== sourceReal && !candidateReal.startsWith(sourceReal + sep)) {
182
+ throw new Error(`[nifra/web] publicDir entry escapes its root through a symlink: ${rel}`);
183
+ }
184
+ const target = join(to, rel);
185
+ await mkdir(join(target, ".."), { recursive: true });
186
+ await cp(candidateReal, target);
187
+ copied.push(`/${rel.split(sep).map(encodePathSegment).join("/")}`);
188
+ }
189
+ return copied.sort();
190
+ }
37
191
  /** The `node:` specifier of an import edge, or undefined if it isn't a Node built-in. Reads the
38
192
  * `original` (as-written) specifier first, falling back to the resolved `path` (an `external` node:
39
193
  * import keeps `node:` in its path). */
@@ -48,10 +202,10 @@ const nodeBuiltinOf = (im) => {
48
202
  * BFS the metafile import graph for the SHORTEST user-module path that pulls `builtin` into the
49
203
  * bundle, returning it as display labels `[entryFile, …as-written specifiers…, builtin]`. The frontier
50
204
  * starts at every `entryInput` (the route/user entrypoints) so the reported chain begins where the dev
51
- * 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
52
206
  * only NON-`node:` edges (so Bun's polyfill chain never extends the path) and stops at the first edge
53
207
  * whose target is the builtin. Returns `[builtin]` if no entry reaches it (defensive; a flagged builtin
54
- * 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.
55
209
  */
56
210
  function shortestBuiltinChain(inputs, entryInputs, builtin) {
57
211
  // Each queue item carries the resolved-path node + the display chain to reach it (entry + the
@@ -72,7 +226,7 @@ function shortestBuiltinChain(inputs, entryInputs, builtin) {
72
226
  return [...chain, builtin];
73
227
  const target = im.path;
74
228
  // Only follow edges into user (non-`node:`) modules that exist in the input graph and haven't
75
- // 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.
76
230
  if (target === undefined ||
77
231
  target.startsWith("node:") ||
78
232
  inputs[target] === undefined ||
@@ -81,45 +235,45 @@ function shortestBuiltinChain(inputs, entryInputs, builtin) {
81
235
  }
82
236
  seen.add(target);
83
237
  // Display the hop by its as-written specifier (`../db/client.ts`, `postgres`), falling back to
84
- // 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.
85
239
  next.push({ node: target, chain: [...chain, im.original ?? target] });
86
240
  }
87
241
  }
88
242
  frontier = next;
89
243
  }
90
- 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)
91
245
  }
92
246
  /**
93
247
  * Scan a build's metafile for any `node:` builtin that a USER module pulled into a CLIENT output
94
248
  * chunk, returning a sorted, deduped list of {@link NodeBuiltinFinding}s. Three graph facts combine so
95
249
  * the report is precise AND actionable:
96
- * 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
97
251
  * polyfill chain (`node:crypto` → `node:buffer`/`node:stream`/…) doesn't bury the real cause.
98
- * 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
99
253
  * emitted file to look at.
100
- * 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
101
255
  * (`shortestBuiltinChain`), so the error points straight at the offending `import` line instead of
102
256
  * leaving the dev to grep the dependency tree (the DX gap this closes).
103
257
  * Graph-based (never the emitted text), so it survives minification and can't be fooled by a string
104
258
  * literal that merely contains `"node:crypto"`. Pure + exported for unit testing. Empty ⇒ clean.
105
259
  */
106
- export function detectNodeBuiltinsInClient(meta) {
107
- const inputs = meta?.inputs ?? {};
108
- // (1) The builtins a user (non-polyfill) module imports directly — the ones the author controls.
260
+ export function detectNodeBuiltinsInClient(graph) {
261
+ const inputs = graph.modules;
262
+ // (1) The builtins a user (non-polyfill) module imports directly - the ones the author controls.
109
263
  const userImported = new Set();
110
264
  for (const [inputKey, input] of Object.entries(inputs)) {
111
265
  if (inputKey.startsWith("node:"))
112
- continue; // a polyfill importing another builtin — not the cause
266
+ continue; // a polyfill importing another builtin - not the cause
113
267
  for (const im of input.imports ?? []) {
114
268
  const builtin = nodeBuiltinOf(im);
115
269
  if (builtin !== undefined)
116
270
  userImported.add(builtin);
117
271
  }
118
272
  }
119
- // 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
120
274
  // at the file the dev wrote an `import` in. Each output's `entryPoint` is one such input.
121
275
  const entryInputs = [
122
- ...new Set(Object.values(meta?.outputs ?? {})
276
+ ...new Set(Object.values(graph.chunks)
123
277
  .map((o) => o.entryPoint)
124
278
  .filter((e) => e !== undefined && !e.startsWith("node:"))),
125
279
  ];
@@ -135,8 +289,8 @@ export function detectNodeBuiltinsInClient(meta) {
135
289
  };
136
290
  // (2) Locate which emitted chunk each user-imported builtin reached, via the per-output `inputs`.
137
291
  const findings = new Map();
138
- for (const [outPath, out] of Object.entries(meta?.outputs ?? {})) {
139
- for (const inputKey of Object.keys(out.inputs ?? {})) {
292
+ for (const [outPath, out] of Object.entries(graph.chunks)) {
293
+ for (const inputKey of out.modules) {
140
294
  if (userImported.has(inputKey)) {
141
295
  const chunk = basename(outPath);
142
296
  findings.set(`${inputKey}\0${chunk}`, {
@@ -154,8 +308,8 @@ export function detectNodeBuiltinsInClient(meta) {
154
308
  // node-builtin guard: a module of PURE server logic with NO `node:` import (a secret-bearing constant,
155
309
  // a server-only API call) that an author wants to FAIL LOUD if it reaches the client opts in with a
156
310
  // side-effect `import "@nifrajs/web/server-only"` (Next's `import "server-only"`). On the SERVER build
157
- // the marker is an empty no-op; the CLIENT build detects — via the SAME Bun metafile graph the
158
- // 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
159
313
  // the build with the import chain. Graph-based (never the emitted text), so it survives minification.
160
314
  // ---------------------------------------------------------------------------------------------------
161
315
  /** The marker specifier an author imports to opt a module into the client-leak guard. Matched on the
@@ -164,7 +318,7 @@ export function detectNodeBuiltinsInClient(meta) {
164
318
  export const SERVER_ONLY_MARKER = "@nifrajs/web/server-only";
165
319
  /** True when an import edge is the `server-only` marker import. Reads the as-written `original` (the
166
320
  * specifier the author typed); falls back to the resolved `path`'s basename so a pre-resolved edge
167
- * (no `original`) — or a workspace-relative resolution — is still recognised. */
321
+ * (no `original`) - or a workspace-relative resolution - is still recognised. */
168
322
  const isServerOnlyMarkerImport = (im) => {
169
323
  if (im.original === SERVER_ONLY_MARKER)
170
324
  return true;
@@ -179,14 +333,14 @@ const isServerOnlyMarkerImport = (im) => {
179
333
  * "<module> (marked server-only)"]`. Mirrors {@link shortestBuiltinChain}: the frontier starts at
180
334
  * every `entryInput` so the chain begins at the file the dev wrote an `import` in; traversal crosses
181
335
  * only NON-`node:` user edges (no cycles via `seen`); it stops at the first node whose import set
182
- * contains the marker (the marked module — the actionable tail), labelling that node by the as-written
183
- * 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.
184
338
  */
185
339
  function shortestServerOnlyChain(inputs, entryInputs, markedModule, resolveTarget) {
186
340
  // The label for the marked module's tail: its as-written specifier (filled when we cross the edge
187
341
  // that reaches it) suffixed with `(marked server-only)`; the entry case uses the entry key itself.
188
342
  const tail = (label) => `${label} (marked server-only)`;
189
- // 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.
190
344
  if (entryInputs.includes(markedModule))
191
345
  return [tail(markedModule)];
192
346
  const seen = new Set(entryInputs);
@@ -198,7 +352,7 @@ function shortestServerOnlyChain(inputs, entryInputs, markedModule, resolveTarge
198
352
  const next = [];
199
353
  for (const { node, chain } of frontier) {
200
354
  for (const im of inputs[node]?.imports ?? []) {
201
- // 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`
202
356
  // is absolute while the input keys are cwd-relative, so a raw equality/lookup would miss every
203
357
  // multi-hop user edge (and the chain would degrade to just the tail). The resolver maps both.
204
358
  const target = resolveTarget(im);
@@ -247,14 +401,14 @@ function inputKeyResolver(inputs) {
247
401
  * Scan a build's metafile for any module that opts into the `server-only` marker (a side-effect
248
402
  * `import "@nifrajs/web/server-only"`) yet landed in a CLIENT output chunk, returning a sorted, deduped
249
403
  * list of {@link ServerOnlyFinding}s. Mirrors {@link detectNodeBuiltinsInClient}: it reads the SAME
250
- * 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
251
405
  * per-output `inputs`), and the shortest import chain from a user entry to it. The marker module ITSELF
252
- * (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
253
407
  * unit testing. Empty ⇒ clean.
254
408
  */
255
- export function detectServerOnlyInClient(meta) {
256
- const inputs = meta?.inputs ?? {};
257
- // (1) The modules that import the marker — the ones the author opted into the guard. The marker
409
+ export function detectServerOnlyInClient(graph) {
410
+ const inputs = graph.modules;
411
+ // (1) The modules that import the marker - the ones the author opted into the guard. The marker
258
412
  // module itself is skipped: it's the import TARGET, not an opt-in (it imports nothing of its own).
259
413
  const marked = new Set();
260
414
  for (const [inputKey, input] of Object.entries(inputs)) {
@@ -265,10 +419,10 @@ export function detectServerOnlyInClient(meta) {
265
419
  }
266
420
  if (marked.size === 0)
267
421
  return [];
268
- // 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
269
423
  // wrote an `import` in. Same derivation as the node-builtin guard.
270
424
  const entryInputs = [
271
- ...new Set(Object.values(meta?.outputs ?? {})
425
+ ...new Set(Object.values(graph.chunks)
272
426
  .map((o) => o.entryPoint)
273
427
  .filter((e) => e !== undefined && !e.startsWith("node:"))),
274
428
  ];
@@ -284,8 +438,8 @@ export function detectServerOnlyInClient(meta) {
284
438
  };
285
439
  // (2) Locate which emitted chunk each marked module reached, via the per-output `inputs`.
286
440
  const findings = new Map();
287
- for (const [outPath, out] of Object.entries(meta?.outputs ?? {})) {
288
- for (const inputKey of Object.keys(out.inputs ?? {})) {
441
+ for (const [outPath, out] of Object.entries(graph.chunks)) {
442
+ for (const inputKey of out.modules) {
289
443
  if (marked.has(inputKey)) {
290
444
  const chunk = basename(outPath);
291
445
  findings.set(`${inputKey}\0${chunk}`, { chunk, chain: chainFor(inputKey) });
@@ -299,8 +453,37 @@ export function detectServerOnlyInClient(meta) {
299
453
  });
300
454
  }
301
455
  /** True when an INPUT graph key is the marker module file itself (`…/server-only.{ts,js}` under web).
302
- * 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. */
303
457
  const isServerOnlyMarkerModule = (inputKey) => /(^|\/)server-only\.[cm]?[jt]s$/.test(inputKey);
458
+ // ---------------------------------------------------------------------------------------------------
459
+ // Guard MESSAGES - one owner for both production pipelines. The Bun build (below) and the Vite/Rollup
460
+ // leak-guard plugin (plugins/vite-leak-guard.ts) both call these, so a leak reads IDENTICALLY whichever
461
+ // bundler produced it. A second bundler must not grow a second, subtly-different wording of a security
462
+ // error; that is exactly the "mostly ported" outcome the neutral graph seam exists to prevent.
463
+ // ---------------------------------------------------------------------------------------------------
464
+ /** The build-failing message for `node:` builtins that reached the client bundle. `undefined` ⇒ clean. */
465
+ export function formatNodeBuiltinLeak(findings) {
466
+ if (findings.length === 0)
467
+ return undefined;
468
+ const lines = findings.map((f) => f.chain.length > 1
469
+ ? ` - ${f.builtin} reached the client bundle via ${f.chain.join(" → ")} (chunk: ${f.chunk})`
470
+ : ` - ${f.builtin} reached the client bundle via ${f.chunk}`);
471
+ return (`[nifra/web] Node built-in(s) in the client bundle - move them behind a server-only path ` +
472
+ `(a loader/action runs on the server; import the \`node:\` module there, not at a route's ` +
473
+ `top level):\n${lines.join("\n")}`);
474
+ }
475
+ /** The build-failing message for `server-only`-marked modules that reached the client. `undefined` ⇒ clean. */
476
+ export function formatServerOnlyLeak(findings) {
477
+ if (findings.length === 0)
478
+ return undefined;
479
+ const lines = findings.map((f) => f.chain.length > 1
480
+ ? ` - server-only module reached the client bundle via ${f.chain.join(" → ")} (chunk: ${f.chunk})`
481
+ : ` - server-only module reached the client bundle via ${f.chunk}`);
482
+ return (`[nifra/web] server-only module(s) in the client bundle - a module marked ` +
483
+ `\`import "${SERVER_ONLY_MARKER}"\` reached the browser. Move it behind a server-only path ` +
484
+ `(reach it via a loader/action, or rename it \`*.server.ts\`), so its server logic never ships ` +
485
+ `to the client:\n${lines.join("\n")}`);
486
+ }
304
487
  const basename = (path) => path.slice(path.lastIndexOf("/") + 1);
305
488
  /** `[name]` Bun derives for an entrypoint: basename without extension (`users/[id].tsx` → `[id]`). */
306
489
  const entryName = (file) => {
@@ -310,7 +493,7 @@ const entryName = (file) => {
310
493
  };
311
494
  /**
312
495
  * Aggregate a list of measured chunks into a {@link SizeReport}: sort biggest-gzip-first (ties broken
313
- * 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
314
497
  * file + `Bun.gzipSync`) happens in the orchestrator; this is the deterministic, unit-testable core.
315
498
  */
316
499
  export function aggregateSizeReport(chunks) {
@@ -332,7 +515,7 @@ export function formatBytes(bytes) {
332
515
  return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
333
516
  }
334
517
  /**
335
- * 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
336
519
  * `nifra build --report` prints. Pure (string in, string out) so the formatting is unit-testable.
337
520
  */
338
521
  export function renderSizeReport(report) {
@@ -367,7 +550,7 @@ const MANIFEST_CLIENT_ENTRY = /export\s+const\s+clientEntry\s*=\s*["']([^"']+)["
367
550
  * `routes/`-relative keys `discoverRoutes` produces (e.g. `docs/index.tsx`). `routesPrefix` is the
368
551
  * specifier prefix the manifest used for the routes dir (default `./routes/`, what `buildServer`'s
369
552
  * default `resolve` emits). Only import specifiers under that prefix are route files; the
370
- * `@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.
371
554
  */
372
555
  export function parseManifestRouteFiles(source, routesPrefix = "./routes/") {
373
556
  const files = new Set();
@@ -443,8 +626,8 @@ export function resyncServerManifestSource(source, manifest, routesPrefix) {
443
626
  }
444
627
  /**
445
628
  * Diff the route files a committed server-manifest imports against the files freshly discovered in
446
- * `routes/`. Returns the `missing` (in routes/, not in manifest — stale manifest) and `extra` (in
447
- * 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
448
631
  * supplies both file lists (the committed source is parsed via {@link parseManifestRouteFiles}; the
449
632
  * fresh list comes from `discoverRoutes`). Lists need not be pre-sorted; the result is sorted.
450
633
  */
@@ -468,20 +651,20 @@ export function formatManifestDrift(drift, manifestPath = "server-manifest.ts")
468
651
  if (isManifestInSync(drift))
469
652
  return undefined;
470
653
  const lines = [
471
- `[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/.`,
472
655
  ];
473
656
  if (drift.missing.length > 0) {
474
- 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(", ")}`);
475
658
  }
476
659
  if (drift.extra.length > 0) {
477
- 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(", ")}`);
478
661
  }
479
662
  lines.push(" Fix: re-run the build to regenerate the server manifest, then commit it.");
480
663
  return lines.join("\n");
481
664
  }
482
665
  /**
483
666
  * Build the client bundle for a file-routed app. Writes the hashed assets + `manifest.json` to
484
- * `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
485
668
  * silently ships a broken bundle.
486
669
  */
487
670
  export async function buildClient(options) {
@@ -496,7 +679,7 @@ export async function buildClient(options) {
496
679
  // PUBLIC_*-prefixed env → client define (Vite/Next convention). Sourced from the build env (`Bun.env`,
497
680
  // falling back to `process.env`). Only prefixed vars are baked in; unprefixed secrets stay undefined
498
681
  // in the client via the bare `process.env` → `({})` define below. Caller's `options.define` wins (it's
499
- // 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.
500
683
  const buildEnv = (typeof Bun !== "undefined" ? Bun.env : undefined) ?? process.env;
501
684
  const publicDefines = publicEnvDefines(options.publicEnvPrefix ?? "PUBLIC_", buildEnv);
502
685
  // Keep the generated source beside the project, not inside `outDir`: module resolution starts at the
@@ -507,7 +690,7 @@ export async function buildClient(options) {
507
690
  // A client bundle has no Node `process`; provide a minimal one so a stray bare `process` reference in
508
691
  // an app module doesn't crash hydration (`process.env.*` reads are handled at compile time by `define`).
509
692
  writeFileSync(entryFile, `globalThis.process ??= { env: {} };\n${generateClientEntry(routeManifest, { clientModule, resolve })}`);
510
- // 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
511
694
  // named chunk per file that the bootstrap's lazy `import()` dedupes to (verified), so the manifest
512
695
  // can map each route to its chunk URLs for matched-route preload. `resolve(file)` is the same
513
696
  // specifier the bootstrap imports, so the entrypoint + lazy import are the same module (dedup).
@@ -518,7 +701,7 @@ export async function buildClient(options) {
518
701
  ...(routeManifest.notFound ? [routeManifest.notFound.file] : []),
519
702
  ]),
520
703
  ].sort();
521
- // `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`
522
705
  // (the source file) + `outputs[js].cssBundle` (that entry's emitted stylesheet). It's the robust
523
706
  // entry→CSS link for per-route splitting: keyed by the unique source path, so it survives
524
707
  // same-basename collisions (`index.tsx` + `blog/index.tsx`) that a filename match can't. Not yet in
@@ -535,7 +718,7 @@ export async function buildClient(options) {
535
718
  splitting: true, // one chunk per lazily-imported route; shared deps deduped into shared chunks
536
719
  // `import "./x.css"` in a route/component → bundled, minified, content-hashed `.css` asset (Bun
537
720
  // strips the import from the JS; CSS bundling is on by default since Bun 1.2). Mapped to routes
538
- // 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.
539
722
  ...buildExtras,
540
723
  minify: options.minify ?? true,
541
724
  plugins: [
@@ -543,13 +726,14 @@ export async function buildClient(options) {
543
726
  preactDedupePlugin(routesDir),
544
727
  svelteDedupePlugin(routesDir),
545
728
  serverOnlyEmptyPlugin(),
729
+ serverFnStubPlugin(),
546
730
  ...(options.plugins ?? []),
547
731
  ],
548
732
  ...(options.conditions ? { conditions: [...options.conditions] } : {}),
549
733
  // Replace `process.env.*` at compile time so an app module reading config off `process.env` doesn't
550
734
  // hit a `process is not defined` crash in the browser. Bun does longest-match: NODE_ENV resolves to
551
735
  // the build mode (React's prod/dev branch); each PUBLIC_* var resolves to its baked VALUE; every
552
- // 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
553
737
  // never leak). Callers can override any of these via `options.define` (layered last).
554
738
  define: {
555
739
  "process.env": "({})",
@@ -568,39 +752,25 @@ export async function buildClient(options) {
568
752
  }
569
753
  // #4: a `node:` builtin (e.g. `node:crypto`) pulled into a CLIENT chunk builds fine (Bun substitutes
570
754
  // a browser polyfill) but breaks/leaks at runtime. Fail the build with a named, actionable error
571
- // 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
572
756
  // per-output `inputs`), so it can't false-positive on a `"node:..."` string literal and survives
573
757
  // minification. Only the client build runs this; the server build's `node:` imports are legitimate.
574
758
  const clientMeta = result.metafile;
575
- const nodeBuiltins = detectNodeBuiltinsInClient(clientMeta);
576
- if (nodeBuiltins.length > 0) {
577
- // Name the offending builtin AND the import chain that pulled it in (entry → … → builtin), so the
578
- // dev sees the exact path from a route file to the leak instead of just the emitted chunk name.
579
- // The chain always starts at a user entry; fall back to the chunk name if it somehow came back empty.
580
- const lines = nodeBuiltins.map((f) => f.chain.length > 1
581
- ? ` - ${f.builtin} reached the client bundle via ${f.chain.join(" → ")} (chunk: ${f.chunk})`
582
- : ` - ${f.builtin} reached the client bundle via ${f.chunk}`);
583
- throw new Error(`[nifra/web] Node built-in(s) in the client bundle — move them behind a server-only path ` +
584
- `(a loader/action runs on the server; import the \`node:\` module there, not at a route's ` +
585
- `top level):\n${lines.join("\n")}`);
586
- }
587
- // §3.3/§5.1: a module that opted into the `server-only` marker (`import "@nifrajs/web/server-only"`)
588
- // yet reached a CLIENT chunk — fail the build with the same chain-bearing, "reached the client bundle
589
- // via" message shape as the node-builtin guard above. This catches pure-server logic (a secret, a
590
- // server-only API call) that carries no `node:` import and isn't named `*.server`, so neither of the
591
- // other two guards fires. Graph-based (the same metafile), so it survives minification.
592
- const serverOnly = detectServerOnlyInClient(clientMeta);
593
- if (serverOnly.length > 0) {
594
- const lines = serverOnly.map((f) => f.chain.length > 1
595
- ? ` - server-only module reached the client bundle via ${f.chain.join(" → ")} (chunk: ${f.chunk})`
596
- : ` - server-only module reached the client bundle via ${f.chunk}`);
597
- throw new Error(`[nifra/web] server-only module(s) in the client bundle — a module marked ` +
598
- `\`import "${SERVER_ONLY_MARKER}"\` reached the browser. Move it behind a server-only path ` +
599
- `(reach it via a loader/action, or rename it \`*.server.ts\`), so its server logic never ships ` +
600
- `to the client:\n${lines.join("\n")}`);
601
- }
759
+ const clientGraph = fromBunMetafile(clientMeta);
760
+ // #4: a `node:` builtin (e.g. `node:crypto`) pulled into a CLIENT chunk builds fine (Bun substitutes a
761
+ // browser polyfill) but breaks/leaks at runtime. Fail with the chain (entry → … → builtin), through the
762
+ // SHARED formatter so the Vite pipeline's identical guard reads byte-for-byte the same.
763
+ const nodeBuiltinLeak = formatNodeBuiltinLeak(detectNodeBuiltinsInClient(clientGraph));
764
+ if (nodeBuiltinLeak !== undefined)
765
+ throw new Error(nodeBuiltinLeak);
766
+ // §3.3/§5.1: a module that opted into the `server-only` marker yet reached a CLIENT chunk - catches
767
+ // pure-server logic (a secret, a server-only API call) carrying no `node:` import and not named
768
+ // `*.server`, so neither other guard fires. Same shared formatter as above.
769
+ const serverOnlyLeak = formatServerOnlyLeak(detectServerOnlyInClient(clientGraph));
770
+ if (serverOnlyLeak !== undefined)
771
+ throw new Error(serverOnlyLeak);
602
772
  // Rename any chunk whose basename isn't URL-safe (dynamic-route files become `[slug]-hash.js`) and
603
- // 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.
604
774
  const renamed = sanitizeOutputNames(result.outputs);
605
775
  const toUrl = (path) => `${publicPath}${renamed.get(basename(path)) ?? basename(path)}`;
606
776
  // Entry-point outputs come back in entrypoint order: [bootstrap, ...routeFiles]. Map each route file
@@ -615,7 +785,7 @@ export async function buildClient(options) {
615
785
  }
616
786
  const fileToChunk = new Map();
617
787
  routeFiles.forEach((file, i) => {
618
- const out = entryPoints[i + 1]; // in range — length checked above
788
+ const out = entryPoints[i + 1]; // in range - length checked above
619
789
  if (out !== undefined)
620
790
  fileToChunk.set(file, toUrl(out.path));
621
791
  });
@@ -629,9 +799,9 @@ export async function buildClient(options) {
629
799
  }
630
800
  if (routeManifest.notFound)
631
801
  routes._404 = chunksFor([routeManifest.notFound.file]);
632
- // 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
633
803
  // import from the JS). The bootstrap lazily imports every route, so its **aggregate** stylesheet is
634
- // 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
635
805
  // entry below. Fallback to all CSS assets if Bun emitted no distinct aggregate.
636
806
  const bootstrapName = entryName(entryFile); // `_nifra-entry`
637
807
  const cssNameOf = (path) => {
@@ -643,12 +813,12 @@ export async function buildClient(options) {
643
813
  .filter((o) => cssNameOf(o.path) === bootstrapName)
644
814
  .map((o) => toUrl(o.path));
645
815
  const css = aggregate.length > 0 ? aggregate : cssAssets.map((o) => toUrl(o.path));
646
- // CSS — per-route: each route/layout file is its own entrypoint, so the build metafile records its
647
- // `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
648
818
  // consumer; verified). Keyed by the metafile's unique source `entryPoint`, so it survives
649
819
  // same-basename collisions (`index.tsx` + `blog/index.tsx`) that a filename match can't. A page then
650
820
  // links only its layout chain + own CSS (deduped); an empty array means the page needs no CSS at all.
651
- // 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.
652
822
  const cwd = process.cwd();
653
823
  const cssByEntry = new Map();
654
824
  for (const out of Object.values(clientMeta?.outputs ?? {})) {
@@ -673,10 +843,15 @@ export async function buildClient(options) {
673
843
  if (routeManifest.notFound)
674
844
  routeStyles._404 = stylesFor([routeManifest.notFound.file]);
675
845
  }
846
+ // Copy `public/` into the output next to the hashed assets. A missing directory is normal (most
847
+ // apps have none) and must not fail the build.
848
+ const publicDir = options.publicDir === false ? undefined : (options.publicDir ?? "public");
849
+ const publicFiles = publicDir !== undefined && existsSync(publicDir) ? await copyPublicDir(publicDir, outDir) : [];
676
850
  const manifest = {
677
851
  entry: toUrl(bootstrap.path),
678
852
  assets: result.outputs.map((o) => toUrl(o.path)),
679
853
  routes,
854
+ ...(publicFiles.length > 0 ? { publicFiles } : {}),
680
855
  ...(css.length > 0 ? { css } : {}),
681
856
  ...(Object.keys(routeStyles).length > 0 ? { routeStyles } : {}),
682
857
  };
@@ -688,7 +863,7 @@ export async function buildClient(options) {
688
863
  * on workerd, and `Bun.build` always applies the `bun` condition (it wins over `workerd`/`edge-light`),
689
864
  * so conditions alone can't select the edge build. This shim pins `react-dom/server` to its edge build
690
865
  * (`server.edge.js`, which exports `renderToReadableStream`). A no-op when nothing imports react-dom
691
- * (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.
692
867
  */
693
868
  const reactDomEdgePlugin = (from) => ({
694
869
  name: "nifra-react-dom-edge",
@@ -700,11 +875,11 @@ const reactDomEdgePlugin = (from) => ({
700
875
  });
701
876
  /**
702
877
  * Dedupe React to a single copy. A `file:`-linked package can ship its OWN `react` under its own
703
- * 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
704
879
  * throws the cryptic `null is not an object (evaluating '…H.useState')` (the second renderer's dispatcher
705
880
  * is null). This was the #1 time-sink in app builds. Pinning `react` + its JSX runtimes to ONE resolved
706
881
  * copy (the app's, from `from`) guarantees a single dispatcher; `react-dom`, which imports `react`, then
707
- * 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
708
883
  * (an unresolvable spec is skipped; the resolver only fires on an exact match). React core is
709
884
  * condition-agnostic, so pinning it doesn't disturb the edge/browser/server conditions that select
710
885
  * react-dom's build.
@@ -719,7 +894,7 @@ export const reactDedupePlugin = (from) => ({
719
894
  resolved = Bun.resolveSync(spec, from);
720
895
  }
721
896
  catch {
722
- 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
723
898
  }
724
899
  const escaped = spec.replace(/[/\\^$*+?.()|[\]{}]/g, "\\$&");
725
900
  build.onResolve({ filter: new RegExp(`^${escaped}$`) }, () => ({ path: resolved }));
@@ -727,14 +902,14 @@ export const reactDedupePlugin = (from) => ({
727
902
  },
728
903
  });
729
904
  /**
730
- * 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
731
906
  * bug for the Preact framework (which had NO build-time dedup before). A `file:`-linked package can ship
732
907
  * its OWN `preact`, so the bundle ends up with two Preact cores; since `preact-render-to-string` mutates
733
908
  * `preact`'s shared `options` global and `preact/hooks` writes the SAME global, two copies → two `options`
734
909
  * → SSR throws `undefined is not an object (… __H)` (the vnode's hook state was set up on the other copy).
735
910
  * Pinning `preact` + its hooks/compat/jsx subpaths to ONE resolved copy (the app's, from `from`) makes the
736
911
  * renderer and the components share one core. No-op when Preact isn't used (an unresolvable spec is
737
- * 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
738
913
  * unlike react-dom there is no edge-vs-server build to preserve, so pinning the subpaths is safe.
739
914
  */
740
915
  const PREACT_DEDUPE_SPECS = [
@@ -753,7 +928,7 @@ export const preactDedupePlugin = (from) => ({
753
928
  resolved = Bun.resolveSync(spec, from);
754
929
  }
755
930
  catch {
756
- 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
757
932
  }
758
933
  const escaped = spec.replace(/[/\\^$*+?.()|[\]{}]/g, "\\$&");
759
934
  build.onResolve({ filter: new RegExp(`^${escaped}$`) }, () => ({ path: resolved }));
@@ -761,10 +936,10 @@ export const preactDedupePlugin = (from) => ({
761
936
  },
762
937
  });
763
938
  /**
764
- * 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
765
940
  * the same class of bug for Svelte (which had NO build-time dedup before). A workspace- or file-linked
766
941
  * `@nifrajs/web-svelte` can resolve its OWN `svelte` (e.g. a sibling repo's install store) while the app's
767
- * 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
768
943
  * (`svelte/internal/client`) holds module-level component-context state, so two copies means the compiled
769
944
  * components register on one runtime while `hydrate` runs on the other → hydration throws
770
945
  * `Cannot read properties of undefined (reading 'call')` and the server-rendered markup is wiped.
@@ -783,26 +958,46 @@ export const svelteDedupePlugin = (from) => ({
783
958
  return { path: Bun.resolveSync(args.path, from) };
784
959
  }
785
960
  catch {
786
- 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
787
962
  }
788
963
  });
789
964
  },
790
965
  });
791
966
  /**
792
967
  * Remix-style `.server` convention for the CLIENT build. A module named `*.server.ts(x)` (`db.server.ts`,
793
- * `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 /
794
969
  * Capacitor) import subtree never reaches the client. The body is CJS-with-a-Proxy so any named OR default
795
970
  * import resolves to `undefined` rather than a "missing export" bundle error (verified), and the real
796
971
  * import subtree is gone. The complement to the node-builtin guard: when a server-only import is co-located
797
972
  * in a route file (so it can't be tree-shaken out and the guard fails loud), moving it into a `*.server`
798
- * 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.
799
974
  */
800
- const SERVER_ONLY_MODULE = /\.server(\.[cm]?[jt]sx?)?$/;
801
975
  export const serverOnlyEmptyPlugin = () => ({
802
976
  name: "nifra-server-only-empty",
803
977
  setup(build) {
804
978
  build.onLoad({ filter: SERVER_ONLY_MODULE }, () => ({
805
- 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)),
806
1001
  loader: "js",
807
1002
  }));
808
1003
  },
@@ -831,7 +1026,7 @@ const solidWebServerPlugin = (from) => ({
831
1026
  * (`generateServerManifest`, written next to `serverEntry`), then bundles `serverEntry` with
832
1027
  * `Bun.build` using **edge conditions** + the adapter's SSR plugins. The output imports no `node:fs`
833
1028
  * and does no dynamic-path import, so it runs on workerd: point `wrangler.toml`'s `main` at it and
834
- * 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
835
1030
  * silently ships a broken worker.
836
1031
  */
837
1032
  export async function buildServer(options) {
@@ -847,7 +1042,7 @@ export async function buildServer(options) {
847
1042
  // Edge (browser) target: Bun's `bun` condition contaminates react-dom's server build + the `worker`
848
1043
  // condition segfaults Bun.build on solid, so force the edge/server builds via shims. The `node`/`bun`
849
1044
  // targets resolve those correctly via their own condition, so the shims (and edge conditions) don't
850
- // 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`).
851
1046
  const edge = target === "browser";
852
1047
  const conditions = options.conditions ?? (edge ? ["workerd", "edge-light", "browser"] : [target]);
853
1048
  const manifest = discoverRoutes(routesDir);
@@ -862,11 +1057,12 @@ export async function buildServer(options) {
862
1057
  // Tag every BUNDLED SSR output so @nifrajs/web-react's react-dom adapter takes the static
863
1058
  // (bundled, deduped) react-dom instead of re-rooting react-dom/server to a DISK copy at runtime.
864
1059
  // A `target:"bun"` bundle still has `Bun.resolveSync` under the Bun runtime, so without this tag the
865
- // 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
866
1061
  // is null for the bundled components → SSR throws `…H.useRef of null`. Always set (a structural fact
867
1062
  // of bundling, layered after any caller `define` so it can't be overridden). Unbundled Bun runtimes
868
- // (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.
869
1064
  "process.env.NIFRA_SSR_BUNDLED": '"1"',
1065
+ "globalThis.__NIFRA_EDGE_RUNTIME__": edge ? "true" : "false",
870
1066
  },
871
1067
  minify: options.minify ?? true,
872
1068
  // Lazy → one chunk per route (loaded on first request); eager → a single self-contained file.
@@ -888,12 +1084,12 @@ export async function buildServer(options) {
888
1084
  return { worker: entryOutput.path, outputs: result.outputs.map((o) => o.path) };
889
1085
  }
890
1086
  // ===================================================================================================
891
- // `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.
892
1088
  //
893
1089
  // An app already declares everything the build needs through nifra's conventions: `adapter` +
894
1090
  // `clientModule` (nifra.config.ts / framework.ts), an optional `backend` (backend.ts), and `routes/`.
895
1091
  // The ONLY thing apps used to hand-write per target was the server entry (`_worker.ts`, `server-bun.ts`,
896
- // …) — 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.
897
1093
  // ===================================================================================================
898
1094
  /** A deploy target `nifra build --target <t>` can emit. `static` is pure SSG (no server). */
899
1095
  export const BUILD_TARGETS = ["bun", "node", "deno", "cf-pages", "vercel", "static"];
@@ -910,12 +1106,12 @@ export function isBuildTarget(value) {
910
1106
  * - `deno`: same fetch-handler default, plus `Deno.serve` self-host when run directly.
911
1107
  * - `bun` / `node`: a self-hosting server that ALSO serves the client bundle from disk (those
912
1108
  * runtimes have a filesystem; the static `/assets/*` sit next to the entry).
913
- * `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) -
914
1110
  * `buildServer` writes the entry next to `serverEntry`, so they're resolved from there. Pure (string in,
915
1111
  * string out) so the generation is unit-testable without a real build.
916
1112
  */
917
1113
  export function generateServerEntry(options) {
918
- const { target, adapterImport, backendImport, title = "nifra" } = options;
1114
+ const { target, adapterImport, backendImport, title = "nifra", publicFiles = [] } = options;
919
1115
  if (target === "static") {
920
1116
  throw new Error("[nifra/web] generateServerEntry: `static` has no server entry (SSG only)");
921
1117
  }
@@ -935,30 +1131,35 @@ export function generateServerEntry(options) {
935
1131
  lines.push('import { serve } from "@nifrajs/node"');
936
1132
  lines.push("", "const app = createWebApp({", " adapter,", " manifest,", " clientEntry,", " styles,", " routeStyles,", ...(backendImport !== undefined ? [" api: inProcessClient(backend),"] : []), ` title: ${JSON.stringify(title)},`, "})", "");
937
1133
  if (target === "cf-pages") {
938
- // Cloudflare Pages advanced mode: `_routes.json` serves /assets/* from the CDN; everything else
939
- // falls through to this fetch handler (SSR). The default export is the handler object.
940
- lines.push("export default toFetchHandler(app)");
1134
+ // Cloudflare Pages advanced mode: `_routes.json` keeps static paths off the worker entirely, and
1135
+ // everything else falls through to this handler (SSR).
1136
+ //
1137
+ // That exclude list cannot always name every public file - Cloudflare caps `_routes.json` at 100
1138
+ // rules - so a static request CAN arrive here. Serve it from the ASSETS binding instead of letting
1139
+ // the router 404 it, which makes correctness independent of how much of the list fit: the exclude
1140
+ // list decides only how many static requests skip the worker, never which files exist.
1141
+ lines.push(`const PUBLIC_FILES = new Set(${JSON.stringify(publicFiles)})`, "const handler = toFetchHandler(app)", "type Assets = { readonly ASSETS?: { fetch(request: Request): Promise<Response> } }", "export default {", " fetch(request: Request, env: Assets, ctx: ExecutionContext) {", " const { pathname } = new URL(request.url)", " // Only the build's own outputs: the hashed bundle prefix and the files it copied.", ' if (env?.ASSETS !== undefined && (pathname.startsWith("/assets/") || PUBLIC_FILES.has(pathname))) {', " return env.ASSETS.fetch(request)", " }", " return handler.fetch(request, env as never, ctx)", " },", "}");
941
1142
  return `${lines.join("\n")}\n`;
942
1143
  }
943
1144
  if (target === "vercel") {
944
- 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)");
945
1146
  return `${lines.join("\n")}\n`;
946
1147
  }
947
1148
  // bun / node / deno self-host AND serve the client bundle from disk (it sits next to this entry).
948
- lines.push("// The client bundle lives next to this entry; serve /assets/* from disk, SSR everything else.", 'const ASSETS = new URL("./assets/", import.meta.url)', 'const TYPES = { js: "text/javascript", css: "text/css", map: "application/json" }');
1149
+ lines.push("// The client bundle and public files live next to this entry; serve only manifest-approved paths.", 'const STATIC_ROOT = new URL("./", import.meta.url)', `const PUBLIC_FILES = new Set(${JSON.stringify(publicFiles)})`, "const staticPath = (pathname: string): string | undefined => {", " if (PUBLIC_FILES.has(pathname)) return '.' + pathname", ' if (!pathname.startsWith("/assets/")) return undefined', ' const segments = pathname.slice(1).split("/")', " // `.` and `..` match the character class, and the path is joined onto a base URL that resolves", " // dot segments - so they are rejected explicitly rather than relying on the request URL having", " // been normalized upstream.", " const safe = (segment: string): boolean =>", ' segment !== "." && segment !== ".." && /^[A-Za-z0-9._-]+$/.test(segment)', " return segments.every(safe) ? '.' + pathname : undefined", "}", 'const TYPES = { js: "text/javascript", css: "text/css", map: "application/json" }');
949
1150
  if (target === "bun") {
950
- lines.push("const server = Bun.serve({", " port: Number(Bun.env.PORT ?? 3000),", " async fetch(req) {", " const { pathname } = new URL(req.url)", ' if (pathname.startsWith("/assets/")) {', ' const name = pathname.slice("/assets/".length)', ' if (!/^[A-Za-z0-9._-]+$/.test(name)) return new Response("bad request", { status: 400 })', " const file = Bun.file(new URL(name, ASSETS))", ' if (!(await file.exists())) return new Response("not found", { status: 404 })', ' const ext = name.slice(name.lastIndexOf(".") + 1)', ' return new Response(file, { headers: { "content-type": TYPES[ext] ?? "application/octet-stream" } })', " }", " return app.fetch(req)", " },", "})",
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)", " },", "})",
951
1152
  // The `${...}` here is literal OUTPUT (a template in the GENERATED file), not a template in this
952
- // 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.
953
1154
  `console.log(\`nifra (Bun) → http://localhost:$${"{server.port}"}\`)`);
954
1155
  return `${lines.join("\n")}\n`;
955
1156
  }
956
1157
  if (target === "node") {
957
- lines.push('import { readFile } from "node:fs/promises"', "await serve(", " {", " async fetch(req) {", " const { pathname } = new URL(req.url)", ' if (pathname.startsWith("/assets/")) {', ' const name = pathname.slice("/assets/".length)', ' if (!/^[A-Za-z0-9._-]+$/.test(name)) return new Response("bad request", { status: 400 })', " try {", " const body = await readFile(new URL(name, ASSETS))", ' const ext = name.slice(name.lastIndexOf(".") + 1)', ' return new Response(body, { headers: { "content-type": TYPES[ext] ?? "application/octet-stream" } })', " } catch {", ' return new Response("not found", { status: 404 })', " }", " }", " return app.fetch(req)", " },", " },", " { port: Number(process.env.PORT ?? 3000) },", ")");
1158
+ lines.push('import { readFile } from "node:fs/promises"', "await serve(", " {", " async fetch(req) {", " const { pathname } = new URL(req.url)", " const filePath = staticPath(pathname)", " if (filePath !== undefined) {", " try {", " const body = await readFile(new URL(filePath, STATIC_ROOT))", ' const ext = pathname.slice(pathname.lastIndexOf(".") + 1)', ' return new Response(body, { headers: { "content-type": TYPES[ext] ?? "application/octet-stream" } })', " } catch {", ' return new Response("not found", { status: 404 })', " }", " }", " return app.fetch(req)", " },", " },", " { port: Number(process.env.PORT ?? 3000) },", ")");
958
1159
  return `${lines.join("\n")}\n`;
959
1160
  }
960
1161
  // deno
961
- 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)", ' if (pathname.startsWith("/assets/")) {', ' const name = pathname.slice("/assets/".length)', ' if (!/^[A-Za-z0-9._-]+$/.test(name)) return new Response("bad request", { status: 400 })', " try {", " // @ts-ignore — Deno.readFile is present on the Deno runtime.", " const body = await Deno.readFile(new URL(name, ASSETS))", ' const ext = name.slice(name.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)", "})");
962
1163
  return `${lines.join("\n")}\n`;
963
1164
  }
964
1165
  /** The Bun.build `target` each deploy target compiles its server with (mirrors `buildServer`'s docs). */
@@ -984,22 +1185,39 @@ async function measureOutputs(paths) {
984
1185
  }
985
1186
  return chunks;
986
1187
  }
1188
+ /** The default (Bun) strategy - `buildClient`/`buildServer` from this module. */
1189
+ export const bunBundler = {
1190
+ buildClient: (input) => buildClient({
1191
+ routesDir: input.routesDir,
1192
+ outDir: input.outDir,
1193
+ clientModule: input.clientModule,
1194
+ ...(input.plugins ? { plugins: input.plugins } : {}),
1195
+ ...(input.conditions ? { conditions: input.conditions } : {}),
1196
+ ...(input.define ? { define: input.define } : {}),
1197
+ ...(input.publicDir !== undefined ? { publicDir: input.publicDir } : {}),
1198
+ ...(input.publicEnvPrefix !== undefined ? { publicEnvPrefix: input.publicEnvPrefix } : {}),
1199
+ }),
1200
+ buildServer: (input) => buildServer({
1201
+ routesDir: input.routesDir,
1202
+ serverEntry: input.serverEntry,
1203
+ outDir: input.outDir,
1204
+ clientEntry: input.clientEntry,
1205
+ target: input.target,
1206
+ ...(input.plugins ? { plugins: input.plugins } : {}),
1207
+ ...(input.define ? { define: input.define } : {}),
1208
+ }),
1209
+ };
1210
+ /** Build a full deploy dir for `target` using the default Bun bundler. See {@link buildTargetWith}. */
1211
+ export async function buildTarget(target, options) {
1212
+ return buildTargetWith(target, options, bunBundler);
1213
+ }
987
1214
  /**
988
- * Build a full deploy directory for `target` from a file-routed nifra app. Emits the client bundle to
989
- * `<outDir>/assets/*`, then per target:
990
- * - `static`: prerenders opted-in routes (`prerenderRoutes`) to `<outDir>/<path>/index.html` (+
991
- * `_data.json`); needs `prerenderApp`. No server.
992
- * - `cf-pages`: a `_worker.js` (edge bundle) + a `_routes.json` excluding /assets/* from the worker.
993
- * - `vercel`: a `.vercel/output`-shaped function isn't emitted here — `vercel` emits the bundled edge
994
- * entry as `<outDir>/index.js` (the CLI's docs point at `vercel`'s Build Output wrapper). [see note]
995
- * - `deno`/`node`/`bun`: the self-hosting server bundle (`server.js`) next to the assets.
996
- * The server entry is GENERATED (`generateServerEntry`) and bundled (`buildServer`); the app supplies
997
- * only adapter/backend/routes. Returns the manifest + a size report. Throws on any build failure.
998
- *
999
- * Note: the heavier platform wrappers (`.vercel/output` v3 layout, wrangler ISR `find_additional_modules`)
1000
- * remain app-owned scripts; this command targets the common single-bundle deploys. See the CLI docs.
1215
+ * The bundler-agnostic deploy orchestrator: everything `buildTarget` does EXCEPT the two bundling steps,
1216
+ * which come from `bundler`. `buildTarget` passes {@link bunBundler}; `buildTargetVite`
1217
+ * (`@nifrajs/web/build-vite`) passes the Vite strategy. One orchestrator, so the deploy-dir shape,
1218
+ * server-entry codegen, prerender and size report are identical across pipelines.
1001
1219
  */
1002
- export async function buildTarget(target, options) {
1220
+ export async function buildTargetWith(target, options, bundler) {
1003
1221
  const { routesDir, outDir, workDir } = options;
1004
1222
  const { rmSync } = await import("node:fs");
1005
1223
  rmSync(outDir, { recursive: true, force: true });
@@ -1008,14 +1226,25 @@ export async function buildTarget(target, options) {
1008
1226
  mkdirSync(assetsDir, { recursive: true });
1009
1227
  mkdirSync(workDir, { recursive: true });
1010
1228
  // (1) Client bundle → <outDir>/assets/* (every target ships the same hashed client bundle).
1011
- const client = await buildClient({
1229
+ let client = await bundler.buildClient({
1012
1230
  routesDir,
1013
1231
  outDir: assetsDir,
1014
1232
  clientModule: options.clientModule,
1015
1233
  ...(options.clientPlugins ? { plugins: options.clientPlugins } : {}),
1016
1234
  ...(options.conditions ? { conditions: options.conditions } : {}),
1017
1235
  define: { "process.env.NODE_ENV": '"production"', ...(options.define ?? {}) },
1236
+ publicDir: false,
1237
+ ...(options.publicEnvPrefix !== undefined ? { publicEnvPrefix: options.publicEnvPrefix } : {}),
1238
+ root: resolvePath(dirname(routesDir)),
1018
1239
  });
1240
+ const publicDir = options.publicDir === false
1241
+ ? undefined
1242
+ : resolvePath(options.publicDir ?? join(dirname(routesDir), "public"));
1243
+ const publicFiles = publicDir !== undefined && existsSync(publicDir) ? await copyPublicDir(publicDir, outDir) : [];
1244
+ if (publicFiles.length > 0) {
1245
+ client = { ...client, publicFiles };
1246
+ writeFileSync(`${assetsDir}/manifest.json`, JSON.stringify(client, null, 2));
1247
+ }
1019
1248
  if (target === "static") {
1020
1249
  if (options.prerenderApp === undefined) {
1021
1250
  throw new Error("[nifra/web] buildTarget(static) requires `prerenderApp` (a factory `(client) => createWebApp`)");
@@ -1032,8 +1261,8 @@ export async function buildTarget(target, options) {
1032
1261
  });
1033
1262
  if (result.prerendered.length === 0) {
1034
1263
  // A static build that renders nothing is almost always a misconfig (no `prerender = true` / no
1035
- // getStaticPaths) — fail loudly rather than ship an empty dir the dev thinks is their site.
1036
- 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 " +
1037
1266
  "`export const prerender = true` (static) or `getStaticPaths` (dynamic).");
1038
1267
  }
1039
1268
  rmSync(workDir, { recursive: true, force: true });
@@ -1055,9 +1284,10 @@ export async function buildTarget(target, options) {
1055
1284
  adapterImport: options.adapterImport,
1056
1285
  ...(options.backendImport !== undefined ? { backendImport: options.backendImport } : {}),
1057
1286
  ...(options.title !== undefined ? { title: options.title } : {}),
1287
+ ...(publicFiles.length > 0 ? { publicFiles } : {}),
1058
1288
  }));
1059
1289
  const serverTarget = SERVER_BUILD_TARGET[target];
1060
- const { worker } = await buildServer({
1290
+ const { worker } = await bundler.buildServer({
1061
1291
  routesDir,
1062
1292
  serverEntry: serverEntryPath,
1063
1293
  outDir: `${workDir}/server`,
@@ -1065,13 +1295,24 @@ export async function buildTarget(target, options) {
1065
1295
  target: serverTarget,
1066
1296
  ...(options.serverPlugins ? { plugins: options.serverPlugins } : {}),
1067
1297
  define: { "process.env.NODE_ENV": '"production"', ...(options.define ?? {}) },
1298
+ root: resolvePath(dirname(routesDir)),
1068
1299
  });
1069
1300
  // (3) Assemble the deploy dir for the target.
1070
1301
  const { cpSync } = await import("node:fs");
1071
1302
  let run;
1072
1303
  if (target === "cf-pages") {
1073
1304
  cpSync(worker, `${outDir}/_worker.js`);
1074
- writeFileSync(`${outDir}/_routes.json`, `${JSON.stringify({ version: 1, include: ["/*"], exclude: ["/assets/*"] }, null, 2)}\n`);
1305
+ // The app's real patterns, so a directory is only collapsed into a glob once the route table
1306
+ // proves nothing can be served beneath it.
1307
+ const rules = cloudflareRouteRules(publicFiles, discoverRoutes(routesDir).routes.map((route) => route.pattern));
1308
+ writeFileSync(`${outDir}/_routes.json`, `${JSON.stringify({ version: 1, include: rules.include, exclude: rules.exclude }, null, 2)}\n`);
1309
+ // Never cap silently: an omitted file still serves (the worker's ASSETS fallback), but it costs a
1310
+ // worker invocation, and that trade should be visible rather than inferred from a latency graph.
1311
+ if (rules.omitted.length > 0) {
1312
+ console.log(`[nifra/web] _routes.json holds ${rules.exclude.length} of ${publicFiles.length + 1} static rules ` +
1313
+ `(Cloudflare allows ${CF_MAX_ROUTE_RULES}). The other ${rules.omitted.length} file(s) are served by the ` +
1314
+ "worker via ASSETS instead of the CDN directly - correct, just one invocation each.");
1315
+ }
1075
1316
  run = `Cloudflare Pages → ${outDir} (deploy: wrangler pages deploy ${basename(outDir)})`;
1076
1317
  }
1077
1318
  else if (target === "vercel") {