@nifrajs/web 2.0.0 → 2.2.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 (102) hide show
  1. package/dist/build-vite.d.ts +68 -0
  2. package/dist/build-vite.d.ts.map +1 -0
  3. package/dist/build-vite.js +487 -0
  4. package/dist/build-vite.js.map +1 -0
  5. package/dist/build.d.ts +112 -25
  6. package/dist/build.d.ts.map +1 -1
  7. package/dist/build.js +283 -64
  8. package/dist/build.js.map +1 -1
  9. package/dist/bun-dev-entry.d.ts +83 -0
  10. package/dist/bun-dev-entry.d.ts.map +1 -0
  11. package/dist/bun-dev-entry.js +136 -0
  12. package/dist/bun-dev-entry.js.map +1 -0
  13. package/dist/client.d.ts.map +1 -1
  14. package/dist/client.js +10 -0
  15. package/dist/client.js.map +1 -1
  16. package/dist/deferred.d.ts +3 -2
  17. package/dist/deferred.d.ts.map +1 -1
  18. package/dist/deferred.js +11 -8
  19. package/dist/deferred.js.map +1 -1
  20. package/dist/dev-port.d.ts +36 -0
  21. package/dist/dev-port.d.ts.map +1 -0
  22. package/dist/dev-port.js +48 -0
  23. package/dist/dev-port.js.map +1 -0
  24. package/dist/dev.d.ts +61 -4
  25. package/dist/dev.d.ts.map +1 -1
  26. package/dist/dev.js +419 -60
  27. package/dist/dev.js.map +1 -1
  28. package/dist/draft.d.ts +37 -0
  29. package/dist/draft.d.ts.map +1 -1
  30. package/dist/draft.js +87 -4
  31. package/dist/draft.js.map +1 -1
  32. package/dist/fs.d.ts +14 -1
  33. package/dist/fs.d.ts.map +1 -1
  34. package/dist/fs.js +7 -1
  35. package/dist/fs.js.map +1 -1
  36. package/dist/index.d.ts +116 -5
  37. package/dist/index.d.ts.map +1 -1
  38. package/dist/index.js +565 -53
  39. package/dist/index.js.map +1 -1
  40. package/dist/internal/timing-safe-equal.d.ts +16 -0
  41. package/dist/internal/timing-safe-equal.d.ts.map +1 -0
  42. package/dist/internal/timing-safe-equal.js +26 -0
  43. package/dist/internal/timing-safe-equal.js.map +1 -0
  44. package/dist/isr.d.ts.map +1 -1
  45. package/dist/isr.js +1 -14
  46. package/dist/isr.js.map +1 -1
  47. package/dist/llms-txt.d.ts.map +1 -1
  48. package/dist/llms-txt.js +12 -6
  49. package/dist/llms-txt.js.map +1 -1
  50. package/dist/manifest.d.ts +51 -8
  51. package/dist/manifest.d.ts.map +1 -1
  52. package/dist/manifest.js +130 -14
  53. package/dist/manifest.js.map +1 -1
  54. package/dist/module-graph.d.ts +91 -0
  55. package/dist/module-graph.d.ts.map +1 -0
  56. package/dist/module-graph.js +92 -0
  57. package/dist/module-graph.js.map +1 -0
  58. package/dist/plugins/css-modules.d.ts.map +1 -1
  59. package/dist/plugins/css-modules.js +0 -0
  60. package/dist/plugins/css-modules.js.map +1 -1
  61. package/dist/plugins/vite-leak-guard.d.ts +45 -0
  62. package/dist/plugins/vite-leak-guard.d.ts.map +1 -0
  63. package/dist/plugins/vite-leak-guard.js +67 -0
  64. package/dist/plugins/vite-leak-guard.js.map +1 -0
  65. package/dist/public-dir.d.ts +36 -0
  66. package/dist/public-dir.d.ts.map +1 -0
  67. package/dist/public-dir.js +89 -0
  68. package/dist/public-dir.js.map +1 -0
  69. package/dist/route-manifest.d.ts +91 -0
  70. package/dist/route-manifest.d.ts.map +1 -0
  71. package/dist/route-manifest.js +128 -0
  72. package/dist/route-manifest.js.map +1 -0
  73. package/dist/router.d.ts +64 -1
  74. package/dist/router.d.ts.map +1 -1
  75. package/dist/router.js +114 -31
  76. package/dist/router.js.map +1 -1
  77. package/dist/vite.d.ts +10 -4
  78. package/dist/vite.d.ts.map +1 -1
  79. package/dist/vite.js +44 -12
  80. package/dist/vite.js.map +1 -1
  81. package/package.json +17 -2
  82. package/src/build-vite.ts +606 -0
  83. package/src/build.ts +432 -73
  84. package/src/bun-dev-entry.ts +184 -0
  85. package/src/client.ts +8 -0
  86. package/src/deferred.ts +13 -6
  87. package/src/dev-port.ts +63 -0
  88. package/src/dev.ts +467 -65
  89. package/src/draft.ts +116 -4
  90. package/src/fs.ts +22 -4
  91. package/src/index.ts +727 -57
  92. package/src/internal/timing-safe-equal.ts +24 -0
  93. package/src/isr.ts +1 -13
  94. package/src/llms-txt.ts +13 -5
  95. package/src/manifest.ts +188 -13
  96. package/src/module-graph.ts +146 -0
  97. package/src/plugins/css-modules.ts +0 -0
  98. package/src/plugins/vite-leak-guard.ts +110 -0
  99. package/src/public-dir.ts +108 -0
  100. package/src/route-manifest.ts +240 -0
  101. package/src/router.ts +198 -34
  102. package/src/vite.ts +62 -20
