@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/src/build.ts CHANGED
@@ -1,17 +1,26 @@
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"
10
- import type { BunPlugin } from "bun"
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 { type BunPlugin, Glob } from "bun"
11
12
  import { sanitizeOutputNames } from "./chunk-names.ts"
12
13
  import { discoverRoutes } from "./fs.ts"
13
14
  import { generateClientEntry, generateServerManifest } from "./index.ts"
15
+ import {
16
+ generateServerFnStub,
17
+ SERVER_FN_MODULE,
18
+ SERVER_ONLY_MODULE,
19
+ SERVER_ONLY_REPLACEMENT,
20
+ serverFnNamespace,
21
+ } from "./internal/server-boundary.ts"
14
22
  // `buildTarget(static)` drives the SSG prerender engine directly (it's also re-exported below).
23
+ import { type ClientModuleGraph, fromBunMetafile } from "./module-graph.ts"
15
24
  import { prerenderRoutes } from "./prerender.ts"
16
25
 
17
26
  // Build-time SSG: prerender opted-in static + dynamic routes to `index.html` (+ static `_data.json`),
@@ -48,11 +57,20 @@ export interface BuildClientOptions {
48
57
  readonly minify?: boolean
49
58
  /** URL prefix the assets are served under (default `"/assets/"`); also Bun's chunk `publicPath`. */
50
59
  readonly publicPath?: string
60
+ /**
61
+ * Directory of user-authored static files copied into the build and served at the root (default
62
+ * `"public"`). Absent directory ⇒ nothing copied, no error.
63
+ *
64
+ * NOT the same thing as {@link publicPath}, despite the names: that is the URL prefix for
65
+ * content-hashed bundle chunks and never covers files an author put on disk. The collision is a
66
+ * real source of confusion, which is why both are spelled out here.
67
+ */
68
+ readonly publicDir?: string | false
51
69
  /**
52
70
  * Prefix that opts an environment variable into the **client** bundle (Vite/Next convention; default
53
71
  * `"PUBLIC_"`). Every var in the build environment whose name starts with this prefix is baked into
54
72
  * the client `define` as `"process.env.NAME": JSON.stringify(value)`, so `process.env.PUBLIC_API_URL`
55
- * compiles to its literal value in the browser. Vars WITHOUT the prefix are never exposed — the bare
73
+ * compiles to its literal value in the browser. Vars WITHOUT the prefix are never exposed - the bare
56
74
  * `process.env` define resolves them to `undefined`, so server secrets can't leak into the client
57
75
  * bundle. Set to `""` to disable auto-exposure entirely (no var is baked in). `options.define` still
58
76
  * wins over an auto-exposed var (it's layered last). Sourced from `Bun.env` (falls back to
@@ -82,26 +100,209 @@ export function publicEnvDefines(
82
100
  return defines
83
101
  }
84
102
 
85
- /** The built asset map — the server reads `entry` for the client script + serves `assets`. */
103
+ /**
104
+ * Percent-encode one path segment exactly the way a browser encodes it into a request URL.
105
+ *
106
+ * NOT `encodeURIComponent`. That escapes the sub-delimiters `, @ + = & ; $`, which a browser sends raw -
107
+ * so a file named `report,2026.csv` would be recorded as `/report%2C2026.csv` while the request arrives
108
+ * as `/report,2026.csv`, the allowlist lookup misses, and the file 404s in production only. `encodeURI`
109
+ * agrees with `URL.pathname` on every character except `?` and `#`, which terminate a path and so must
110
+ * be escaped explicitly here.
111
+ */
112
+ function encodePathSegment(segment: string): string {
113
+ return encodeURI(segment).replace(
114
+ /[?#]/g,
115
+ (char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`,
116
+ )
117
+ }
118
+
119
+ /**
120
+ * Cloudflare Pages rejects a `_routes.json` with more than 100 include+exclude rules combined, or any
121
+ * single rule longer than 100 characters.
122
+ */
123
+ const CF_MAX_ROUTE_RULES = 100
124
+ const CF_MAX_RULE_LENGTH = 100
125
+
126
+ export interface CloudflareRouteRules {
127
+ readonly include: readonly string[]
128
+ readonly exclude: readonly string[]
129
+ /** Public files the budget could not name, so the build can say so instead of capping silently. */
130
+ readonly omitted: readonly string[]
131
+ }
132
+
133
+ /**
134
+ * What a route pattern's first URL segment says about the paths it can reach.
135
+ *
136
+ * The three cases are genuinely different and collapsing them is a fail-open bug: `/` reaches only the
137
+ * root and can never match below a directory, while `/:locale/about` can match below ANY directory, and
138
+ * both would otherwise read as "no literal first segment". `dynamic` is also where unrecognised syntax
139
+ * lands, deliberately - a pattern form this does not understand must never be taken as proof that
140
+ * nothing matches.
141
+ */
142
+ type FirstSegment =
143
+ | { readonly kind: "root" }
144
+ | { readonly kind: "literal"; readonly value: string }
145
+ | { readonly kind: "dynamic" }
146
+
147
+ function firstSegmentOf(pattern: string): FirstSegment {
148
+ const first = pattern.split("/").find((segment) => segment !== "")
149
+ if (first === undefined) return { kind: "root" }
150
+ return /^[A-Za-z0-9._-]+$/.test(first) ? { kind: "literal", value: first } : { kind: "dynamic" }
151
+ }
152
+
153
+ /**
154
+ * Directories under `public/` whose every file can be replaced by one `/<dir>/*` rule.
155
+ *
156
+ * Safe only when no route can ever be served beneath that directory, because the glob does not merely
157
+ * describe today's files - it hands Pages every future path under the prefix, and the worker never sees
158
+ * them. A `public/blog/hero.png` next to a `/blog/:slug` route is the ordinary case that breaks: the
159
+ * glob would send `/blog/my-post` to the CDN, which has no such file, and the page 404s in production
160
+ * only. So a directory collapses only when every route pattern starts with a literal segment that is
161
+ * something else. One dynamic first segment anywhere (`/:locale/…`) disables collapsing entirely, which
162
+ * is correct - such a route can match under any directory name.
163
+ *
164
+ * A collapsed directory also gives up the app's own 404 page for a missing file beneath it, since the
165
+ * request never reaches the worker. That is the right trade for a directory that holds only static
166
+ * files: a missing image should fail as a fast CDN 404, not as an HTML error page.
167
+ */
168
+ function collapsibleDirs(
169
+ publicFiles: readonly string[],
170
+ routePatterns: readonly string[],
171
+ ): ReadonlySet<string> {
172
+ const reserved = new Set<string>()
173
+ for (const pattern of routePatterns) {
174
+ const first = firstSegmentOf(pattern)
175
+ // A dynamic first segment can match anywhere, so nothing is collapsible at all.
176
+ if (first.kind === "dynamic") return new Set()
177
+ // `root` reserves nothing: `/` cannot match a path below a directory.
178
+ if (first.kind === "literal") reserved.add(first.value)
179
+ }
180
+ const dirs = new Set<string>()
181
+ for (const file of publicFiles) {
182
+ const slash = file.indexOf("/", 1)
183
+ if (slash === -1) continue // a root-level file has no directory to collapse into
184
+ const dir = file.slice(1, slash)
185
+ if (!reserved.has(dir)) dirs.add(dir)
186
+ }
187
+ return dirs
188
+ }
189
+
190
+ /**
191
+ * Build the cf-pages `_routes.json` rules for a set of copied public files, within Cloudflare's budget.
192
+ *
193
+ * `exclude` is what Pages serves straight from the CDN instead of invoking the worker, so naming every
194
+ * public file is ideal - and impossible past ~99 of them. A `public/` of icons, fonts and share images
195
+ * clears 100 easily, and the rejection lands at `wrangler pages deploy`, long after the build reported
196
+ * success.
197
+ *
198
+ * A glob is emitted only where it cannot be wrong. `/assets/*` always is: the build owns that prefix
199
+ * outright, since it is where the hashed bundle is written. A `public/` subdirectory is different - the
200
+ * name is the author's, and a `/blog/*` rule would hand Pages every future path under `/blog/`, so a
201
+ * `/blog/:slug` route would 404 from the CDN in production only. {@link collapsibleDirs} therefore
202
+ * collapses a directory only after checking it against the app's real route patterns, which is what
203
+ * makes the compaction safe rather than merely smaller. Everything else stays an exact path, and a name
204
+ * containing `*` is dropped rather than escaped, since the rule would become a wildcard.
205
+ *
206
+ * What still does not fit is dropped, which is safe because the list is only an optimization: the
207
+ * generated worker serves any allowlisted path it receives through the ASSETS binding, so an omitted
208
+ * file costs one worker invocation, not a 404.
209
+ */
210
+ export function cloudflareRouteRules(
211
+ publicFiles: readonly string[],
212
+ /**
213
+ * Every route pattern the app serves. Required, not defaulted: an empty list means "this app serves
214
+ * no routes", which makes every directory collapsible - a defensible answer to state deliberately and
215
+ * a dangerous one to arrive at by forgetting the argument.
216
+ */
217
+ routePatterns: readonly string[],
218
+ ): CloudflareRouteRules {
219
+ const include = ["/*"]
220
+ const exclude = ["/assets/*"]
221
+ const omitted: string[] = []
222
+
223
+ // Collapse only where the route table proves nothing can be served beneath the directory, and only
224
+ // when the per-file list would not have fit anyway - a smaller `_routes.json` is worth nothing on its
225
+ // own, and exact paths keep the app's own 404 for a missing file under that directory.
226
+ const perFileFits = publicFiles.length + include.length + exclude.length <= CF_MAX_ROUTE_RULES
227
+ const collapsible = perFileFits ? new Set<string>() : collapsibleDirs(publicFiles, routePatterns)
228
+ const emitted = new Set<string>()
229
+
230
+ for (const file of publicFiles) {
231
+ const slash = file.indexOf("/", 1)
232
+ const dir = slash === -1 ? undefined : file.slice(1, slash)
233
+ const collapsed = dir !== undefined && collapsible.has(dir)
234
+ const rule = collapsed ? `/${dir}/*` : file
235
+ if (emitted.has(rule)) continue // already covered by a rule emitted for an earlier file
236
+ // A `*` in a FILE name would turn that file's own rule into a wildcard. A collapsed rule's `*` is
237
+ // ours and deliberate, and the file's name never appears in it, so the check applies to one case.
238
+ if (!collapsed && file.includes("*")) {
239
+ omitted.push(file)
240
+ continue
241
+ }
242
+ if (include.length + exclude.length >= CF_MAX_ROUTE_RULES || rule.length > CF_MAX_RULE_LENGTH) {
243
+ omitted.push(file)
244
+ continue
245
+ }
246
+ exclude.push(rule)
247
+ emitted.add(rule)
248
+ }
249
+ return { include, exclude, omitted }
250
+ }
251
+
252
+ /** The built asset map - the server reads `entry` for the client script + serves `assets`. */
253
+ /**
254
+ * Copy `from` into `to`, returning the URL paths copied (sorted).
255
+ *
256
+ * Lives here, not beside `servePublicDir`, because it is BUILD-time: it reaches for `Bun.Glob` and
257
+ * `node:fs`, and `public-dir.ts` is reachable from the client bundle graph through the package
258
+ * entry - a dynamic `import("bun")` there fails the browser build outright.
259
+ */
260
+ export async function copyPublicDir(from: string, to: string): Promise<string[]> {
261
+ const source = resolvePath(from)
262
+ const sourceReal = await realpath(source)
263
+ const copied: string[] = []
264
+ for await (const rel of new Glob("**/*").scan({ cwd: source, dot: true, onlyFiles: false })) {
265
+ const candidate = join(source, rel)
266
+ const candidateStat = await lstat(candidate)
267
+ if (candidateStat.isSymbolicLink()) {
268
+ throw new Error(`[nifra/web] publicDir entry escapes its root through a symlink: ${rel}`)
269
+ }
270
+ if (!candidateStat.isFile()) continue
271
+ const candidateReal = await realpath(candidate)
272
+ if (candidateReal !== sourceReal && !candidateReal.startsWith(sourceReal + sep)) {
273
+ throw new Error(`[nifra/web] publicDir entry escapes its root through a symlink: ${rel}`)
274
+ }
275
+ const target = join(to, rel)
276
+ await mkdir(join(target, ".."), { recursive: true })
277
+ await cp(candidateReal, target)
278
+ copied.push(`/${rel.split(sep).map(encodePathSegment).join("/")}`)
279
+ }
280
+ return copied.sort()
281
+ }
282
+
86
283
  export interface BuildManifest {
87
284
  /** URL of the client entry module (content-hashed). */
88
285
  readonly entry: string
89
- /** URLs of every emitted asset (entry + chunks) — for serving + preloading. */
286
+ /** URLs of every emitted asset (entry + chunks) - for serving + preloading. */
90
287
  readonly assets: readonly string[]
91
- /** `routeId → [layout chunk URLs…, own chunk URL]` — the chunks a route needs, for `createWebApp`'s
288
+ /** `routeId → [layout chunk URLs…, own chunk URL]` - the chunks a route needs, for `createWebApp`'s
92
289
  * `routePreload` (`<link rel="modulepreload">` the matched route alongside the entry). Each route +
93
290
  * layout is also a build entrypoint, so it gets a named chunk the bootstrap's lazy import dedupes to. */
94
291
  readonly routes: Readonly<Record<string, readonly string[]>>
95
- /** The app's bundled, content-hashed stylesheet(s) — the bootstrap's **aggregate** CSS (every
292
+ /** URL paths copied from `publicDir`, sorted. Lets the server entry serve them without scanning a
293
+ * directory per request, and lets an adapter that needs a file list (CDN upload, platform static
294
+ * assets) consume one. Omitted when there is no `public/`. */
295
+ readonly publicFiles?: readonly string[]
296
+ /** The app's bundled, content-hashed stylesheet(s) - the bootstrap's **aggregate** CSS (every
96
297
  * `import './x.css'` reachable from the app). The complete stylesheet regardless of which file
97
298
  * imported the CSS; the always-safe fallback `createWebApp` links when a route has no per-route entry
98
299
  * in {@link routeStyles}. Omitted when the app imports no CSS. */
99
300
  readonly css?: readonly string[]
100
- /** `routeId → [chain CSS URLs]` — only the stylesheets the matched route's layout chain + own file
301
+ /** `routeId → [chain CSS URLs]` - only the stylesheets the matched route's layout chain + own file
101
302
  * actually use (Bun emits a per-entrypoint CSS bundle per route/layout, with shared-component CSS
102
303
  * inlined into each consumer). `createWebApp` links these instead of the aggregate, so a page ships
103
304
  * only its own CSS. A route is omitted (→ aggregate fallback) when its `[name]` collides with another
104
- * route's basename (ambiguous CSS↔route) or the build emitted orphan shared-chunk CSS — correctness
305
+ * route's basename (ambiguous CSS↔route) or the build emitted orphan shared-chunk CSS - correctness
105
306
  * over minimality. Absent entirely when the app imports no CSS. */
106
307
  readonly routeStyles?: Readonly<Record<string, readonly string[]>>
107
308
  }
@@ -154,10 +355,10 @@ export interface NodeBuiltinFinding {
154
355
  * BFS the metafile import graph for the SHORTEST user-module path that pulls `builtin` into the
155
356
  * bundle, returning it as display labels `[entryFile, …as-written specifiers…, builtin]`. The frontier
156
357
  * starts at every `entryInput` (the route/user entrypoints) so the reported chain begins where the dev
157
- * actually wrote `import` — the actionable root, not an arbitrary internal module. Traversal crosses
358
+ * actually wrote `import` - the actionable root, not an arbitrary internal module. Traversal crosses
158
359
  * only NON-`node:` edges (so Bun's polyfill chain never extends the path) and stops at the first edge
159
360
  * whose target is the builtin. Returns `[builtin]` if no entry reaches it (defensive; a flagged builtin
160
- * is reachable by construction). Pure — operates on the graph, never the emitted text.
361
+ * is reachable by construction). Pure - operates on the graph, never the emitted text.
161
362
  */
162
363
  function shortestBuiltinChain(
163
364
  inputs: Readonly<Record<string, { readonly imports?: readonly BunMetafileImport[] }>>,
@@ -181,7 +382,7 @@ function shortestBuiltinChain(
181
382
  if (nodeBuiltinOf(im) === builtin) return [...chain, builtin]
182
383
  const target = im.path
183
384
  // Only follow edges into user (non-`node:`) modules that exist in the input graph and haven't
184
- // been visited — so the polyfill subtree can't lengthen the path and there are no cycles.
385
+ // been visited - so the polyfill subtree can't lengthen the path and there are no cycles.
185
386
  if (
186
387
  target === undefined ||
187
388
  target.startsWith("node:") ||
@@ -192,47 +393,47 @@ function shortestBuiltinChain(
192
393
  }
193
394
  seen.add(target)
194
395
  // Display the hop by its as-written specifier (`../db/client.ts`, `postgres`), falling back to
195
- // the resolved path — that's what the dev recognizes in their source, not the resolved path.
396
+ // the resolved path - that's what the dev recognizes in their source, not the resolved path.
196
397
  next.push({ node: target, chain: [...chain, im.original ?? target] })
197
398
  }
198
399
  }
199
400
  frontier = next
200
401
  }
201
- return [builtin] // unreachable from any entry — degrade to just the builtin (shouldn't happen)
402
+ return [builtin] // unreachable from any entry - degrade to just the builtin (shouldn't happen)
202
403
  }
203
404
 
204
405
  /**
205
406
  * Scan a build's metafile for any `node:` builtin that a USER module pulled into a CLIENT output
206
407
  * chunk, returning a sorted, deduped list of {@link NodeBuiltinFinding}s. Three graph facts combine so
207
408
  * the report is precise AND actionable:
208
- * 1. **What the user wrote** — only builtins imported by a NON-`node:` input count, so Bun's own
409
+ * 1. **What the user wrote** - only builtins imported by a NON-`node:` input count, so Bun's own
209
410
  * polyfill chain (`node:crypto` → `node:buffer`/`node:stream`/…) doesn't bury the real cause.
210
- * 2. **Where it landed** — the chunk is read from the per-output `inputs`, so the error names the
411
+ * 2. **Where it landed** - the chunk is read from the per-output `inputs`, so the error names the
211
412
  * emitted file to look at.
212
- * 3. **How it got there** — the shortest import chain from a user entry to the builtin
413
+ * 3. **How it got there** - the shortest import chain from a user entry to the builtin
213
414
  * (`shortestBuiltinChain`), so the error points straight at the offending `import` line instead of
214
415
  * leaving the dev to grep the dependency tree (the DX gap this closes).
215
416
  * Graph-based (never the emitted text), so it survives minification and can't be fooled by a string
216
417
  * literal that merely contains `"node:crypto"`. Pure + exported for unit testing. Empty ⇒ clean.
217
418
  */
218
419
  export function detectNodeBuiltinsInClient(
219
- meta: BunMetafile | undefined,
420
+ graph: ClientModuleGraph,
220
421
  ): ReadonlyArray<NodeBuiltinFinding> {
221
- const inputs = meta?.inputs ?? {}
222
- // (1) The builtins a user (non-polyfill) module imports directly — the ones the author controls.
422
+ const inputs = graph.modules
423
+ // (1) The builtins a user (non-polyfill) module imports directly - the ones the author controls.
223
424
  const userImported = new Set<string>()
224
425
  for (const [inputKey, input] of Object.entries(inputs)) {
225
- if (inputKey.startsWith("node:")) continue // a polyfill importing another builtin — not the cause
426
+ if (inputKey.startsWith("node:")) continue // a polyfill importing another builtin - not the cause
226
427
  for (const im of input.imports ?? []) {
227
428
  const builtin = nodeBuiltinOf(im)
228
429
  if (builtin !== undefined) userImported.add(builtin)
229
430
  }
230
431
  }
231
- // The entry inputs (route/user entrypoints) — the chain BFS starts here so the reported path begins
432
+ // The entry inputs (route/user entrypoints) - the chain BFS starts here so the reported path begins
232
433
  // at the file the dev wrote an `import` in. Each output's `entryPoint` is one such input.
233
434
  const entryInputs = [
234
435
  ...new Set(
235
- Object.values(meta?.outputs ?? {})
436
+ Object.values(graph.chunks)
236
437
  .map((o) => o.entryPoint)
237
438
  .filter((e): e is string => e !== undefined && !e.startsWith("node:")),
238
439
  ),
@@ -248,8 +449,8 @@ export function detectNodeBuiltinsInClient(
248
449
  }
249
450
  // (2) Locate which emitted chunk each user-imported builtin reached, via the per-output `inputs`.
250
451
  const findings = new Map<string, NodeBuiltinFinding>()
251
- for (const [outPath, out] of Object.entries(meta?.outputs ?? {})) {
252
- for (const inputKey of Object.keys(out.inputs ?? {})) {
452
+ for (const [outPath, out] of Object.entries(graph.chunks)) {
453
+ for (const inputKey of out.modules) {
253
454
  if (userImported.has(inputKey)) {
254
455
  const chunk = basename(outPath)
255
456
  findings.set(`${inputKey}\0${chunk}`, {
@@ -270,8 +471,8 @@ export function detectNodeBuiltinsInClient(
270
471
  // node-builtin guard: a module of PURE server logic with NO `node:` import (a secret-bearing constant,
271
472
  // a server-only API call) that an author wants to FAIL LOUD if it reaches the client opts in with a
272
473
  // side-effect `import "@nifrajs/web/server-only"` (Next's `import "server-only"`). On the SERVER build
273
- // the marker is an empty no-op; the CLIENT build detects — via the SAME Bun metafile graph the
274
- // node-builtin guard walks — any module that imports the marker AND lands in a client chunk, and fails
474
+ // the marker is an empty no-op; the CLIENT build detects - via the SAME Bun metafile graph the
475
+ // node-builtin guard walks - any module that imports the marker AND lands in a client chunk, and fails
275
476
  // the build with the import chain. Graph-based (never the emitted text), so it survives minification.
276
477
  // ---------------------------------------------------------------------------------------------------
277
478
 
@@ -282,7 +483,7 @@ export const SERVER_ONLY_MARKER = "@nifrajs/web/server-only"
282
483
 
283
484
  /** True when an import edge is the `server-only` marker import. Reads the as-written `original` (the
284
485
  * specifier the author typed); falls back to the resolved `path`'s basename so a pre-resolved edge
285
- * (no `original`) — or a workspace-relative resolution — is still recognised. */
486
+ * (no `original`) - or a workspace-relative resolution - is still recognised. */
286
487
  const isServerOnlyMarkerImport = (im: BunMetafileImport): boolean => {
287
488
  if (im.original === SERVER_ONLY_MARKER) return true
288
489
  // Defensive fallback: an edge that lost its `original` but resolved to the marker module file. The
@@ -310,8 +511,8 @@ export interface ServerOnlyFinding {
310
511
  * "<module> (marked server-only)"]`. Mirrors {@link shortestBuiltinChain}: the frontier starts at
311
512
  * every `entryInput` so the chain begins at the file the dev wrote an `import` in; traversal crosses
312
513
  * only NON-`node:` user edges (no cycles via `seen`); it stops at the first node whose import set
313
- * contains the marker (the marked module — the actionable tail), labelling that node by the as-written
314
- * specifier the previous hop used to reach it. Pure — operates on the graph, never the emitted text.
514
+ * contains the marker (the marked module - the actionable tail), labelling that node by the as-written
515
+ * specifier the previous hop used to reach it. Pure - operates on the graph, never the emitted text.
315
516
  */
316
517
  function shortestServerOnlyChain(
317
518
  inputs: Readonly<Record<string, { readonly imports?: readonly BunMetafileImport[] }>>,
@@ -322,7 +523,7 @@ function shortestServerOnlyChain(
322
523
  // The label for the marked module's tail: its as-written specifier (filled when we cross the edge
323
524
  // that reaches it) suffixed with `(marked server-only)`; the entry case uses the entry key itself.
324
525
  const tail = (label: string): string => `${label} (marked server-only)`
325
- // An entry that is ITSELF the marked module — the chain is just that one node.
526
+ // An entry that is ITSELF the marked module - the chain is just that one node.
326
527
  if (entryInputs.includes(markedModule)) return [tail(markedModule)]
327
528
  const seen = new Set<string>(entryInputs)
328
529
  let frontier: Array<{ node: string; chain: string[] }> = entryInputs.map((node) => ({
@@ -333,7 +534,7 @@ function shortestServerOnlyChain(
333
534
  const next: Array<{ node: string; chain: string[] }> = []
334
535
  for (const { node, chain } of frontier) {
335
536
  for (const im of inputs[node]?.imports ?? []) {
336
- // Resolve the edge's `path` to the matching INPUT-GRAPH KEY — in a real build the edge `path`
537
+ // Resolve the edge's `path` to the matching INPUT-GRAPH KEY - in a real build the edge `path`
337
538
  // is absolute while the input keys are cwd-relative, so a raw equality/lookup would miss every
338
539
  // multi-hop user edge (and the chain would degrade to just the tail). The resolver maps both.
339
540
  const target = resolveTarget(im)
@@ -380,16 +581,16 @@ function inputKeyResolver(
380
581
  * Scan a build's metafile for any module that opts into the `server-only` marker (a side-effect
381
582
  * `import "@nifrajs/web/server-only"`) yet landed in a CLIENT output chunk, returning a sorted, deduped
382
583
  * list of {@link ServerOnlyFinding}s. Mirrors {@link detectNodeBuiltinsInClient}: it reads the SAME
383
- * graph facts — which inputs import the marker (the "marked" modules), which chunk each landed in (the
584
+ * graph facts - which inputs import the marker (the "marked" modules), which chunk each landed in (the
384
585
  * per-output `inputs`), and the shortest import chain from a user entry to it. The marker module ITSELF
385
- * (which imports nothing) is excluded — only the modules that *opt in* are flagged. Pure + exported for
586
+ * (which imports nothing) is excluded - only the modules that *opt in* are flagged. Pure + exported for
386
587
  * unit testing. Empty ⇒ clean.
387
588
  */
388
589
  export function detectServerOnlyInClient(
389
- meta: BunMetafile | undefined,
590
+ graph: ClientModuleGraph,
390
591
  ): ReadonlyArray<ServerOnlyFinding> {
391
- const inputs = meta?.inputs ?? {}
392
- // (1) The modules that import the marker — the ones the author opted into the guard. The marker
592
+ const inputs = graph.modules
593
+ // (1) The modules that import the marker - the ones the author opted into the guard. The marker
393
594
  // module itself is skipped: it's the import TARGET, not an opt-in (it imports nothing of its own).
394
595
  const marked = new Set<string>()
395
596
  for (const [inputKey, input] of Object.entries(inputs)) {
@@ -397,11 +598,11 @@ export function detectServerOnlyInClient(
397
598
  if ((input.imports ?? []).some(isServerOnlyMarkerImport)) marked.add(inputKey)
398
599
  }
399
600
  if (marked.size === 0) return []
400
- // The entry inputs — the chain BFS starts here, so the reported path begins at the file the dev
601
+ // The entry inputs - the chain BFS starts here, so the reported path begins at the file the dev
401
602
  // wrote an `import` in. Same derivation as the node-builtin guard.
402
603
  const entryInputs = [
403
604
  ...new Set(
404
- Object.values(meta?.outputs ?? {})
605
+ Object.values(graph.chunks)
405
606
  .map((o) => o.entryPoint)
406
607
  .filter((e): e is string => e !== undefined && !e.startsWith("node:")),
407
608
  ),
@@ -417,8 +618,8 @@ export function detectServerOnlyInClient(
417
618
  }
418
619
  // (2) Locate which emitted chunk each marked module reached, via the per-output `inputs`.
419
620
  const findings = new Map<string, ServerOnlyFinding>()
420
- for (const [outPath, out] of Object.entries(meta?.outputs ?? {})) {
421
- for (const inputKey of Object.keys(out.inputs ?? {})) {
621
+ for (const [outPath, out] of Object.entries(graph.chunks)) {
622
+ for (const inputKey of out.modules) {
422
623
  if (marked.has(inputKey)) {
423
624
  const chunk = basename(outPath)
424
625
  findings.set(`${inputKey}\0${chunk}`, { chunk, chain: chainFor(inputKey) })
@@ -433,10 +634,52 @@ export function detectServerOnlyInClient(
433
634
  }
434
635
 
435
636
  /** True when an INPUT graph key is the marker module file itself (`…/server-only.{ts,js}` under web).
436
- * Excluded from the "marked" set — the marker is the import target, not an opt-in module. */
637
+ * Excluded from the "marked" set - the marker is the import target, not an opt-in module. */
437
638
  const isServerOnlyMarkerModule = (inputKey: string): boolean =>
438
639
  /(^|\/)server-only\.[cm]?[jt]s$/.test(inputKey)
439
640
 
641
+ // ---------------------------------------------------------------------------------------------------
642
+ // Guard MESSAGES - one owner for both production pipelines. The Bun build (below) and the Vite/Rollup
643
+ // leak-guard plugin (plugins/vite-leak-guard.ts) both call these, so a leak reads IDENTICALLY whichever
644
+ // bundler produced it. A second bundler must not grow a second, subtly-different wording of a security
645
+ // error; that is exactly the "mostly ported" outcome the neutral graph seam exists to prevent.
646
+ // ---------------------------------------------------------------------------------------------------
647
+
648
+ /** The build-failing message for `node:` builtins that reached the client bundle. `undefined` ⇒ clean. */
649
+ export function formatNodeBuiltinLeak(
650
+ findings: ReadonlyArray<NodeBuiltinFinding>,
651
+ ): string | undefined {
652
+ if (findings.length === 0) return undefined
653
+ const lines = findings.map((f) =>
654
+ f.chain.length > 1
655
+ ? ` - ${f.builtin} reached the client bundle via ${f.chain.join(" → ")} (chunk: ${f.chunk})`
656
+ : ` - ${f.builtin} reached the client bundle via ${f.chunk}`,
657
+ )
658
+ return (
659
+ `[nifra/web] Node built-in(s) in the client bundle - move them behind a server-only path ` +
660
+ `(a loader/action runs on the server; import the \`node:\` module there, not at a route's ` +
661
+ `top level):\n${lines.join("\n")}`
662
+ )
663
+ }
664
+
665
+ /** The build-failing message for `server-only`-marked modules that reached the client. `undefined` ⇒ clean. */
666
+ export function formatServerOnlyLeak(
667
+ findings: ReadonlyArray<ServerOnlyFinding>,
668
+ ): string | undefined {
669
+ if (findings.length === 0) return undefined
670
+ const lines = findings.map((f) =>
671
+ f.chain.length > 1
672
+ ? ` - server-only module reached the client bundle via ${f.chain.join(" → ")} (chunk: ${f.chunk})`
673
+ : ` - server-only module reached the client bundle via ${f.chunk}`,
674
+ )
675
+ return (
676
+ `[nifra/web] server-only module(s) in the client bundle - a module marked ` +
677
+ `\`import "${SERVER_ONLY_MARKER}"\` reached the browser. Move it behind a server-only path ` +
678
+ `(reach it via a loader/action, or rename it \`*.server.ts\`), so its server logic never ships ` +
679
+ `to the client:\n${lines.join("\n")}`
680
+ )
681
+ }
682
+
440
683
  const basename = (path: string): string => path.slice(path.lastIndexOf("/") + 1)
441
684
  /** `[name]` Bun derives for an entrypoint: basename without extension (`users/[id].tsx` → `[id]`). */
442
685
  const entryName = (file: string): string => {
@@ -461,7 +704,7 @@ export interface ChunkSize {
461
704
  readonly gzip: number
462
705
  }
463
706
 
464
- /** A whole build's size report — every chunk (largest first) + the totals. */
707
+ /** A whole build's size report - every chunk (largest first) + the totals. */
465
708
  export interface SizeReport {
466
709
  /** Per-chunk sizes, sorted biggest gzip first (the regression you want to see at the top). */
467
710
  readonly chunks: readonly ChunkSize[]
@@ -473,7 +716,7 @@ export interface SizeReport {
473
716
 
474
717
  /**
475
718
  * Aggregate a list of measured chunks into a {@link SizeReport}: sort biggest-gzip-first (ties broken
476
- * by raw bytes, then name for stable output) and sum the totals. Pure — the measurement (reading the
719
+ * by raw bytes, then name for stable output) and sum the totals. Pure - the measurement (reading the
477
720
  * file + `Bun.gzipSync`) happens in the orchestrator; this is the deterministic, unit-testable core.
478
721
  */
479
722
  export function aggregateSizeReport(chunks: readonly ChunkSize[]): SizeReport {
@@ -497,7 +740,7 @@ export function formatBytes(bytes: number): string {
497
740
  }
498
741
 
499
742
  /**
500
- * Render a {@link SizeReport} as a terse aligned table (biggest first) with a totals row — the text
743
+ * Render a {@link SizeReport} as a terse aligned table (biggest first) with a totals row - the text
501
744
  * `nifra build --report` prints. Pure (string in, string out) so the formatting is unit-testable.
502
745
  */
503
746
  export function renderSizeReport(report: SizeReport): string {
@@ -526,18 +769,18 @@ export function renderSizeReport(report: SizeReport): string {
526
769
  // ---------------------------------------------------------------------------------------------------
527
770
  // Server-manifest drift detection (#7). `server-manifest.ts` is a committed generated file: it bakes
528
771
  // the route list + the client-entry hash for a disk-less worker (`generateServerManifest`). If `routes/`
529
- // changes but the manifest isn't regenerated, the worker serves a stale route table — a silent edge
772
+ // changes but the manifest isn't regenerated, the worker serves a stale route table - a silent edge
530
773
  // break. These pure helpers diff the COMMITTED manifest source against the freshly-discovered routes so
531
774
  // `nifra check` (and `buildServer`) can fail with a named, actionable error before the drift ships.
532
775
  // ---------------------------------------------------------------------------------------------------
533
776
 
534
777
  /** A drift finding between a committed server-manifest and the live `routes/` tree. */
535
778
  export interface ManifestDrift {
536
- /** Route files present in `routes/` but ABSENT from the committed manifest (the manifest is stale —
779
+ /** Route files present in `routes/` but ABSENT from the committed manifest (the manifest is stale -
537
780
  * the new route won't be served by the worker). */
538
781
  readonly missing: readonly string[]
539
782
  /** Route files the committed manifest imports that no longer exist in `routes/` (a deleted/renamed
540
- * route still wired into the worker — a build/runtime break). */
783
+ * route still wired into the worker - a build/runtime break). */
541
784
  readonly extra: readonly string[]
542
785
  }
543
786
 
@@ -553,7 +796,7 @@ const MANIFEST_CLIENT_ENTRY = /export\s+const\s+clientEntry\s*=\s*["']([^"']+)["
553
796
  * `routes/`-relative keys `discoverRoutes` produces (e.g. `docs/index.tsx`). `routesPrefix` is the
554
797
  * specifier prefix the manifest used for the routes dir (default `./routes/`, what `buildServer`'s
555
798
  * default `resolve` emits). Only import specifiers under that prefix are route files; the
556
- * `@nifrajs/web` import (and any other bare specifier) is ignored. Pure — operates on source text.
799
+ * `@nifrajs/web` import (and any other bare specifier) is ignored. Pure - operates on source text.
557
800
  */
558
801
  export function parseManifestRouteFiles(source: string, routesPrefix = "./routes/"): string[] {
559
802
  const files = new Set<string>()
@@ -635,8 +878,8 @@ export function resyncServerManifestSource(
635
878
 
636
879
  /**
637
880
  * Diff the route files a committed server-manifest imports against the files freshly discovered in
638
- * `routes/`. Returns the `missing` (in routes/, not in manifest — stale manifest) and `extra` (in
639
- * manifest, gone from routes/ — dangling import) sets. Empty arrays ⇒ in sync. Pure — the caller
881
+ * `routes/`. Returns the `missing` (in routes/, not in manifest - stale manifest) and `extra` (in
882
+ * manifest, gone from routes/ - dangling import) sets. Empty arrays ⇒ in sync. Pure - the caller
640
883
  * supplies both file lists (the committed source is parsed via {@link parseManifestRouteFiles}; the
641
884
  * fresh list comes from `discoverRoutes`). Lists need not be pre-sorted; the result is sorted.
642
885
  */
@@ -667,16 +910,16 @@ export function formatManifestDrift(
667
910
  ): string | undefined {
668
911
  if (isManifestInSync(drift)) return undefined
669
912
  const lines: string[] = [
670
- `[nifra/web] server-manifest drift — \`${manifestPath}\` is out of sync with routes/.`,
913
+ `[nifra/web] server-manifest drift - \`${manifestPath}\` is out of sync with routes/.`,
671
914
  ]
672
915
  if (drift.missing.length > 0) {
673
916
  lines.push(
674
- ` Missing (in routes/, not in the manifest — these routes won't be served): ${drift.missing.join(", ")}`,
917
+ ` Missing (in routes/, not in the manifest - these routes won't be served): ${drift.missing.join(", ")}`,
675
918
  )
676
919
  }
677
920
  if (drift.extra.length > 0) {
678
921
  lines.push(
679
- ` Extra (imported by the manifest, gone from routes/ — a dangling import): ${drift.extra.join(", ")}`,
922
+ ` Extra (imported by the manifest, gone from routes/ - a dangling import): ${drift.extra.join(", ")}`,
680
923
  )
681
924
  }
682
925
  lines.push(" Fix: re-run the build to regenerate the server manifest, then commit it.")
@@ -685,7 +928,7 @@ export function formatManifestDrift(
685
928
 
686
929
  /**
687
930
  * Build the client bundle for a file-routed app. Writes the hashed assets + `manifest.json` to
688
- * `outDir` and returns the manifest. Throws (with the bundler logs) on build failure — never
931
+ * `outDir` and returns the manifest. Throws (with the bundler logs) on build failure - never
689
932
  * silently ships a broken bundle.
690
933
  */
691
934
  export async function buildClient(options: BuildClientOptions): Promise<BuildManifest> {
@@ -701,7 +944,7 @@ export async function buildClient(options: BuildClientOptions): Promise<BuildMan
701
944
  // PUBLIC_*-prefixed env → client define (Vite/Next convention). Sourced from the build env (`Bun.env`,
702
945
  // falling back to `process.env`). Only prefixed vars are baked in; unprefixed secrets stay undefined
703
946
  // in the client via the bare `process.env` → `({})` define below. Caller's `options.define` wins (it's
704
- // layered after these in the `define` object). `Bun` may be absent under non-Bun typecheck — guard it.
947
+ // layered after these in the `define` object). `Bun` may be absent under non-Bun typecheck - guard it.
705
948
  const buildEnv = (typeof Bun !== "undefined" ? Bun.env : undefined) ?? process.env
706
949
  const publicDefines = publicEnvDefines(options.publicEnvPrefix ?? "PUBLIC_", buildEnv)
707
950
  // Keep the generated source beside the project, not inside `outDir`: module resolution starts at the
@@ -716,7 +959,7 @@ export async function buildClient(options: BuildClientOptions): Promise<BuildMan
716
959
  `globalThis.process ??= { env: {} };\n${generateClientEntry(routeManifest, { clientModule, resolve })}`,
717
960
  )
718
961
 
719
- // Every unique route/layout/`_404` file (sorted, stable), as ADDITIONAL entrypoints — Bun emits a
962
+ // Every unique route/layout/`_404` file (sorted, stable), as ADDITIONAL entrypoints - Bun emits a
720
963
  // named chunk per file that the bootstrap's lazy `import()` dedupes to (verified), so the manifest
721
964
  // can map each route to its chunk URLs for matched-route preload. `resolve(file)` is the same
722
965
  // specifier the bootstrap imports, so the entrypoint + lazy import are the same module (dedup).
@@ -728,7 +971,7 @@ export async function buildClient(options: BuildClientOptions): Promise<BuildMan
728
971
  ]),
729
972
  ].sort()
730
973
 
731
- // `metafile: true` asks Bun for the input/output graph — specifically `outputs[js].entryPoint`
974
+ // `metafile: true` asks Bun for the input/output graph - specifically `outputs[js].entryPoint`
732
975
  // (the source file) + `outputs[js].cssBundle` (that entry's emitted stylesheet). It's the robust
733
976
  // entry→CSS link for per-route splitting: keyed by the unique source path, so it survives
734
977
  // same-basename collisions (`index.tsx` + `blog/index.tsx`) that a filename match can't. Not yet in
@@ -745,7 +988,7 @@ export async function buildClient(options: BuildClientOptions): Promise<BuildMan
745
988
  splitting: true, // one chunk per lazily-imported route; shared deps deduped into shared chunks
746
989
  // `import "./x.css"` in a route/component → bundled, minified, content-hashed `.css` asset (Bun
747
990
  // strips the import from the JS; CSS bundling is on by default since Bun 1.2). Mapped to routes
748
- // below — both the aggregate and per-route — via the metafile, for `<link>` injection.
991
+ // below - both the aggregate and per-route - via the metafile, for `<link>` injection.
749
992
  ...buildExtras,
750
993
  minify: options.minify ?? true,
751
994
  plugins: [
@@ -753,13 +996,14 @@ export async function buildClient(options: BuildClientOptions): Promise<BuildMan
753
996
  preactDedupePlugin(routesDir),
754
997
  svelteDedupePlugin(routesDir),
755
998
  serverOnlyEmptyPlugin(),
999
+ serverFnStubPlugin(),
756
1000
  ...(options.plugins ?? []),
757
1001
  ],
758
1002
  ...(options.conditions ? { conditions: [...options.conditions] } : {}),
759
1003
  // Replace `process.env.*` at compile time so an app module reading config off `process.env` doesn't
760
1004
  // hit a `process is not defined` crash in the browser. Bun does longest-match: NODE_ENV resolves to
761
1005
  // the build mode (React's prod/dev branch); each PUBLIC_* var resolves to its baked VALUE; every
762
- // other `process.env.X` becomes undefined (the bare `process.env` → `({})` fallback — so secrets
1006
+ // other `process.env.X` becomes undefined (the bare `process.env` → `({})` fallback - so secrets
763
1007
  // never leak). Callers can override any of these via `options.define` (layered last).
764
1008
  define: {
765
1009
  "process.env": "({})",
@@ -780,49 +1024,25 @@ export async function buildClient(options: BuildClientOptions): Promise<BuildMan
780
1024
 
781
1025
  // #4: a `node:` builtin (e.g. `node:crypto`) pulled into a CLIENT chunk builds fine (Bun substitutes
782
1026
  // a browser polyfill) but breaks/leaks at runtime. Fail the build with a named, actionable error
783
- // instead — caught at build time, not by a confused user in the browser. Graph-based (the metafile's
1027
+ // instead - caught at build time, not by a confused user in the browser. Graph-based (the metafile's
784
1028
  // per-output `inputs`), so it can't false-positive on a `"node:..."` string literal and survives
785
1029
  // minification. Only the client build runs this; the server build's `node:` imports are legitimate.
786
1030
  const clientMeta = (result as unknown as { metafile?: BunMetafile }).metafile
787
- const nodeBuiltins = detectNodeBuiltinsInClient(clientMeta)
788
- if (nodeBuiltins.length > 0) {
789
- // Name the offending builtin AND the import chain that pulled it in (entry → … → builtin), so the
790
- // dev sees the exact path from a route file to the leak instead of just the emitted chunk name.
791
- // The chain always starts at a user entry; fall back to the chunk name if it somehow came back empty.
792
- const lines = nodeBuiltins.map((f) =>
793
- f.chain.length > 1
794
- ? ` - ${f.builtin} reached the client bundle via ${f.chain.join(" → ")} (chunk: ${f.chunk})`
795
- : ` - ${f.builtin} reached the client bundle via ${f.chunk}`,
796
- )
797
- throw new Error(
798
- `[nifra/web] Node built-in(s) in the client bundle — move them behind a server-only path ` +
799
- `(a loader/action runs on the server; import the \`node:\` module there, not at a route's ` +
800
- `top level):\n${lines.join("\n")}`,
801
- )
802
- }
803
-
804
- // §3.3/§5.1: a module that opted into the `server-only` marker (`import "@nifrajs/web/server-only"`)
805
- // yet reached a CLIENT chunk — fail the build with the same chain-bearing, "reached the client bundle
806
- // via" message shape as the node-builtin guard above. This catches pure-server logic (a secret, a
807
- // server-only API call) that carries no `node:` import and isn't named `*.server`, so neither of the
808
- // other two guards fires. Graph-based (the same metafile), so it survives minification.
809
- const serverOnly = detectServerOnlyInClient(clientMeta)
810
- if (serverOnly.length > 0) {
811
- const lines = serverOnly.map((f) =>
812
- f.chain.length > 1
813
- ? ` - server-only module reached the client bundle via ${f.chain.join(" → ")} (chunk: ${f.chunk})`
814
- : ` - server-only module reached the client bundle via ${f.chunk}`,
815
- )
816
- throw new Error(
817
- `[nifra/web] server-only module(s) in the client bundle — a module marked ` +
818
- `\`import "${SERVER_ONLY_MARKER}"\` reached the browser. Move it behind a server-only path ` +
819
- `(reach it via a loader/action, or rename it \`*.server.ts\`), so its server logic never ships ` +
820
- `to the client:\n${lines.join("\n")}`,
821
- )
822
- }
1031
+ const clientGraph = fromBunMetafile(clientMeta)
1032
+ // #4: a `node:` builtin (e.g. `node:crypto`) pulled into a CLIENT chunk builds fine (Bun substitutes a
1033
+ // browser polyfill) but breaks/leaks at runtime. Fail with the chain (entry → … → builtin), through the
1034
+ // SHARED formatter so the Vite pipeline's identical guard reads byte-for-byte the same.
1035
+ const nodeBuiltinLeak = formatNodeBuiltinLeak(detectNodeBuiltinsInClient(clientGraph))
1036
+ if (nodeBuiltinLeak !== undefined) throw new Error(nodeBuiltinLeak)
1037
+
1038
+ // §3.3/§5.1: a module that opted into the `server-only` marker yet reached a CLIENT chunk - catches
1039
+ // pure-server logic (a secret, a server-only API call) carrying no `node:` import and not named
1040
+ // `*.server`, so neither other guard fires. Same shared formatter as above.
1041
+ const serverOnlyLeak = formatServerOnlyLeak(detectServerOnlyInClient(clientGraph))
1042
+ if (serverOnlyLeak !== undefined) throw new Error(serverOnlyLeak)
823
1043
 
824
1044
  // Rename any chunk whose basename isn't URL-safe (dynamic-route files become `[slug]-hash.js`) and
825
- // rewrite the references — otherwise the lazy import 404s and the route silently never hydrates.
1045
+ // rewrite the references - otherwise the lazy import 404s and the route silently never hydrates.
826
1046
  const renamed = sanitizeOutputNames(result.outputs)
827
1047
  const toUrl = (path: string): string =>
828
1048
  `${publicPath}${renamed.get(basename(path)) ?? basename(path)}`
@@ -839,7 +1059,7 @@ export async function buildClient(options: BuildClientOptions): Promise<BuildMan
839
1059
  }
840
1060
  const fileToChunk = new Map<string, string>()
841
1061
  routeFiles.forEach((file, i) => {
842
- const out = entryPoints[i + 1] // in range — length checked above
1062
+ const out = entryPoints[i + 1] // in range - length checked above
843
1063
  if (out !== undefined) fileToChunk.set(file, toUrl(out.path))
844
1064
  })
845
1065
  const chunksFor = (chainFiles: readonly string[]): string[] =>
@@ -853,9 +1073,9 @@ export async function buildClient(options: BuildClientOptions): Promise<BuildMan
853
1073
  }
854
1074
  if (routeManifest.notFound) routes._404 = chunksFor([routeManifest.notFound.file])
855
1075
 
856
- // CSS — aggregate: an `import "./x.css"` anywhere → a content-hashed `.css` asset (Bun strips the
1076
+ // CSS - aggregate: an `import "./x.css"` anywhere → a content-hashed `.css` asset (Bun strips the
857
1077
  // import from the JS). The bootstrap lazily imports every route, so its **aggregate** stylesheet is
858
- // the whole app's CSS — the always-safe fallback `createWebApp` links when a route has no per-route
1078
+ // the whole app's CSS - the always-safe fallback `createWebApp` links when a route has no per-route
859
1079
  // entry below. Fallback to all CSS assets if Bun emitted no distinct aggregate.
860
1080
  const bootstrapName = entryName(entryFile) // `_nifra-entry`
861
1081
  const cssNameOf = (path: string): string => {
@@ -869,12 +1089,12 @@ export async function buildClient(options: BuildClientOptions): Promise<BuildMan
869
1089
  const css: readonly string[] =
870
1090
  aggregate.length > 0 ? aggregate : cssAssets.map((o) => toUrl(o.path))
871
1091
 
872
- // CSS — per-route: each route/layout file is its own entrypoint, so the build metafile records its
873
- // `cssBundle` — exactly the CSS that file's subtree uses (shared-component CSS is inlined into each
1092
+ // CSS - per-route: each route/layout file is its own entrypoint, so the build metafile records its
1093
+ // `cssBundle` - exactly the CSS that file's subtree uses (shared-component CSS is inlined into each
874
1094
  // consumer; verified). Keyed by the metafile's unique source `entryPoint`, so it survives
875
1095
  // same-basename collisions (`index.tsx` + `blog/index.tsx`) that a filename match can't. A page then
876
1096
  // links only its layout chain + own CSS (deduped); an empty array means the page needs no CSS at all.
877
- // Absent (→ aggregate fallback) only if Bun emits no metafile/cssBundle — never silently incomplete.
1097
+ // Absent (→ aggregate fallback) only if Bun emits no metafile/cssBundle - never silently incomplete.
878
1098
  const cwd = process.cwd()
879
1099
  const cssByEntry = new Map<string, string>()
880
1100
  for (const out of Object.values(clientMeta?.outputs ?? {})) {
@@ -899,10 +1119,17 @@ export async function buildClient(options: BuildClientOptions): Promise<BuildMan
899
1119
  if (routeManifest.notFound) routeStyles._404 = stylesFor([routeManifest.notFound.file])
900
1120
  }
901
1121
 
1122
+ // Copy `public/` into the output next to the hashed assets. A missing directory is normal (most
1123
+ // apps have none) and must not fail the build.
1124
+ const publicDir = options.publicDir === false ? undefined : (options.publicDir ?? "public")
1125
+ const publicFiles =
1126
+ publicDir !== undefined && existsSync(publicDir) ? await copyPublicDir(publicDir, outDir) : []
1127
+
902
1128
  const manifest: BuildManifest = {
903
1129
  entry: toUrl(bootstrap.path),
904
1130
  assets: result.outputs.map((o) => toUrl(o.path)),
905
1131
  routes,
1132
+ ...(publicFiles.length > 0 ? { publicFiles } : {}),
906
1133
  ...(css.length > 0 ? { css } : {}),
907
1134
  ...(Object.keys(routeStyles).length > 0 ? { routeStyles } : {}),
908
1135
  }
@@ -913,30 +1140,30 @@ export async function buildClient(options: BuildClientOptions): Promise<BuildMan
913
1140
  export interface BuildServerOptions {
914
1141
  /** The `routes/` directory to discover (absolute path). */
915
1142
  readonly routesDir: string
916
- /** The worker entry module (absolute path) — your `worker.ts`. It imports `{ manifest, clientEntry }`
1143
+ /** The worker entry module (absolute path) - your `worker.ts`. It imports `{ manifest, clientEntry }`
917
1144
  * from the generated `./server-manifest`, builds `createWebApp`, and `export default toFetchHandler(app)`. */
918
1145
  readonly serverEntry: string
919
1146
  /** Output directory for the bundled worker (absolute path). */
920
1147
  readonly outDir: string
921
- /** The content-hashed client entry URL (from `buildClient`'s manifest) — **baked** into the generated
1148
+ /** The content-hashed client entry URL (from `buildClient`'s manifest) - **baked** into the generated
922
1149
  * server manifest, since a disk-less worker can't read `manifest.json` at runtime. */
923
1150
  readonly clientEntry: string
924
- /** The app's aggregate stylesheet URLs (`buildClient`'s `BuildManifest.css`) — baked into the generated
1151
+ /** The app's aggregate stylesheet URLs (`buildClient`'s `BuildManifest.css`) - baked into the generated
925
1152
  * manifest so the server entry hands them to `createWebApp` (→ `<link rel="stylesheet">`). Omit ⇒ no CSS
926
1153
  * link (the built SSR page would otherwise render unstyled). */
927
1154
  readonly styles?: readonly string[] | undefined
928
- /** Per-route stylesheet URLs (`buildClient`'s `BuildManifest.routeStyles`) — baked alongside `styles`. */
1155
+ /** Per-route stylesheet URLs (`buildClient`'s `BuildManifest.routeStyles`) - baked alongside `styles`. */
929
1156
  readonly routeStyles?: Readonly<Record<string, readonly string[]>> | undefined
930
1157
  /** Route/layout file → import specifier in the generated manifest (default: a relative path from the
931
- * manifest's location — written next to `serverEntry` — to `routesDir`). */
1158
+ * manifest's location - written next to `serverEntry` - to `routesDir`). */
932
1159
  readonly resolve?: (file: string) => string
933
1160
  /** Filename for the generated server-manifest module, written next to `serverEntry` (default
934
1161
  * `"server-manifest.ts"`); your `serverEntry` imports it as `./server-manifest`. */
935
1162
  readonly manifestFile?: string
936
- /** Adapter build plugins (e.g. `solidBunPlugin("ssr")` — Solid routes need their SSR transform at
1163
+ /** Adapter build plugins (e.g. `solidBunPlugin("ssr")` - Solid routes need their SSR transform at
937
1164
  * build time; React's JSX is Bun-native and needs none). */
938
1165
  readonly plugins?: readonly BunPlugin[]
939
- /** `Bun.build` resolution conditions (default `["workerd", "edge-light", "browser"]`) — selects each
1166
+ /** `Bun.build` resolution conditions (default `["workerd", "edge-light", "browser"]`) - selects each
940
1167
  * dependency's edge build. */
941
1168
  readonly conditions?: readonly string[]
942
1169
  /** Compile-time replacements (default `{ "process.env.NODE_ENV": '"production"' }` → production
@@ -944,7 +1171,7 @@ export interface BuildServerOptions {
944
1171
  readonly define?: Readonly<Record<string, string>>
945
1172
  /** Minify the output (default `true`). */
946
1173
  readonly minify?: boolean
947
- /** `Bun.build` target (default `"browser"` — the right shape for edge runtimes: Cloudflare Workers,
1174
+ /** `Bun.build` target (default `"browser"` - the right shape for edge runtimes: Cloudflare Workers,
948
1175
  * Vercel Edge, Deno, Deno Deploy). Use `"node"` for a `@nifrajs/node` server (so `node:*` built-ins
949
1176
  * stay external), or `"bun"` for a Bun server. The default `conditions` + the edge resolve shims
950
1177
  * only apply to the `"browser"` target; `"node"`/`"bun"` resolve their own renderer builds via the
@@ -953,16 +1180,16 @@ export interface BuildServerOptions {
953
1180
  /** **Lazy/code-split routes** (default `false`): emit `() => import(route)` loaders + bundle with
954
1181
  * `splitting`, so each route is its own chunk loaded on first request (smaller cold-start parse)
955
1182
  * instead of all parsed at boot. The output becomes the worker entry **+ chunk files** in `outDir`
956
- * — on Cloudflare, ship them with wrangler's `no_bundle` + `find_additional_modules` + an ESModule
1183
+ * - on Cloudflare, ship them with wrangler's `no_bundle` + `find_additional_modules` + an ESModule
957
1184
  * `rule` (Node/Deno import the chunks natively). Eager (one self-contained file) stays the default. */
958
1185
  readonly lazy?: boolean
959
1186
  }
960
1187
 
961
- /** The built worker bundle — point your `wrangler.toml`'s `main` at `worker`. */
1188
+ /** The built worker bundle - point your `wrangler.toml`'s `main` at `worker`. */
962
1189
  export interface ServerBuild {
963
1190
  /** Path to the bundled, self-contained worker entry. */
964
1191
  readonly worker: string
965
- /** Paths of every emitted output (entry + any code-split chunks) — what to ship to the platform. */
1192
+ /** Paths of every emitted output (entry + any code-split chunks) - what to ship to the platform. */
966
1193
  readonly outputs: readonly string[]
967
1194
  }
968
1195
 
@@ -971,7 +1198,7 @@ export interface ServerBuild {
971
1198
  * on workerd, and `Bun.build` always applies the `bun` condition (it wins over `workerd`/`edge-light`),
972
1199
  * so conditions alone can't select the edge build. This shim pins `react-dom/server` to its edge build
973
1200
  * (`server.edge.js`, which exports `renderToReadableStream`). A no-op when nothing imports react-dom
974
- * (e.g. a Solid worker) — the resolver only runs on a match.
1201
+ * (e.g. a Solid worker) - the resolver only runs on a match.
975
1202
  */
976
1203
  const reactDomEdgePlugin = (from: string): BunPlugin => ({
977
1204
  name: "nifra-react-dom-edge",
@@ -984,11 +1211,11 @@ const reactDomEdgePlugin = (from: string): BunPlugin => ({
984
1211
 
985
1212
  /**
986
1213
  * Dedupe React to a single copy. A `file:`-linked package can ship its OWN `react` under its own
987
- * node_modules, so the bundle ends up with two React cores — each with its own hook dispatcher — and SSR
1214
+ * node_modules, so the bundle ends up with two React cores - each with its own hook dispatcher - and SSR
988
1215
  * throws the cryptic `null is not an object (evaluating '…H.useState')` (the second renderer's dispatcher
989
1216
  * is null). This was the #1 time-sink in app builds. Pinning `react` + its JSX runtimes to ONE resolved
990
1217
  * copy (the app's, from `from`) guarantees a single dispatcher; `react-dom`, which imports `react`, then
991
- * shares it — so the class can't occur rather than needing a named error. No-op when React isn't used
1218
+ * shares it - so the class can't occur rather than needing a named error. No-op when React isn't used
992
1219
  * (an unresolvable spec is skipped; the resolver only fires on an exact match). React core is
993
1220
  * condition-agnostic, so pinning it doesn't disturb the edge/browser/server conditions that select
994
1221
  * react-dom's build.
@@ -1002,7 +1229,7 @@ export const reactDedupePlugin = (from: string): BunPlugin => ({
1002
1229
  try {
1003
1230
  resolved = Bun.resolveSync(spec, from)
1004
1231
  } catch {
1005
- continue // React (or this subpath) isn't resolvable here — nothing to dedupe
1232
+ continue // React (or this subpath) isn't resolvable here - nothing to dedupe
1006
1233
  }
1007
1234
  const escaped = spec.replace(/[/\\^$*+?.()|[\]{}]/g, "\\$&")
1008
1235
  build.onResolve({ filter: new RegExp(`^${escaped}$`) }, () => ({ path: resolved }))
@@ -1011,14 +1238,14 @@ export const reactDedupePlugin = (from: string): BunPlugin => ({
1011
1238
  })
1012
1239
 
1013
1240
  /**
1014
- * Dedupe Preact to a single copy — the Preact analogue of `reactDedupePlugin`, closing the same class of
1241
+ * Dedupe Preact to a single copy - the Preact analogue of `reactDedupePlugin`, closing the same class of
1015
1242
  * bug for the Preact framework (which had NO build-time dedup before). A `file:`-linked package can ship
1016
1243
  * its OWN `preact`, so the bundle ends up with two Preact cores; since `preact-render-to-string` mutates
1017
1244
  * `preact`'s shared `options` global and `preact/hooks` writes the SAME global, two copies → two `options`
1018
1245
  * → SSR throws `undefined is not an object (… __H)` (the vnode's hook state was set up on the other copy).
1019
1246
  * Pinning `preact` + its hooks/compat/jsx subpaths to ONE resolved copy (the app's, from `from`) makes the
1020
1247
  * renderer and the components share one core. No-op when Preact isn't used (an unresolvable spec is
1021
- * skipped). Preact core is condition-agnostic, so pinning it doesn't disturb any condition selection — and
1248
+ * skipped). Preact core is condition-agnostic, so pinning it doesn't disturb any condition selection - and
1022
1249
  * unlike react-dom there is no edge-vs-server build to preserve, so pinning the subpaths is safe.
1023
1250
  */
1024
1251
  const PREACT_DEDUPE_SPECS = [
@@ -1036,7 +1263,7 @@ export const preactDedupePlugin = (from: string): BunPlugin => ({
1036
1263
  try {
1037
1264
  resolved = Bun.resolveSync(spec, from)
1038
1265
  } catch {
1039
- continue // Preact (or this subpath) isn't resolvable here — nothing to dedupe
1266
+ continue // Preact (or this subpath) isn't resolvable here - nothing to dedupe
1040
1267
  }
1041
1268
  const escaped = spec.replace(/[/\\^$*+?.()|[\]{}]/g, "\\$&")
1042
1269
  build.onResolve({ filter: new RegExp(`^${escaped}$`) }, () => ({ path: resolved }))
@@ -1045,10 +1272,10 @@ export const preactDedupePlugin = (from: string): BunPlugin => ({
1045
1272
  })
1046
1273
 
1047
1274
  /**
1048
- * Dedupe Svelte to a single copy — the Svelte analogue of `reactDedupePlugin`/`preactDedupePlugin`, closing
1275
+ * Dedupe Svelte to a single copy - the Svelte analogue of `reactDedupePlugin`/`preactDedupePlugin`, closing
1049
1276
  * the same class of bug for Svelte (which had NO build-time dedup before). A workspace- or file-linked
1050
1277
  * `@nifrajs/web-svelte` can resolve its OWN `svelte` (e.g. a sibling repo's install store) while the app's
1051
- * components resolve another — SAME version, two physical copies. Svelte 5's client runtime
1278
+ * components resolve another - SAME version, two physical copies. Svelte 5's client runtime
1052
1279
  * (`svelte/internal/client`) holds module-level component-context state, so two copies means the compiled
1053
1280
  * components register on one runtime while `hydrate` runs on the other → hydration throws
1054
1281
  * `Cannot read properties of undefined (reading 'call')` and the server-rendered markup is wiped.
@@ -1066,7 +1293,7 @@ export const svelteDedupePlugin = (from: string): BunPlugin => ({
1066
1293
  try {
1067
1294
  return { path: Bun.resolveSync(args.path, from) }
1068
1295
  } catch {
1069
- return undefined // not resolvable from the app root — leave Bun's default resolution
1296
+ return undefined // not resolvable from the app root - leave Bun's default resolution
1070
1297
  }
1071
1298
  })
1072
1299
  },
@@ -1074,19 +1301,43 @@ export const svelteDedupePlugin = (from: string): BunPlugin => ({
1074
1301
 
1075
1302
  /**
1076
1303
  * Remix-style `.server` convention for the CLIENT build. A module named `*.server.ts(x)` (`db.server.ts`,
1077
- * `auth.server.ts`, …) is server-only — empty it in the browser bundle so its (possibly `node:` / native /
1304
+ * `auth.server.ts`, …) is server-only - empty it in the browser bundle so its (possibly `node:` / native /
1078
1305
  * Capacitor) import subtree never reaches the client. The body is CJS-with-a-Proxy so any named OR default
1079
1306
  * import resolves to `undefined` rather than a "missing export" bundle error (verified), and the real
1080
1307
  * import subtree is gone. The complement to the node-builtin guard: when a server-only import is co-located
1081
1308
  * in a route file (so it can't be tree-shaken out and the guard fails loud), moving it into a `*.server`
1082
- * module is the fix. CLIENT-only — buildServer keeps the real module, which runs server-side.
1309
+ * module is the fix. CLIENT-only - buildServer keeps the real module, which runs server-side.
1083
1310
  */
1084
- const SERVER_ONLY_MODULE = /\.server(\.[cm]?[jt]sx?)?$/
1085
1311
  export const serverOnlyEmptyPlugin = (): BunPlugin => ({
1086
1312
  name: "nifra-server-only-empty",
1087
1313
  setup(build) {
1088
1314
  build.onLoad({ filter: SERVER_ONLY_MODULE }, () => ({
1089
- contents: "module.exports = new Proxy({}, { get: () => undefined })",
1315
+ contents: SERVER_ONLY_REPLACEMENT,
1316
+ loader: "js",
1317
+ }))
1318
+ },
1319
+ })
1320
+
1321
+ /**
1322
+ * Server functions in the CLIENT build: replace each `*.fn.ts` module with stubs that call the routes
1323
+ * the server mounted, so the function bodies - and everything they import - never reach a browser.
1324
+ *
1325
+ * The sibling of {@link serverOnlyEmptyPlugin}, and a deliberate contrast: a `*.server` module is
1326
+ * EMPTIED because nothing may call it from the client, while a `*.fn` module is REPLACED because the
1327
+ * client is supposed to call it, just over HTTP. The generation itself is in
1328
+ * `internal/server-boundary.ts` so the Vite pipeline emits identical stubs from the same code; two
1329
+ * hand-written copies would be a client that works in dev and 404s in production.
1330
+ *
1331
+ * CLIENT-only. The server build keeps the real module, which is what `serverFunctions()` mounts.
1332
+ */
1333
+ export const serverFnStubPlugin = (): BunPlugin => ({
1334
+ name: "nifra-server-fn-stub",
1335
+ setup(build) {
1336
+ build.onLoad({ filter: SERVER_FN_MODULE }, async (args) => ({
1337
+ contents: generateServerFnStub(
1338
+ await Bun.file(args.path).text(),
1339
+ serverFnNamespace(args.path),
1340
+ ),
1090
1341
  loader: "js",
1091
1342
  }))
1092
1343
  },
@@ -1117,7 +1368,7 @@ const solidWebServerPlugin = (from: string): BunPlugin => ({
1117
1368
  * (`generateServerManifest`, written next to `serverEntry`), then bundles `serverEntry` with
1118
1369
  * `Bun.build` using **edge conditions** + the adapter's SSR plugins. The output imports no `node:fs`
1119
1370
  * and does no dynamic-path import, so it runs on workerd: point `wrangler.toml`'s `main` at it and
1120
- * serve the client assets via Workers Assets. Throws (with the bundler logs) on failure — never
1371
+ * serve the client assets via Workers Assets. Throws (with the bundler logs) on failure - never
1121
1372
  * silently ships a broken worker.
1122
1373
  */
1123
1374
  export async function buildServer(options: BuildServerOptions): Promise<ServerBuild> {
@@ -1134,7 +1385,7 @@ export async function buildServer(options: BuildServerOptions): Promise<ServerBu
1134
1385
  // Edge (browser) target: Bun's `bun` condition contaminates react-dom's server build + the `worker`
1135
1386
  // condition segfaults Bun.build on solid, so force the edge/server builds via shims. The `node`/`bun`
1136
1387
  // targets resolve those correctly via their own condition, so the shims (and edge conditions) don't
1137
- // apply — defaults become `[target]` (e.g. react-dom → server.node.js under `node`).
1388
+ // apply - defaults become `[target]` (e.g. react-dom → server.node.js under `node`).
1138
1389
  const edge = target === "browser"
1139
1390
  const conditions = options.conditions ?? (edge ? ["workerd", "edge-light", "browser"] : [target])
1140
1391
  const manifest = discoverRoutes(routesDir)
@@ -1153,11 +1404,12 @@ export async function buildServer(options: BuildServerOptions): Promise<ServerBu
1153
1404
  // Tag every BUNDLED SSR output so @nifrajs/web-react's react-dom adapter takes the static
1154
1405
  // (bundled, deduped) react-dom instead of re-rooting react-dom/server to a DISK copy at runtime.
1155
1406
  // A `target:"bun"` bundle still has `Bun.resolveSync` under the Bun runtime, so without this tag the
1156
- // adapter re-imports a SECOND react-dom from node_modules — a second React core whose hook dispatcher
1407
+ // adapter re-imports a SECOND react-dom from node_modules - a second React core whose hook dispatcher
1157
1408
  // is null for the bundled components → SSR throws `…H.useRef of null`. Always set (a structural fact
1158
1409
  // of bundling, layered after any caller `define` so it can't be overridden). Unbundled Bun runtimes
1159
- // (nifra dev/start, nifra_render) never define it, so they still re-root — the dev dual-install fix.
1410
+ // (nifra dev/start, nifra_render) never define it, so they still re-root - the dev dual-install fix.
1160
1411
  "process.env.NIFRA_SSR_BUNDLED": '"1"',
1412
+ "globalThis.__NIFRA_EDGE_RUNTIME__": edge ? "true" : "false",
1161
1413
  },
1162
1414
  minify: options.minify ?? true,
1163
1415
  // Lazy → one chunk per route (loaded on first request); eager → a single self-contained file.
@@ -1182,12 +1434,12 @@ export async function buildServer(options: BuildServerOptions): Promise<ServerBu
1182
1434
  }
1183
1435
 
1184
1436
  // ===================================================================================================
1185
- // `nifra build --target` — package the engine above into one command that emits a full deploy dir.
1437
+ // `nifra build --target` - package the engine above into one command that emits a full deploy dir.
1186
1438
  //
1187
1439
  // An app already declares everything the build needs through nifra's conventions: `adapter` +
1188
1440
  // `clientModule` (nifra.config.ts / framework.ts), an optional `backend` (backend.ts), and `routes/`.
1189
1441
  // The ONLY thing apps used to hand-write per target was the server entry (`_worker.ts`, `server-bun.ts`,
1190
- // …) — so we GENERATE it here (per target) instead of asking each app to ship five near-identical files.
1442
+ // …) - so we GENERATE it here (per target) instead of asking each app to ship five near-identical files.
1191
1443
  // ===================================================================================================
1192
1444
 
1193
1445
  /** A deploy target `nifra build --target <t>` can emit. `static` is pure SSG (no server). */
@@ -1208,7 +1460,7 @@ export function isBuildTarget(value: string): value is BuildTarget {
1208
1460
  * - `deno`: same fetch-handler default, plus `Deno.serve` self-host when run directly.
1209
1461
  * - `bun` / `node`: a self-hosting server that ALSO serves the client bundle from disk (those
1210
1462
  * runtimes have a filesystem; the static `/assets/*` sit next to the entry).
1211
- * `adapterImport`/`backendImport` are the specifiers the entry uses (relative to where it's written) —
1463
+ * `adapterImport`/`backendImport` are the specifiers the entry uses (relative to where it's written) -
1212
1464
  * `buildServer` writes the entry next to `serverEntry`, so they're resolved from there. Pure (string in,
1213
1465
  * string out) so the generation is unit-testable without a real build.
1214
1466
  */
@@ -1220,8 +1472,10 @@ export function generateServerEntry(options: {
1220
1472
  readonly backendImport?: string
1221
1473
  /** Document `<title>` passed to `createWebApp`. */
1222
1474
  readonly title?: string
1475
+ /** Encoded root-relative public file paths copied into the deploy directory. */
1476
+ readonly publicFiles?: readonly string[]
1223
1477
  }): string {
1224
- const { target, adapterImport, backendImport, title = "nifra" } = options
1478
+ const { target, adapterImport, backendImport, title = "nifra", publicFiles = [] } = options
1225
1479
  if (target === "static") {
1226
1480
  throw new Error("[nifra/web] generateServerEntry: `static` has no server entry (SSG only)")
1227
1481
  }
@@ -1251,14 +1505,33 @@ export function generateServerEntry(options: {
1251
1505
  )
1252
1506
 
1253
1507
  if (target === "cf-pages") {
1254
- // Cloudflare Pages advanced mode: `_routes.json` serves /assets/* from the CDN; everything else
1255
- // falls through to this fetch handler (SSR). The default export is the handler object.
1256
- lines.push("export default toFetchHandler(app)")
1508
+ // Cloudflare Pages advanced mode: `_routes.json` keeps static paths off the worker entirely, and
1509
+ // everything else falls through to this handler (SSR).
1510
+ //
1511
+ // That exclude list cannot always name every public file - Cloudflare caps `_routes.json` at 100
1512
+ // rules - so a static request CAN arrive here. Serve it from the ASSETS binding instead of letting
1513
+ // the router 404 it, which makes correctness independent of how much of the list fit: the exclude
1514
+ // list decides only how many static requests skip the worker, never which files exist.
1515
+ lines.push(
1516
+ `const PUBLIC_FILES = new Set(${JSON.stringify(publicFiles)})`,
1517
+ "const handler = toFetchHandler(app)",
1518
+ "type Assets = { readonly ASSETS?: { fetch(request: Request): Promise<Response> } }",
1519
+ "export default {",
1520
+ " fetch(request: Request, env: Assets, ctx: ExecutionContext) {",
1521
+ " const { pathname } = new URL(request.url)",
1522
+ " // Only the build's own outputs: the hashed bundle prefix and the files it copied.",
1523
+ ' if (env?.ASSETS !== undefined && (pathname.startsWith("/assets/") || PUBLIC_FILES.has(pathname))) {',
1524
+ " return env.ASSETS.fetch(request)",
1525
+ " }",
1526
+ " return handler.fetch(request, env as never, ctx)",
1527
+ " },",
1528
+ "}",
1529
+ )
1257
1530
  return `${lines.join("\n")}\n`
1258
1531
  }
1259
1532
  if (target === "vercel") {
1260
1533
  lines.push(
1261
- "// Vercel Edge Function — Vercel serves /assets/* from its CDN; this only SSRs page routes.",
1534
+ "// Vercel Edge Function - Vercel serves /assets/* from its CDN; this only SSRs page routes.",
1262
1535
  'export const config = { runtime: "edge" }',
1263
1536
  "export default (req: Request): Response | Promise<Response> => app.fetch(req)",
1264
1537
  )
@@ -1267,8 +1540,20 @@ export function generateServerEntry(options: {
1267
1540
 
1268
1541
  // bun / node / deno self-host AND serve the client bundle from disk (it sits next to this entry).
1269
1542
  lines.push(
1270
- "// The client bundle lives next to this entry; serve /assets/* from disk, SSR everything else.",
1271
- 'const ASSETS = new URL("./assets/", import.meta.url)',
1543
+ "// The client bundle and public files live next to this entry; serve only manifest-approved paths.",
1544
+ 'const STATIC_ROOT = new URL("./", import.meta.url)',
1545
+ `const PUBLIC_FILES = new Set(${JSON.stringify(publicFiles)})`,
1546
+ "const staticPath = (pathname: string): string | undefined => {",
1547
+ " if (PUBLIC_FILES.has(pathname)) return '.' + pathname",
1548
+ ' if (!pathname.startsWith("/assets/")) return undefined',
1549
+ ' const segments = pathname.slice(1).split("/")',
1550
+ " // `.` and `..` match the character class, and the path is joined onto a base URL that resolves",
1551
+ " // dot segments - so they are rejected explicitly rather than relying on the request URL having",
1552
+ " // been normalized upstream.",
1553
+ " const safe = (segment: string): boolean =>",
1554
+ ' segment !== "." && segment !== ".." && /^[A-Za-z0-9._-]+$/.test(segment)',
1555
+ " return segments.every(safe) ? '.' + pathname : undefined",
1556
+ "}",
1272
1557
  'const TYPES = { js: "text/javascript", css: "text/css", map: "application/json" }',
1273
1558
  )
1274
1559
  if (target === "bun") {
@@ -1277,19 +1562,18 @@ export function generateServerEntry(options: {
1277
1562
  " port: Number(Bun.env.PORT ?? 3000),",
1278
1563
  " async fetch(req) {",
1279
1564
  " const { pathname } = new URL(req.url)",
1280
- ' if (pathname.startsWith("/assets/")) {',
1281
- ' const name = pathname.slice("/assets/".length)',
1282
- ' if (!/^[A-Za-z0-9._-]+$/.test(name)) return new Response("bad request", { status: 400 })',
1283
- " const file = Bun.file(new URL(name, ASSETS))",
1565
+ " const filePath = staticPath(pathname)",
1566
+ " if (filePath !== undefined) {",
1567
+ " const file = Bun.file(new URL(filePath, STATIC_ROOT))",
1284
1568
  ' if (!(await file.exists())) return new Response("not found", { status: 404 })',
1285
- ' const ext = name.slice(name.lastIndexOf(".") + 1)',
1569
+ ' const ext = pathname.slice(pathname.lastIndexOf(".") + 1)',
1286
1570
  ' return new Response(file, { headers: { "content-type": TYPES[ext] ?? "application/octet-stream" } })',
1287
1571
  " }",
1288
1572
  " return app.fetch(req)",
1289
1573
  " },",
1290
1574
  "})",
1291
1575
  // The `${...}` here is literal OUTPUT (a template in the GENERATED file), not a template in this
1292
- // source — split so biome's noTemplateCurlyInString doesn't flag it; the emitted line is unchanged.
1576
+ // source - split so biome's noTemplateCurlyInString doesn't flag it; the emitted line is unchanged.
1293
1577
  `console.log(\`nifra (Bun) → http://localhost:$${"{server.port}"}\`)`,
1294
1578
  )
1295
1579
  return `${lines.join("\n")}\n`
@@ -1301,12 +1585,11 @@ export function generateServerEntry(options: {
1301
1585
  " {",
1302
1586
  " async fetch(req) {",
1303
1587
  " const { pathname } = new URL(req.url)",
1304
- ' if (pathname.startsWith("/assets/")) {',
1305
- ' const name = pathname.slice("/assets/".length)',
1306
- ' if (!/^[A-Za-z0-9._-]+$/.test(name)) return new Response("bad request", { status: 400 })',
1588
+ " const filePath = staticPath(pathname)",
1589
+ " if (filePath !== undefined) {",
1307
1590
  " try {",
1308
- " const body = await readFile(new URL(name, ASSETS))",
1309
- ' const ext = name.slice(name.lastIndexOf(".") + 1)',
1591
+ " const body = await readFile(new URL(filePath, STATIC_ROOT))",
1592
+ ' const ext = pathname.slice(pathname.lastIndexOf(".") + 1)',
1310
1593
  ' return new Response(body, { headers: { "content-type": TYPES[ext] ?? "application/octet-stream" } })',
1311
1594
  " } catch {",
1312
1595
  ' return new Response("not found", { status: 404 })',
@@ -1323,16 +1606,15 @@ export function generateServerEntry(options: {
1323
1606
  // deno
1324
1607
  lines.push(
1325
1608
  "const handler = toFetchHandler(app)",
1326
- "// @ts-ignore — Deno global is present on the Deno runtime this output targets.",
1609
+ "// @ts-ignore - Deno global is present on the Deno runtime this output targets.",
1327
1610
  'Deno.serve({ port: Number(Deno.env.get("PORT") ?? "3000") }, async (req) => {',
1328
1611
  " const { pathname } = new URL(req.url)",
1329
- ' if (pathname.startsWith("/assets/")) {',
1330
- ' const name = pathname.slice("/assets/".length)',
1331
- ' if (!/^[A-Za-z0-9._-]+$/.test(name)) return new Response("bad request", { status: 400 })',
1612
+ " const filePath = staticPath(pathname)",
1613
+ " if (filePath !== undefined) {",
1332
1614
  " try {",
1333
- " // @ts-ignore — Deno.readFile is present on the Deno runtime.",
1334
- " const body = await Deno.readFile(new URL(name, ASSETS))",
1335
- ' const ext = name.slice(name.lastIndexOf(".") + 1)',
1615
+ " // @ts-ignore - Deno.readFile is present on the Deno runtime.",
1616
+ " const body = await Deno.readFile(new URL(filePath, STATIC_ROOT))",
1617
+ ' const ext = pathname.slice(pathname.lastIndexOf(".") + 1)',
1336
1618
  ' return new Response(body, { headers: { "content-type": TYPES[ext] ?? "application/octet-stream" } })',
1337
1619
  " } catch {",
1338
1620
  ' return new Response("not found", { status: 404 })',
@@ -1383,7 +1665,7 @@ export interface BuildTargetOptions {
1383
1665
  readonly adapterImport: string
1384
1666
  /** Import specifier (resolvable from `workDir`) of the module exporting `backend`, or `undefined`. */
1385
1667
  readonly backendImport?: string
1386
- /** Factory that builds the app for `static` prerendering, GIVEN the client build's manifest — so the
1668
+ /** Factory that builds the app for `static` prerendering, GIVEN the client build's manifest - so the
1387
1669
  * emitted hydration `<script src>` uses the REAL content-hashed entry (`client.entry`) plus the same
1388
1670
  * styles/route-preload the server targets use. A pre-built instance can't work here: the hash isn't known
1389
1671
  * until `buildClient` runs inside `buildTarget`, so a hardcoded entry 404s → pages render but never
@@ -1397,16 +1679,20 @@ export interface BuildTargetOptions {
1397
1679
  readonly conditions?: readonly string[]
1398
1680
  /** Compile-time `define` replacements layered onto both builds. */
1399
1681
  readonly define?: Readonly<Record<string, string>>
1682
+ /** Project-root static directory copied to deploy root. `false` disables it. */
1683
+ readonly publicDir?: string | false
1684
+ /** Prefix of environment variables allowed into the client bundle (default `"PUBLIC_"`). */
1685
+ readonly publicEnvPrefix?: string
1400
1686
  /** Document `<title>` for the generated server entry. */
1401
1687
  readonly title?: string
1402
1688
  }
1403
1689
 
1404
- /** Minimal app surface `buildTarget`'s static path needs — a fetch handler (a built `createWebApp`). */
1690
+ /** Minimal app surface `buildTarget`'s static path needs - a fetch handler (a built `createWebApp`). */
1405
1691
  export interface PrerenderAppLike {
1406
1692
  fetch(req: Request): Response | Promise<Response>
1407
1693
  }
1408
1694
 
1409
- /** The result of a target build — the deploy dir + the client manifest + an optional size report. */
1695
+ /** The result of a target build - the deploy dir + the client manifest + an optional size report. */
1410
1696
  export interface BuildTargetResult {
1411
1697
  /** The deploy target that was built. */
1412
1698
  readonly target: BuildTarget
@@ -1427,7 +1713,7 @@ export interface BuildTargetResult {
1427
1713
  * - `static`: prerenders opted-in routes (`prerenderRoutes`) to `<outDir>/<path>/index.html` (+
1428
1714
  * `_data.json`); needs `prerenderApp`. No server.
1429
1715
  * - `cf-pages`: a `_worker.js` (edge bundle) + a `_routes.json` excluding /assets/* from the worker.
1430
- * - `vercel`: a `.vercel/output`-shaped function isn't emitted here — `vercel` emits the bundled edge
1716
+ * - `vercel`: a `.vercel/output`-shaped function isn't emitted here - `vercel` emits the bundled edge
1431
1717
  * entry as `<outDir>/index.js` (the CLI's docs point at `vercel`'s Build Output wrapper). [see note]
1432
1718
  * - `deno`/`node`/`bun`: the self-hosting server bundle (`server.js`) next to the assets.
1433
1719
  * The server entry is GENERATED (`generateServerEntry`) and bundled (`buildServer`); the app supplies
@@ -1436,9 +1722,84 @@ export interface BuildTargetResult {
1436
1722
  * Note: the heavier platform wrappers (`.vercel/output` v3 layout, wrangler ISR `find_additional_modules`)
1437
1723
  * remain app-owned scripts; this command targets the common single-bundle deploys. See the CLI docs.
1438
1724
  */
1725
+ /**
1726
+ * A build-tool STRATEGY for {@link buildTargetWith} - the two bundling steps, and nothing else. Everything
1727
+ * around them (server-entry codegen, deploy assembly, prerender, size report) is bundler-agnostic and
1728
+ * lives in `buildTargetWith`, so a second bundler (Vite) is this interface, not a second orchestrator.
1729
+ *
1730
+ * Plugin lists are `readonly unknown[]` because a Bun plugin and a Vite plugin are different types; each
1731
+ * strategy casts to its own. `buildTargetWith` only forwards them.
1732
+ */
1733
+ export interface Bundler {
1734
+ /** Build the client bundle → the shared {@link BuildManifest}. */
1735
+ buildClient(input: {
1736
+ readonly routesDir: string
1737
+ readonly outDir: string
1738
+ readonly clientModule: string
1739
+ readonly plugins?: readonly unknown[]
1740
+ readonly conditions?: readonly string[]
1741
+ readonly define?: Readonly<Record<string, string>>
1742
+ readonly publicDir?: string | false
1743
+ readonly publicEnvPrefix?: string
1744
+ /** Project root (Vite needs it; the Bun strategy ignores it). */
1745
+ readonly root?: string
1746
+ }): Promise<BuildManifest>
1747
+ /** Build the server worker → the shared {@link ServerBuild}. */
1748
+ buildServer(input: {
1749
+ readonly routesDir: string
1750
+ readonly serverEntry: string
1751
+ readonly outDir: string
1752
+ readonly clientEntry: string
1753
+ readonly target: "browser" | "node" | "bun"
1754
+ readonly plugins?: readonly unknown[]
1755
+ readonly define?: Readonly<Record<string, string>>
1756
+ readonly root?: string
1757
+ }): Promise<ServerBuild>
1758
+ }
1759
+
1760
+ /** The default (Bun) strategy - `buildClient`/`buildServer` from this module. */
1761
+ export const bunBundler: Bundler = {
1762
+ buildClient: (input) =>
1763
+ buildClient({
1764
+ routesDir: input.routesDir,
1765
+ outDir: input.outDir,
1766
+ clientModule: input.clientModule,
1767
+ ...(input.plugins ? { plugins: input.plugins as BunPlugin[] } : {}),
1768
+ ...(input.conditions ? { conditions: input.conditions } : {}),
1769
+ ...(input.define ? { define: input.define } : {}),
1770
+ ...(input.publicDir !== undefined ? { publicDir: input.publicDir } : {}),
1771
+ ...(input.publicEnvPrefix !== undefined ? { publicEnvPrefix: input.publicEnvPrefix } : {}),
1772
+ }),
1773
+ buildServer: (input) =>
1774
+ buildServer({
1775
+ routesDir: input.routesDir,
1776
+ serverEntry: input.serverEntry,
1777
+ outDir: input.outDir,
1778
+ clientEntry: input.clientEntry,
1779
+ target: input.target,
1780
+ ...(input.plugins ? { plugins: input.plugins as BunPlugin[] } : {}),
1781
+ ...(input.define ? { define: input.define } : {}),
1782
+ }),
1783
+ }
1784
+
1785
+ /** Build a full deploy dir for `target` using the default Bun bundler. See {@link buildTargetWith}. */
1439
1786
  export async function buildTarget(
1440
1787
  target: BuildTarget,
1441
1788
  options: BuildTargetOptions,
1789
+ ): Promise<BuildTargetResult> {
1790
+ return buildTargetWith(target, options, bunBundler)
1791
+ }
1792
+
1793
+ /**
1794
+ * The bundler-agnostic deploy orchestrator: everything `buildTarget` does EXCEPT the two bundling steps,
1795
+ * which come from `bundler`. `buildTarget` passes {@link bunBundler}; `buildTargetVite`
1796
+ * (`@nifrajs/web/build-vite`) passes the Vite strategy. One orchestrator, so the deploy-dir shape,
1797
+ * server-entry codegen, prerender and size report are identical across pipelines.
1798
+ */
1799
+ export async function buildTargetWith(
1800
+ target: BuildTarget,
1801
+ options: BuildTargetOptions,
1802
+ bundler: Bundler,
1442
1803
  ): Promise<BuildTargetResult> {
1443
1804
  const { routesDir, outDir, workDir } = options
1444
1805
  const { rmSync } = await import("node:fs")
@@ -1449,14 +1810,27 @@ export async function buildTarget(
1449
1810
  mkdirSync(workDir, { recursive: true })
1450
1811
 
1451
1812
  // (1) Client bundle → <outDir>/assets/* (every target ships the same hashed client bundle).
1452
- const client = await buildClient({
1813
+ let client = await bundler.buildClient({
1453
1814
  routesDir,
1454
1815
  outDir: assetsDir,
1455
1816
  clientModule: options.clientModule,
1456
1817
  ...(options.clientPlugins ? { plugins: options.clientPlugins } : {}),
1457
1818
  ...(options.conditions ? { conditions: options.conditions } : {}),
1458
1819
  define: { "process.env.NODE_ENV": '"production"', ...(options.define ?? {}) },
1820
+ publicDir: false,
1821
+ ...(options.publicEnvPrefix !== undefined ? { publicEnvPrefix: options.publicEnvPrefix } : {}),
1822
+ root: resolvePath(dirname(routesDir)),
1459
1823
  })
1824
+ const publicDir =
1825
+ options.publicDir === false
1826
+ ? undefined
1827
+ : resolvePath(options.publicDir ?? join(dirname(routesDir), "public"))
1828
+ const publicFiles =
1829
+ publicDir !== undefined && existsSync(publicDir) ? await copyPublicDir(publicDir, outDir) : []
1830
+ if (publicFiles.length > 0) {
1831
+ client = { ...client, publicFiles }
1832
+ writeFileSync(`${assetsDir}/manifest.json`, JSON.stringify(client, null, 2))
1833
+ }
1460
1834
 
1461
1835
  if (target === "static") {
1462
1836
  if (options.prerenderApp === undefined) {
@@ -1476,9 +1850,9 @@ export async function buildTarget(
1476
1850
  })
1477
1851
  if (result.prerendered.length === 0) {
1478
1852
  // A static build that renders nothing is almost always a misconfig (no `prerender = true` / no
1479
- // getStaticPaths) — fail loudly rather than ship an empty dir the dev thinks is their site.
1853
+ // getStaticPaths) - fail loudly rather than ship an empty dir the dev thinks is their site.
1480
1854
  throw new Error(
1481
- "[nifra/web] buildTarget(static): no routes were prerendered — opt routes in with " +
1855
+ "[nifra/web] buildTarget(static): no routes were prerendered - opt routes in with " +
1482
1856
  "`export const prerender = true` (static) or `getStaticPaths` (dynamic).",
1483
1857
  )
1484
1858
  }
@@ -1506,10 +1880,11 @@ export async function buildTarget(
1506
1880
  adapterImport: options.adapterImport,
1507
1881
  ...(options.backendImport !== undefined ? { backendImport: options.backendImport } : {}),
1508
1882
  ...(options.title !== undefined ? { title: options.title } : {}),
1883
+ ...(publicFiles.length > 0 ? { publicFiles } : {}),
1509
1884
  }),
1510
1885
  )
1511
1886
  const serverTarget = SERVER_BUILD_TARGET[target]
1512
- const { worker } = await buildServer({
1887
+ const { worker } = await bundler.buildServer({
1513
1888
  routesDir,
1514
1889
  serverEntry: serverEntryPath,
1515
1890
  outDir: `${workDir}/server`,
@@ -1517,6 +1892,7 @@ export async function buildTarget(
1517
1892
  target: serverTarget,
1518
1893
  ...(options.serverPlugins ? { plugins: options.serverPlugins } : {}),
1519
1894
  define: { "process.env.NODE_ENV": '"production"', ...(options.define ?? {}) },
1895
+ root: resolvePath(dirname(routesDir)),
1520
1896
  })
1521
1897
 
1522
1898
  // (3) Assemble the deploy dir for the target.
@@ -1524,10 +1900,25 @@ export async function buildTarget(
1524
1900
  let run: string
1525
1901
  if (target === "cf-pages") {
1526
1902
  cpSync(worker, `${outDir}/_worker.js`)
1903
+ // The app's real patterns, so a directory is only collapsed into a glob once the route table
1904
+ // proves nothing can be served beneath it.
1905
+ const rules = cloudflareRouteRules(
1906
+ publicFiles,
1907
+ discoverRoutes(routesDir).routes.map((route) => route.pattern),
1908
+ )
1527
1909
  writeFileSync(
1528
1910
  `${outDir}/_routes.json`,
1529
- `${JSON.stringify({ version: 1, include: ["/*"], exclude: ["/assets/*"] }, null, 2)}\n`,
1911
+ `${JSON.stringify({ version: 1, include: rules.include, exclude: rules.exclude }, null, 2)}\n`,
1530
1912
  )
1913
+ // Never cap silently: an omitted file still serves (the worker's ASSETS fallback), but it costs a
1914
+ // worker invocation, and that trade should be visible rather than inferred from a latency graph.
1915
+ if (rules.omitted.length > 0) {
1916
+ console.log(
1917
+ `[nifra/web] _routes.json holds ${rules.exclude.length} of ${publicFiles.length + 1} static rules ` +
1918
+ `(Cloudflare allows ${CF_MAX_ROUTE_RULES}). The other ${rules.omitted.length} file(s) are served by the ` +
1919
+ "worker via ASSETS instead of the CDN directly - correct, just one invocation each.",
1920
+ )
1921
+ }
1531
1922
  run = `Cloudflare Pages → ${outDir} (deploy: wrangler pages deploy ${basename(outDir)})`
1532
1923
  } else if (target === "vercel") {
1533
1924
  cpSync(worker, `${outDir}/index.js`)