package/src/build.ts CHANGED
@@ -5,13 +5,15 @@
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"
14
15
  // `buildTarget(static)` drives the SSG prerender engine directly (it's also re-exported below).
16
+ import { type ClientModuleGraph, fromBunMetafile } from "./module-graph.ts"
15
17
  import { prerenderRoutes } from "./prerender.ts"
16
18
 
17
19
  // Build-time SSG: prerender opted-in static + dynamic routes to `index.html` (+ static `_data.json`),
@@ -48,6 +50,15 @@ export interface BuildClientOptions {
48
50
  readonly minify?: boolean
49
51
  /** URL prefix the assets are served under (default `"/assets/"`); also Bun's chunk `publicPath`. */
50
52
  readonly publicPath?: string
53
+ /**
54
+ * Directory of user-authored static files copied into the build and served at the root (default
55
+ * `"public"`). Absent directory ⇒ nothing copied, no error.
56
+ *
57
+ * NOT the same thing as {@link publicPath}, despite the names: that is the URL prefix for
58
+ * content-hashed bundle chunks and never covers files an author put on disk. The collision is a
59
+ * real source of confusion, which is why both are spelled out here.
60
+ */
61
+ readonly publicDir?: string | false
51
62
  /**
52
63
  * Prefix that opts an environment variable into the **client** bundle (Vite/Next convention; default
53
64
  * `"PUBLIC_"`). Every var in the build environment whose name starts with this prefix is baked into
@@ -82,7 +93,186 @@ export function publicEnvDefines(
82
93
  return defines
83
94
  }
84
95
 
96
+ /**
97
+ * Percent-encode one path segment exactly the way a browser encodes it into a request URL.
98
+ *
99
+ * NOT `encodeURIComponent`. That escapes the sub-delimiters `, @ + = & ; $`, which a browser sends raw -
100
+ * so a file named `report,2026.csv` would be recorded as `/report%2C2026.csv` while the request arrives
101
+ * as `/report,2026.csv`, the allowlist lookup misses, and the file 404s in production only. `encodeURI`
102
+ * agrees with `URL.pathname` on every character except `?` and `#`, which terminate a path and so must
103
+ * be escaped explicitly here.
104
+ */
105
+ function encodePathSegment(segment: string): string {
106
+ return encodeURI(segment).replace(
107
+ /[?#]/g,
108
+ (char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`,
109
+ )
110
+ }
111
+
112
+ /**
113
+ * Cloudflare Pages rejects a `_routes.json` with more than 100 include+exclude rules combined, or any
114
+ * single rule longer than 100 characters.
115
+ */
116
+ const CF_MAX_ROUTE_RULES = 100
117
+ const CF_MAX_RULE_LENGTH = 100
118
+
119
+ export interface CloudflareRouteRules {
120
+ readonly include: readonly string[]
121
+ readonly exclude: readonly string[]
122
+ /** Public files the budget could not name, so the build can say so instead of capping silently. */
123
+ readonly omitted: readonly string[]
124
+ }
125
+
126
+ /**
127
+ * What a route pattern's first URL segment says about the paths it can reach.
128
+ *
129
+ * The three cases are genuinely different and collapsing them is a fail-open bug: `/` reaches only the
130
+ * root and can never match below a directory, while `/:locale/about` can match below ANY directory, and
131
+ * both would otherwise read as "no literal first segment". `dynamic` is also where unrecognised syntax
132
+ * lands, deliberately - a pattern form this does not understand must never be taken as proof that
133
+ * nothing matches.
134
+ */
135
+ type FirstSegment =
136
+ | { readonly kind: "root" }
137
+ | { readonly kind: "literal"; readonly value: string }
138
+ | { readonly kind: "dynamic" }
139
+
140
+ function firstSegmentOf(pattern: string): FirstSegment {
141
+ const first = pattern.split("/").find((segment) => segment !== "")
142
+ if (first === undefined) return { kind: "root" }
143
+ return /^[A-Za-z0-9._-]+$/.test(first) ? { kind: "literal", value: first } : { kind: "dynamic" }
144
+ }
145
+
146
+ /**
147
+ * Directories under `public/` whose every file can be replaced by one `/<dir>/*` rule.
148
+ *
149
+ * Safe only when no route can ever be served beneath that directory, because the glob does not merely
150
+ * describe today's files - it hands Pages every future path under the prefix, and the worker never sees
151
+ * them. A `public/blog/hero.png` next to a `/blog/:slug` route is the ordinary case that breaks: the
152
+ * glob would send `/blog/my-post` to the CDN, which has no such file, and the page 404s in production
153
+ * only. So a directory collapses only when every route pattern starts with a literal segment that is
154
+ * something else. One dynamic first segment anywhere (`/:locale/…`) disables collapsing entirely, which
155
+ * is correct - such a route can match under any directory name.
156
+ *
157
+ * A collapsed directory also gives up the app's own 404 page for a missing file beneath it, since the
158
+ * request never reaches the worker. That is the right trade for a directory that holds only static
159
+ * files: a missing image should fail as a fast CDN 404, not as an HTML error page.
160
+ */
161
+ function collapsibleDirs(
162
+ publicFiles: readonly string[],
163
+ routePatterns: readonly string[],
164
+ ): ReadonlySet<string> {
165
+ const reserved = new Set<string>()
166
+ for (const pattern of routePatterns) {
167
+ const first = firstSegmentOf(pattern)
168
+ // A dynamic first segment can match anywhere, so nothing is collapsible at all.
169
+ if (first.kind === "dynamic") return new Set()
170
+ // `root` reserves nothing: `/` cannot match a path below a directory.
171
+ if (first.kind === "literal") reserved.add(first.value)
172
+ }
173
+ const dirs = new Set<string>()
174
+ for (const file of publicFiles) {
175
+ const slash = file.indexOf("/", 1)
176
+ if (slash === -1) continue // a root-level file has no directory to collapse into
177
+ const dir = file.slice(1, slash)
178
+ if (!reserved.has(dir)) dirs.add(dir)
179
+ }
180
+ return dirs
181
+ }
182
+
183
+ /**
184
+ * Build the cf-pages `_routes.json` rules for a set of copied public files, within Cloudflare's budget.
185
+ *
186
+ * `exclude` is what Pages serves straight from the CDN instead of invoking the worker, so naming every
187
+ * public file is ideal - and impossible past ~99 of them. A `public/` of icons, fonts and share images
188
+ * clears 100 easily, and the rejection lands at `wrangler pages deploy`, long after the build reported
189
+ * success.
190
+ *
191
+ * A glob is emitted only where it cannot be wrong. `/assets/*` always is: the build owns that prefix
192
+ * outright, since it is where the hashed bundle is written. A `public/` subdirectory is different - the
193
+ * name is the author's, and a `/blog/*` rule would hand Pages every future path under `/blog/`, so a
194
+ * `/blog/:slug` route would 404 from the CDN in production only. {@link collapsibleDirs} therefore
195
+ * collapses a directory only after checking it against the app's real route patterns, which is what
196
+ * makes the compaction safe rather than merely smaller. Everything else stays an exact path, and a name
197
+ * containing `*` is dropped rather than escaped, since the rule would become a wildcard.
198
+ *
199
+ * What still does not fit is dropped, which is safe because the list is only an optimization: the
200
+ * generated worker serves any allowlisted path it receives through the ASSETS binding, so an omitted
201
+ * file costs one worker invocation, not a 404.
202
+ */
203
+ export function cloudflareRouteRules(
204
+ publicFiles: readonly string[],
205
+ /**
206
+ * Every route pattern the app serves. Required, not defaulted: an empty list means "this app serves
207
+ * no routes", which makes every directory collapsible - a defensible answer to state deliberately and
208
+ * a dangerous one to arrive at by forgetting the argument.
209
+ */
210
+ routePatterns: readonly string[],
211
+ ): CloudflareRouteRules {
212
+ const include = ["/*"]
213
+ const exclude = ["/assets/*"]
214
+ const omitted: string[] = []
215
+
216
+ // Collapse only where the route table proves nothing can be served beneath the directory, and only
217
+ // when the per-file list would not have fit anyway - a smaller `_routes.json` is worth nothing on its
218
+ // own, and exact paths keep the app's own 404 for a missing file under that directory.
219
+ const perFileFits = publicFiles.length + include.length + exclude.length <= CF_MAX_ROUTE_RULES
220
+ const collapsible = perFileFits ? new Set<string>() : collapsibleDirs(publicFiles, routePatterns)
221
+ const emitted = new Set<string>()
222
+
223
+ for (const file of publicFiles) {
224
+ const slash = file.indexOf("/", 1)
225
+ const dir = slash === -1 ? undefined : file.slice(1, slash)
226
+ const collapsed = dir !== undefined && collapsible.has(dir)
227
+ const rule = collapsed ? `/${dir}/*` : file
228
+ if (emitted.has(rule)) continue // already covered by a rule emitted for an earlier file
229
+ // A `*` in a FILE name would turn that file's own rule into a wildcard. A collapsed rule's `*` is
230
+ // ours and deliberate, and the file's name never appears in it, so the check applies to one case.
231
+ if (!collapsed && file.includes("*")) {
232
+ omitted.push(file)
233
+ continue
234
+ }
235
+ if (include.length + exclude.length >= CF_MAX_ROUTE_RULES || rule.length > CF_MAX_RULE_LENGTH) {
236
+ omitted.push(file)
237
+ continue
238
+ }
239
+ exclude.push(rule)
240
+ emitted.add(rule)
241
+ }
242
+ return { include, exclude, omitted }
243
+ }
244
+
85
245
  /** The built asset map — the server reads `entry` for the client script + serves `assets`. */
246
+ /**
247
+ * Copy `from` into `to`, returning the URL paths copied (sorted).
248
+ *
249
+ * Lives here, not beside `servePublicDir`, because it is BUILD-time: it reaches for `Bun.Glob` and
250
+ * `node:fs`, and `public-dir.ts` is reachable from the client bundle graph through the package
251
+ * entry - a dynamic `import("bun")` there fails the browser build outright.
252
+ */
253
+ export async function copyPublicDir(from: string, to: string): Promise<string[]> {
254
+ const source = resolvePath(from)
255
+ const sourceReal = await realpath(source)
256
+ const copied: string[] = []
257
+ for await (const rel of new Glob("**/*").scan({ cwd: source, dot: true, onlyFiles: false })) {
258
+ const candidate = join(source, rel)
259
+ const candidateStat = await lstat(candidate)
260
+ if (candidateStat.isSymbolicLink()) {
261
+ throw new Error(`[nifra/web] publicDir entry escapes its root through a symlink: ${rel}`)
262
+ }
263
+ if (!candidateStat.isFile()) continue
264
+ const candidateReal = await realpath(candidate)
265
+ if (candidateReal !== sourceReal && !candidateReal.startsWith(sourceReal + sep)) {
266
+ throw new Error(`[nifra/web] publicDir entry escapes its root through a symlink: ${rel}`)
267
+ }
268
+ const target = join(to, rel)
269
+ await mkdir(join(target, ".."), { recursive: true })
270
+ await cp(candidateReal, target)
271
+ copied.push(`/${rel.split(sep).map(encodePathSegment).join("/")}`)
272
+ }
273
+ return copied.sort()
274
+ }
275
+
86
276
  export interface BuildManifest {
87
277
  /** URL of the client entry module (content-hashed). */
88
278
  readonly entry: string
@@ -92,6 +282,10 @@ export interface BuildManifest {
92
282
  * `routePreload` (`<link rel="modulepreload">` the matched route alongside the entry). Each route +
93
283
  * layout is also a build entrypoint, so it gets a named chunk the bootstrap's lazy import dedupes to. */
94
284
  readonly routes: Readonly<Record<string, readonly string[]>>
285
+ /** URL paths copied from `publicDir`, sorted. Lets the server entry serve them without scanning a
286
+ * directory per request, and lets an adapter that needs a file list (CDN upload, platform static
287
+ * assets) consume one. Omitted when there is no `public/`. */
288
+ readonly publicFiles?: readonly string[]
95
289
  /** The app's bundled, content-hashed stylesheet(s) — the bootstrap's **aggregate** CSS (every
96
290
  * `import './x.css'` reachable from the app). The complete stylesheet regardless of which file
97
291
  * imported the CSS; the always-safe fallback `createWebApp` links when a route has no per-route entry
@@ -216,9 +410,9 @@ function shortestBuiltinChain(
216
410
  * literal that merely contains `"node:crypto"`. Pure + exported for unit testing. Empty ⇒ clean.
217
411
  */
218
412
  export function detectNodeBuiltinsInClient(
219
- meta: BunMetafile | undefined,
413
+ graph: ClientModuleGraph,
220
414
  ): ReadonlyArray<NodeBuiltinFinding> {
221
- const inputs = meta?.inputs ?? {}
415
+ const inputs = graph.modules
222
416
  // (1) The builtins a user (non-polyfill) module imports directly — the ones the author controls.
223
417
  const userImported = new Set<string>()
224
418
  for (const [inputKey, input] of Object.entries(inputs)) {
@@ -232,7 +426,7 @@ export function detectNodeBuiltinsInClient(
232
426
  // at the file the dev wrote an `import` in. Each output's `entryPoint` is one such input.
233
427
  const entryInputs = [
234
428
  ...new Set(
235
- Object.values(meta?.outputs ?? {})
429
+ Object.values(graph.chunks)
236
430
  .map((o) => o.entryPoint)
237
431
  .filter((e): e is string => e !== undefined && !e.startsWith("node:")),
238
432
  ),
@@ -248,8 +442,8 @@ export function detectNodeBuiltinsInClient(
248
442
  }
249
443
  // (2) Locate which emitted chunk each user-imported builtin reached, via the per-output `inputs`.
250
444
  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 ?? {})) {
445
+ for (const [outPath, out] of Object.entries(graph.chunks)) {
446
+ for (const inputKey of out.modules) {
253
447
  if (userImported.has(inputKey)) {
254
448
  const chunk = basename(outPath)
255
449
  findings.set(`${inputKey}\0${chunk}`, {
@@ -386,9 +580,9 @@ function inputKeyResolver(
386
580
  * unit testing. Empty ⇒ clean.
387
581
  */
388
582
  export function detectServerOnlyInClient(
389
- meta: BunMetafile | undefined,
583
+ graph: ClientModuleGraph,
390
584
  ): ReadonlyArray<ServerOnlyFinding> {
391
- const inputs = meta?.inputs ?? {}
585
+ const inputs = graph.modules
392
586
  // (1) The modules that import the marker — the ones the author opted into the guard. The marker
393
587
  // module itself is skipped: it's the import TARGET, not an opt-in (it imports nothing of its own).
394
588
  const marked = new Set<string>()
@@ -401,7 +595,7 @@ export function detectServerOnlyInClient(
401
595
  // wrote an `import` in. Same derivation as the node-builtin guard.
402
596
  const entryInputs = [
403
597
  ...new Set(
404
- Object.values(meta?.outputs ?? {})
598
+ Object.values(graph.chunks)
405
599
  .map((o) => o.entryPoint)
406
600
  .filter((e): e is string => e !== undefined && !e.startsWith("node:")),
407
601
  ),
@@ -417,8 +611,8 @@ export function detectServerOnlyInClient(
417
611
  }
418
612
  // (2) Locate which emitted chunk each marked module reached, via the per-output `inputs`.
419
613
  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 ?? {})) {
614
+ for (const [outPath, out] of Object.entries(graph.chunks)) {
615
+ for (const inputKey of out.modules) {
422
616
  if (marked.has(inputKey)) {
423
617
  const chunk = basename(outPath)
424
618
  findings.set(`${inputKey}\0${chunk}`, { chunk, chain: chainFor(inputKey) })
@@ -437,6 +631,48 @@ export function detectServerOnlyInClient(
437
631
  const isServerOnlyMarkerModule = (inputKey: string): boolean =>
438
632
  /(^|\/)server-only\.[cm]?[jt]s$/.test(inputKey)
439
633
 
634
+ // ---------------------------------------------------------------------------------------------------
635
+ // Guard MESSAGES — one owner for both production pipelines. The Bun build (below) and the Vite/Rollup
636
+ // leak-guard plugin (plugins/vite-leak-guard.ts) both call these, so a leak reads IDENTICALLY whichever
637
+ // bundler produced it. A second bundler must not grow a second, subtly-different wording of a security
638
+ // error; that is exactly the "mostly ported" outcome the neutral graph seam exists to prevent.
639
+ // ---------------------------------------------------------------------------------------------------
640
+
641
+ /** The build-failing message for `node:` builtins that reached the client bundle. `undefined` ⇒ clean. */
642
+ export function formatNodeBuiltinLeak(
643
+ findings: ReadonlyArray<NodeBuiltinFinding>,
644
+ ): string | undefined {
645
+ if (findings.length === 0) return undefined
646
+ const lines = findings.map((f) =>
647
+ f.chain.length > 1
648
+ ? ` - ${f.builtin} reached the client bundle via ${f.chain.join(" → ")} (chunk: ${f.chunk})`
649
+ : ` - ${f.builtin} reached the client bundle via ${f.chunk}`,
650
+ )
651
+ return (
652
+ `[nifra/web] Node built-in(s) in the client bundle — move them behind a server-only path ` +
653
+ `(a loader/action runs on the server; import the \`node:\` module there, not at a route's ` +
654
+ `top level):\n${lines.join("\n")}`
655
+ )
656
+ }
657
+
658
+ /** The build-failing message for `server-only`-marked modules that reached the client. `undefined` ⇒ clean. */
659
+ export function formatServerOnlyLeak(
660
+ findings: ReadonlyArray<ServerOnlyFinding>,
661
+ ): string | undefined {
662
+ if (findings.length === 0) return undefined
663
+ const lines = findings.map((f) =>
664
+ f.chain.length > 1
665
+ ? ` - server-only module reached the client bundle via ${f.chain.join(" → ")} (chunk: ${f.chunk})`
666
+ : ` - server-only module reached the client bundle via ${f.chunk}`,
667
+ )
668
+ return (
669
+ `[nifra/web] server-only module(s) in the client bundle — a module marked ` +
670
+ `\`import "${SERVER_ONLY_MARKER}"\` reached the browser. Move it behind a server-only path ` +
671
+ `(reach it via a loader/action, or rename it \`*.server.ts\`), so its server logic never ships ` +
672
+ `to the client:\n${lines.join("\n")}`
673
+ )
674
+ }
675
+
440
676
  const basename = (path: string): string => path.slice(path.lastIndexOf("/") + 1)
441
677
  /** `[name]` Bun derives for an entrypoint: basename without extension (`users/[id].tsx` → `[id]`). */
442
678
  const entryName = (file: string): string => {
@@ -784,42 +1020,18 @@ export async function buildClient(options: BuildClientOptions): Promise<BuildMan
784
1020
  // per-output `inputs`), so it can't false-positive on a `"node:..."` string literal and survives
785
1021
  // minification. Only the client build runs this; the server build's `node:` imports are legitimate.
786
1022
  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
- }
1023
+ const clientGraph = fromBunMetafile(clientMeta)
1024
+ // #4: a `node:` builtin (e.g. `node:crypto`) pulled into a CLIENT chunk builds fine (Bun substitutes a
1025
+ // browser polyfill) but breaks/leaks at runtime. Fail with the chain (entry → … → builtin), through the
1026
+ // SHARED formatter so the Vite pipeline's identical guard reads byte-for-byte the same.
1027
+ const nodeBuiltinLeak = formatNodeBuiltinLeak(detectNodeBuiltinsInClient(clientGraph))
1028
+ if (nodeBuiltinLeak !== undefined) throw new Error(nodeBuiltinLeak)
1029
+
1030
+ // §3.3/§5.1: a module that opted into the `server-only` marker yet reached a CLIENT chunk — catches
1031
+ // pure-server logic (a secret, a server-only API call) carrying no `node:` import and not named
1032
+ // `*.server`, so neither other guard fires. Same shared formatter as above.
1033
+ const serverOnlyLeak = formatServerOnlyLeak(detectServerOnlyInClient(clientGraph))
1034
+ if (serverOnlyLeak !== undefined) throw new Error(serverOnlyLeak)
823
1035
 
824
1036
  // Rename any chunk whose basename isn't URL-safe (dynamic-route files become `[slug]-hash.js`) and
825
1037
  // rewrite the references — otherwise the lazy import 404s and the route silently never hydrates.
@@ -899,10 +1111,17 @@ export async function buildClient(options: BuildClientOptions): Promise<BuildMan
899
1111
  if (routeManifest.notFound) routeStyles._404 = stylesFor([routeManifest.notFound.file])
900
1112
  }
901
1113
 
1114
+ // Copy `public/` into the output next to the hashed assets. A missing directory is normal (most
1115
+ // apps have none) and must not fail the build.
1116
+ const publicDir = options.publicDir === false ? undefined : (options.publicDir ?? "public")
1117
+ const publicFiles =
1118
+ publicDir !== undefined && existsSync(publicDir) ? await copyPublicDir(publicDir, outDir) : []
1119
+
902
1120
  const manifest: BuildManifest = {
903
1121
  entry: toUrl(bootstrap.path),
904
1122
  assets: result.outputs.map((o) => toUrl(o.path)),
905
1123
  routes,
1124
+ ...(publicFiles.length > 0 ? { publicFiles } : {}),
906
1125
  ...(css.length > 0 ? { css } : {}),
907
1126
  ...(Object.keys(routeStyles).length > 0 ? { routeStyles } : {}),
908
1127
  }
@@ -1158,6 +1377,7 @@ export async function buildServer(options: BuildServerOptions): Promise<ServerBu
1158
1377
  // of bundling, layered after any caller `define` so it can't be overridden). Unbundled Bun runtimes
1159
1378
  // (nifra dev/start, nifra_render) never define it, so they still re-root — the dev dual-install fix.
1160
1379
  "process.env.NIFRA_SSR_BUNDLED": '"1"',
1380
+ "globalThis.__NIFRA_EDGE_RUNTIME__": edge ? "true" : "false",
1161
1381
  },
1162
1382
  minify: options.minify ?? true,
1163
1383
  // Lazy → one chunk per route (loaded on first request); eager → a single self-contained file.
@@ -1220,8 +1440,10 @@ export function generateServerEntry(options: {
1220
1440
  readonly backendImport?: string
1221
1441
  /** Document `<title>` passed to `createWebApp`. */
1222
1442
  readonly title?: string
1443
+ /** Encoded root-relative public file paths copied into the deploy directory. */
1444
+ readonly publicFiles?: readonly string[]
1223
1445
  }): string {
1224
- const { target, adapterImport, backendImport, title = "nifra" } = options
1446
+ const { target, adapterImport, backendImport, title = "nifra", publicFiles = [] } = options
1225
1447
  if (target === "static") {
1226
1448
  throw new Error("[nifra/web] generateServerEntry: `static` has no server entry (SSG only)")
1227
1449
  }
@@ -1251,9 +1473,28 @@ export function generateServerEntry(options: {
1251
1473
  )
1252
1474
 
1253
1475
  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)")
1476
+ // Cloudflare Pages advanced mode: `_routes.json` keeps static paths off the worker entirely, and
1477
+ // everything else falls through to this handler (SSR).
1478
+ //
1479
+ // That exclude list cannot always name every public file - Cloudflare caps `_routes.json` at 100
1480
+ // rules - so a static request CAN arrive here. Serve it from the ASSETS binding instead of letting
1481
+ // the router 404 it, which makes correctness independent of how much of the list fit: the exclude
1482
+ // list decides only how many static requests skip the worker, never which files exist.
1483
+ lines.push(
1484
+ `const PUBLIC_FILES = new Set(${JSON.stringify(publicFiles)})`,
1485
+ "const handler = toFetchHandler(app)",
1486
+ "type Assets = { readonly ASSETS?: { fetch(request: Request): Promise<Response> } }",
1487
+ "export default {",
1488
+ " fetch(request: Request, env: Assets, ctx: ExecutionContext) {",
1489
+ " const { pathname } = new URL(request.url)",
1490
+ " // Only the build's own outputs: the hashed bundle prefix and the files it copied.",
1491
+ ' if (env?.ASSETS !== undefined && (pathname.startsWith("/assets/") || PUBLIC_FILES.has(pathname))) {',
1492
+ " return env.ASSETS.fetch(request)",
1493
+ " }",
1494
+ " return handler.fetch(request, env as never, ctx)",
1495
+ " },",
1496
+ "}",
1497
+ )
1257
1498
  return `${lines.join("\n")}\n`
1258
1499
  }
1259
1500
  if (target === "vercel") {
@@ -1267,8 +1508,20 @@ export function generateServerEntry(options: {
1267
1508
 
1268
1509
  // bun / node / deno self-host AND serve the client bundle from disk (it sits next to this entry).
1269
1510
  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)',
1511
+ "// The client bundle and public files live next to this entry; serve only manifest-approved paths.",
1512
+ 'const STATIC_ROOT = new URL("./", import.meta.url)',
1513
+ `const PUBLIC_FILES = new Set(${JSON.stringify(publicFiles)})`,
1514
+ "const staticPath = (pathname: string): string | undefined => {",
1515
+ " if (PUBLIC_FILES.has(pathname)) return '.' + pathname",
1516
+ ' if (!pathname.startsWith("/assets/")) return undefined',
1517
+ ' const segments = pathname.slice(1).split("/")',
1518
+ " // `.` and `..` match the character class, and the path is joined onto a base URL that resolves",
1519
+ " // dot segments - so they are rejected explicitly rather than relying on the request URL having",
1520
+ " // been normalized upstream.",
1521
+ " const safe = (segment: string): boolean =>",
1522
+ ' segment !== "." && segment !== ".." && /^[A-Za-z0-9._-]+$/.test(segment)',
1523
+ " return segments.every(safe) ? '.' + pathname : undefined",
1524
+ "}",
1272
1525
  'const TYPES = { js: "text/javascript", css: "text/css", map: "application/json" }',
1273
1526
  )
1274
1527
  if (target === "bun") {
@@ -1277,12 +1530,11 @@ export function generateServerEntry(options: {
1277
1530
  " port: Number(Bun.env.PORT ?? 3000),",
1278
1531
  " async fetch(req) {",
1279
1532
  " 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))",
1533
+ " const filePath = staticPath(pathname)",
1534
+ " if (filePath !== undefined) {",
1535
+ " const file = Bun.file(new URL(filePath, STATIC_ROOT))",
1284
1536
  ' if (!(await file.exists())) return new Response("not found", { status: 404 })',
1285
- ' const ext = name.slice(name.lastIndexOf(".") + 1)',
1537
+ ' const ext = pathname.slice(pathname.lastIndexOf(".") + 1)',
1286
1538
  ' return new Response(file, { headers: { "content-type": TYPES[ext] ?? "application/octet-stream" } })',
1287
1539
  " }",
1288
1540
  " return app.fetch(req)",
@@ -1301,12 +1553,11 @@ export function generateServerEntry(options: {
1301
1553
  " {",
1302
1554
  " async fetch(req) {",
1303
1555
  " 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 })',
1556
+ " const filePath = staticPath(pathname)",
1557
+ " if (filePath !== undefined) {",
1307
1558
  " try {",
1308
- " const body = await readFile(new URL(name, ASSETS))",
1309
- ' const ext = name.slice(name.lastIndexOf(".") + 1)',
1559
+ " const body = await readFile(new URL(filePath, STATIC_ROOT))",
1560
+ ' const ext = pathname.slice(pathname.lastIndexOf(".") + 1)',
1310
1561
  ' return new Response(body, { headers: { "content-type": TYPES[ext] ?? "application/octet-stream" } })',
1311
1562
  " } catch {",
1312
1563
  ' return new Response("not found", { status: 404 })',
@@ -1326,13 +1577,12 @@ export function generateServerEntry(options: {
1326
1577
  "// @ts-ignore — Deno global is present on the Deno runtime this output targets.",
1327
1578
  'Deno.serve({ port: Number(Deno.env.get("PORT") ?? "3000") }, async (req) => {',
1328
1579
  " 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 })',
1580
+ " const filePath = staticPath(pathname)",
1581
+ " if (filePath !== undefined) {",
1332
1582
  " try {",
1333
1583
  " // @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)',
1584
+ " const body = await Deno.readFile(new URL(filePath, STATIC_ROOT))",
1585
+ ' const ext = pathname.slice(pathname.lastIndexOf(".") + 1)',
1336
1586
  ' return new Response(body, { headers: { "content-type": TYPES[ext] ?? "application/octet-stream" } })',
1337
1587
  " } catch {",
1338
1588
  ' return new Response("not found", { status: 404 })',
@@ -1397,6 +1647,10 @@ export interface BuildTargetOptions {
1397
1647
  readonly conditions?: readonly string[]
1398
1648
  /** Compile-time `define` replacements layered onto both builds. */
1399
1649
  readonly define?: Readonly<Record<string, string>>
1650
+ /** Project-root static directory copied to deploy root. `false` disables it. */
1651
+ readonly publicDir?: string | false
1652
+ /** Prefix of environment variables allowed into the client bundle (default `"PUBLIC_"`). */
1653
+ readonly publicEnvPrefix?: string
1400
1654
  /** Document `<title>` for the generated server entry. */
1401
1655
  readonly title?: string
1402
1656
  }
@@ -1436,9 +1690,84 @@ export interface BuildTargetResult {
1436
1690
  * Note: the heavier platform wrappers (`.vercel/output` v3 layout, wrangler ISR `find_additional_modules`)
1437
1691
  * remain app-owned scripts; this command targets the common single-bundle deploys. See the CLI docs.
1438
1692
  */
1693
+ /**
1694
+ * A build-tool STRATEGY for {@link buildTargetWith} — the two bundling steps, and nothing else. Everything
1695
+ * around them (server-entry codegen, deploy assembly, prerender, size report) is bundler-agnostic and
1696
+ * lives in `buildTargetWith`, so a second bundler (Vite) is this interface, not a second orchestrator.
1697
+ *
1698
+ * Plugin lists are `readonly unknown[]` because a Bun plugin and a Vite plugin are different types; each
1699
+ * strategy casts to its own. `buildTargetWith` only forwards them.
1700
+ */
1701
+ export interface Bundler {
1702
+ /** Build the client bundle → the shared {@link BuildManifest}. */
1703
+ buildClient(input: {
1704
+ readonly routesDir: string
1705
+ readonly outDir: string
1706
+ readonly clientModule: string
1707
+ readonly plugins?: readonly unknown[]
1708
+ readonly conditions?: readonly string[]
1709
+ readonly define?: Readonly<Record<string, string>>
1710
+ readonly publicDir?: string | false
1711
+ readonly publicEnvPrefix?: string
1712
+ /** Project root (Vite needs it; the Bun strategy ignores it). */
1713
+ readonly root?: string
1714
+ }): Promise<BuildManifest>
1715
+ /** Build the server worker → the shared {@link ServerBuild}. */
1716
+ buildServer(input: {
1717
+ readonly routesDir: string
1718
+ readonly serverEntry: string
1719
+ readonly outDir: string
1720
+ readonly clientEntry: string
1721
+ readonly target: "browser" | "node" | "bun"
1722
+ readonly plugins?: readonly unknown[]
1723
+ readonly define?: Readonly<Record<string, string>>
1724
+ readonly root?: string
1725
+ }): Promise<ServerBuild>
1726
+ }
1727
+
1728
+ /** The default (Bun) strategy — `buildClient`/`buildServer` from this module. */
1729
+ export const bunBundler: Bundler = {
1730
+ buildClient: (input) =>
1731
+ buildClient({
1732
+ routesDir: input.routesDir,
1733
+ outDir: input.outDir,
1734
+ clientModule: input.clientModule,
1735
+ ...(input.plugins ? { plugins: input.plugins as BunPlugin[] } : {}),
1736
+ ...(input.conditions ? { conditions: input.conditions } : {}),
1737
+ ...(input.define ? { define: input.define } : {}),
1738
+ ...(input.publicDir !== undefined ? { publicDir: input.publicDir } : {}),
1739
+ ...(input.publicEnvPrefix !== undefined ? { publicEnvPrefix: input.publicEnvPrefix } : {}),
1740
+ }),
1741
+ buildServer: (input) =>
1742
+ buildServer({
1743
+ routesDir: input.routesDir,
1744
+ serverEntry: input.serverEntry,
1745
+ outDir: input.outDir,
1746
+ clientEntry: input.clientEntry,
1747
+ target: input.target,
1748
+ ...(input.plugins ? { plugins: input.plugins as BunPlugin[] } : {}),
1749
+ ...(input.define ? { define: input.define } : {}),
1750
+ }),
1751
+ }
1752
+
1753
+ /** Build a full deploy dir for `target` using the default Bun bundler. See {@link buildTargetWith}. */
1439
1754
  export async function buildTarget(
1440
1755
  target: BuildTarget,
1441
1756
  options: BuildTargetOptions,
1757
+ ): Promise<BuildTargetResult> {
1758
+ return buildTargetWith(target, options, bunBundler)
1759
+ }
1760
+
1761
+ /**
1762
+ * The bundler-agnostic deploy orchestrator: everything `buildTarget` does EXCEPT the two bundling steps,
1763
+ * which come from `bundler`. `buildTarget` passes {@link bunBundler}; `buildTargetVite`
1764
+ * (`@nifrajs/web/build-vite`) passes the Vite strategy. One orchestrator, so the deploy-dir shape,
1765
+ * server-entry codegen, prerender and size report are identical across pipelines.
1766
+ */
1767
+ export async function buildTargetWith(
1768
+ target: BuildTarget,
1769
+ options: BuildTargetOptions,
1770
+ bundler: Bundler,
1442
1771
  ): Promise<BuildTargetResult> {
1443
1772
  const { routesDir, outDir, workDir } = options
1444
1773
  const { rmSync } = await import("node:fs")
@@ -1449,14 +1778,27 @@ export async function buildTarget(
1449
1778
  mkdirSync(workDir, { recursive: true })
1450
1779
 
1451
1780
  // (1) Client bundle → <outDir>/assets/* (every target ships the same hashed client bundle).
1452
- const client = await buildClient({
1781
+ let client = await bundler.buildClient({
1453
1782
  routesDir,
1454
1783
  outDir: assetsDir,
1455
1784
  clientModule: options.clientModule,
1456
1785
  ...(options.clientPlugins ? { plugins: options.clientPlugins } : {}),
1457
1786
  ...(options.conditions ? { conditions: options.conditions } : {}),
1458
1787
  define: { "process.env.NODE_ENV": '"production"', ...(options.define ?? {}) },
1788
+ publicDir: false,
1789
+ ...(options.publicEnvPrefix !== undefined ? { publicEnvPrefix: options.publicEnvPrefix } : {}),
1790
+ root: resolvePath(dirname(routesDir)),
1459
1791
  })
1792
+ const publicDir =
1793
+ options.publicDir === false
1794
+ ? undefined
1795
+ : resolvePath(options.publicDir ?? join(dirname(routesDir), "public"))
1796
+ const publicFiles =
1797
+ publicDir !== undefined && existsSync(publicDir) ? await copyPublicDir(publicDir, outDir) : []
1798
+ if (publicFiles.length > 0) {
1799
+ client = { ...client, publicFiles }
1800
+ writeFileSync(`${assetsDir}/manifest.json`, JSON.stringify(client, null, 2))
1801
+ }
1460
1802
 
1461
1803
  if (target === "static") {
1462
1804
  if (options.prerenderApp === undefined) {
@@ -1506,10 +1848,11 @@ export async function buildTarget(
1506
1848
  adapterImport: options.adapterImport,
1507
1849
  ...(options.backendImport !== undefined ? { backendImport: options.backendImport } : {}),
1508
1850
  ...(options.title !== undefined ? { title: options.title } : {}),
1851
+ ...(publicFiles.length > 0 ? { publicFiles } : {}),
1509
1852
  }),
1510
1853
  )
1511
1854
  const serverTarget = SERVER_BUILD_TARGET[target]
1512
- const { worker } = await buildServer({
1855
+ const { worker } = await bundler.buildServer({
1513
1856
  routesDir,
1514
1857
  serverEntry: serverEntryPath,
1515
1858
  outDir: `${workDir}/server`,
@@ -1517,6 +1860,7 @@ export async function buildTarget(
1517
1860
  target: serverTarget,
1518
1861
  ...(options.serverPlugins ? { plugins: options.serverPlugins } : {}),
1519
1862
  define: { "process.env.NODE_ENV": '"production"', ...(options.define ?? {}) },
1863
+ root: resolvePath(dirname(routesDir)),
1520
1864
  })
1521
1865
 
1522
1866
  // (3) Assemble the deploy dir for the target.
@@ -1524,10 +1868,25 @@ export async function buildTarget(
1524
1868
  let run: string
1525
1869
  if (target === "cf-pages") {
1526
1870
  cpSync(worker, `${outDir}/_worker.js`)
1871
+ // The app's real patterns, so a directory is only collapsed into a glob once the route table
1872
+ // proves nothing can be served beneath it.
1873
+ const rules = cloudflareRouteRules(
1874
+ publicFiles,
1875
+ discoverRoutes(routesDir).routes.map((route) => route.pattern),
1876
+ )
1527
1877
  writeFileSync(
1528
1878
  `${outDir}/_routes.json`,
1529
- `${JSON.stringify({ version: 1, include: ["/*"], exclude: ["/assets/*"] }, null, 2)}\n`,
1879
+ `${JSON.stringify({ version: 1, include: rules.include, exclude: rules.exclude }, null, 2)}\n`,
1530
1880
  )
1881
+ // Never cap silently: an omitted file still serves (the worker's ASSETS fallback), but it costs a
1882
+ // worker invocation, and that trade should be visible rather than inferred from a latency graph.
1883
+ if (rules.omitted.length > 0) {
1884
+ console.log(
1885
+ `[nifra/web] _routes.json holds ${rules.exclude.length} of ${publicFiles.length + 1} static rules ` +
1886
+ `(Cloudflare allows ${CF_MAX_ROUTE_RULES}). The other ${rules.omitted.length} file(s) are served by the ` +
1887
+ "worker via ASSETS instead of the CDN directly - correct, just one invocation each.",
1888
+ )
1889
+ }
1531
1890
  run = `Cloudflare Pages → ${outDir} (deploy: wrangler pages deploy ${basename(outDir)})`
1532
1891
  } else if (target === "vercel") {
1533
1892
  cpSync(worker, `${outDir}/index.js`)