@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/dist/build.js CHANGED
@@ -5,12 +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";
8
+ import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
9
+ import { cp, lstat, mkdir, realpath } from "node:fs/promises";
10
+ import { dirname, join, relative, resolve as resolvePath, sep } from "node:path";
11
+ import { Glob } from "bun";
10
12
  import { sanitizeOutputNames } from "./chunk-names.js";
11
13
  import { discoverRoutes } from "./fs.js";
12
14
  import { generateClientEntry, generateServerManifest } from "./index.js";
13
15
  // `buildTarget(static)` drives the SSG prerender engine directly (it's also re-exported below).
16
+ import { fromBunMetafile } from "./module-graph.js";
14
17
  import { prerenderRoutes } from "./prerender.js";
15
18
  // Build-time SSG: prerender opted-in static + dynamic routes to `index.html` (+ static `_data.json`),
16
19
  // run after `buildClient`.
@@ -34,6 +37,156 @@ export function publicEnvDefines(prefix, env) {
34
37
  }
35
38
  return defines;
36
39
  }
40
+ /**
41
+ * Percent-encode one path segment exactly the way a browser encodes it into a request URL.
42
+ *
43
+ * NOT `encodeURIComponent`. That escapes the sub-delimiters `, @ + = & ; $`, which a browser sends raw -
44
+ * so a file named `report,2026.csv` would be recorded as `/report%2C2026.csv` while the request arrives
45
+ * as `/report,2026.csv`, the allowlist lookup misses, and the file 404s in production only. `encodeURI`
46
+ * agrees with `URL.pathname` on every character except `?` and `#`, which terminate a path and so must
47
+ * be escaped explicitly here.
48
+ */
49
+ function encodePathSegment(segment) {
50
+ return encodeURI(segment).replace(/[?#]/g, (char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`);
51
+ }
52
+ /**
53
+ * Cloudflare Pages rejects a `_routes.json` with more than 100 include+exclude rules combined, or any
54
+ * single rule longer than 100 characters.
55
+ */
56
+ const CF_MAX_ROUTE_RULES = 100;
57
+ const CF_MAX_RULE_LENGTH = 100;
58
+ function firstSegmentOf(pattern) {
59
+ const first = pattern.split("/").find((segment) => segment !== "");
60
+ if (first === undefined)
61
+ return { kind: "root" };
62
+ return /^[A-Za-z0-9._-]+$/.test(first) ? { kind: "literal", value: first } : { kind: "dynamic" };
63
+ }
64
+ /**
65
+ * Directories under `public/` whose every file can be replaced by one `/<dir>/*` rule.
66
+ *
67
+ * Safe only when no route can ever be served beneath that directory, because the glob does not merely
68
+ * describe today's files - it hands Pages every future path under the prefix, and the worker never sees
69
+ * them. A `public/blog/hero.png` next to a `/blog/:slug` route is the ordinary case that breaks: the
70
+ * glob would send `/blog/my-post` to the CDN, which has no such file, and the page 404s in production
71
+ * only. So a directory collapses only when every route pattern starts with a literal segment that is
72
+ * something else. One dynamic first segment anywhere (`/:locale/…`) disables collapsing entirely, which
73
+ * is correct - such a route can match under any directory name.
74
+ *
75
+ * A collapsed directory also gives up the app's own 404 page for a missing file beneath it, since the
76
+ * request never reaches the worker. That is the right trade for a directory that holds only static
77
+ * files: a missing image should fail as a fast CDN 404, not as an HTML error page.
78
+ */
79
+ function collapsibleDirs(publicFiles, routePatterns) {
80
+ const reserved = new Set();
81
+ for (const pattern of routePatterns) {
82
+ const first = firstSegmentOf(pattern);
83
+ // A dynamic first segment can match anywhere, so nothing is collapsible at all.
84
+ if (first.kind === "dynamic")
85
+ return new Set();
86
+ // `root` reserves nothing: `/` cannot match a path below a directory.
87
+ if (first.kind === "literal")
88
+ reserved.add(first.value);
89
+ }
90
+ const dirs = new Set();
91
+ for (const file of publicFiles) {
92
+ const slash = file.indexOf("/", 1);
93
+ if (slash === -1)
94
+ continue; // a root-level file has no directory to collapse into
95
+ const dir = file.slice(1, slash);
96
+ if (!reserved.has(dir))
97
+ dirs.add(dir);
98
+ }
99
+ return dirs;
100
+ }
101
+ /**
102
+ * Build the cf-pages `_routes.json` rules for a set of copied public files, within Cloudflare's budget.
103
+ *
104
+ * `exclude` is what Pages serves straight from the CDN instead of invoking the worker, so naming every
105
+ * public file is ideal - and impossible past ~99 of them. A `public/` of icons, fonts and share images
106
+ * clears 100 easily, and the rejection lands at `wrangler pages deploy`, long after the build reported
107
+ * success.
108
+ *
109
+ * A glob is emitted only where it cannot be wrong. `/assets/*` always is: the build owns that prefix
110
+ * outright, since it is where the hashed bundle is written. A `public/` subdirectory is different - the
111
+ * name is the author's, and a `/blog/*` rule would hand Pages every future path under `/blog/`, so a
112
+ * `/blog/:slug` route would 404 from the CDN in production only. {@link collapsibleDirs} therefore
113
+ * collapses a directory only after checking it against the app's real route patterns, which is what
114
+ * makes the compaction safe rather than merely smaller. Everything else stays an exact path, and a name
115
+ * containing `*` is dropped rather than escaped, since the rule would become a wildcard.
116
+ *
117
+ * What still does not fit is dropped, which is safe because the list is only an optimization: the
118
+ * generated worker serves any allowlisted path it receives through the ASSETS binding, so an omitted
119
+ * file costs one worker invocation, not a 404.
120
+ */
121
+ export function cloudflareRouteRules(publicFiles,
122
+ /**
123
+ * Every route pattern the app serves. Required, not defaulted: an empty list means "this app serves
124
+ * no routes", which makes every directory collapsible - a defensible answer to state deliberately and
125
+ * a dangerous one to arrive at by forgetting the argument.
126
+ */
127
+ routePatterns) {
128
+ const include = ["/*"];
129
+ const exclude = ["/assets/*"];
130
+ const omitted = [];
131
+ // Collapse only where the route table proves nothing can be served beneath the directory, and only
132
+ // when the per-file list would not have fit anyway - a smaller `_routes.json` is worth nothing on its
133
+ // own, and exact paths keep the app's own 404 for a missing file under that directory.
134
+ const perFileFits = publicFiles.length + include.length + exclude.length <= CF_MAX_ROUTE_RULES;
135
+ const collapsible = perFileFits ? new Set() : collapsibleDirs(publicFiles, routePatterns);
136
+ const emitted = new Set();
137
+ for (const file of publicFiles) {
138
+ const slash = file.indexOf("/", 1);
139
+ const dir = slash === -1 ? undefined : file.slice(1, slash);
140
+ const collapsed = dir !== undefined && collapsible.has(dir);
141
+ const rule = collapsed ? `/${dir}/*` : file;
142
+ if (emitted.has(rule))
143
+ continue; // already covered by a rule emitted for an earlier file
144
+ // A `*` in a FILE name would turn that file's own rule into a wildcard. A collapsed rule's `*` is
145
+ // ours and deliberate, and the file's name never appears in it, so the check applies to one case.
146
+ if (!collapsed && file.includes("*")) {
147
+ omitted.push(file);
148
+ continue;
149
+ }
150
+ if (include.length + exclude.length >= CF_MAX_ROUTE_RULES || rule.length > CF_MAX_RULE_LENGTH) {
151
+ omitted.push(file);
152
+ continue;
153
+ }
154
+ exclude.push(rule);
155
+ emitted.add(rule);
156
+ }
157
+ return { include, exclude, omitted };
158
+ }
159
+ /** The built asset map — the server reads `entry` for the client script + serves `assets`. */
160
+ /**
161
+ * Copy `from` into `to`, returning the URL paths copied (sorted).
162
+ *
163
+ * Lives here, not beside `servePublicDir`, because it is BUILD-time: it reaches for `Bun.Glob` and
164
+ * `node:fs`, and `public-dir.ts` is reachable from the client bundle graph through the package
165
+ * entry - a dynamic `import("bun")` there fails the browser build outright.
166
+ */
167
+ export async function copyPublicDir(from, to) {
168
+ const source = resolvePath(from);
169
+ const sourceReal = await realpath(source);
170
+ const copied = [];
171
+ for await (const rel of new Glob("**/*").scan({ cwd: source, dot: true, onlyFiles: false })) {
172
+ const candidate = join(source, rel);
173
+ const candidateStat = await lstat(candidate);
174
+ if (candidateStat.isSymbolicLink()) {
175
+ throw new Error(`[nifra/web] publicDir entry escapes its root through a symlink: ${rel}`);
176
+ }
177
+ if (!candidateStat.isFile())
178
+ continue;
179
+ const candidateReal = await realpath(candidate);
180
+ if (candidateReal !== sourceReal && !candidateReal.startsWith(sourceReal + sep)) {
181
+ throw new Error(`[nifra/web] publicDir entry escapes its root through a symlink: ${rel}`);
182
+ }
183
+ const target = join(to, rel);
184
+ await mkdir(join(target, ".."), { recursive: true });
185
+ await cp(candidateReal, target);
186
+ copied.push(`/${rel.split(sep).map(encodePathSegment).join("/")}`);
187
+ }
188
+ return copied.sort();
189
+ }
37
190
  /** The `node:` specifier of an import edge, or undefined if it isn't a Node built-in. Reads the
38
191
  * `original` (as-written) specifier first, falling back to the resolved `path` (an `external` node:
39
192
  * import keeps `node:` in its path). */
@@ -103,8 +256,8 @@ function shortestBuiltinChain(inputs, entryInputs, builtin) {
103
256
  * Graph-based (never the emitted text), so it survives minification and can't be fooled by a string
104
257
  * literal that merely contains `"node:crypto"`. Pure + exported for unit testing. Empty ⇒ clean.
105
258
  */
106
- export function detectNodeBuiltinsInClient(meta) {
107
- const inputs = meta?.inputs ?? {};
259
+ export function detectNodeBuiltinsInClient(graph) {
260
+ const inputs = graph.modules;
108
261
  // (1) The builtins a user (non-polyfill) module imports directly — the ones the author controls.
109
262
  const userImported = new Set();
110
263
  for (const [inputKey, input] of Object.entries(inputs)) {
@@ -119,7 +272,7 @@ export function detectNodeBuiltinsInClient(meta) {
119
272
  // The entry inputs (route/user entrypoints) — the chain BFS starts here so the reported path begins
120
273
  // at the file the dev wrote an `import` in. Each output's `entryPoint` is one such input.
121
274
  const entryInputs = [
122
- ...new Set(Object.values(meta?.outputs ?? {})
275
+ ...new Set(Object.values(graph.chunks)
123
276
  .map((o) => o.entryPoint)
124
277
  .filter((e) => e !== undefined && !e.startsWith("node:"))),
125
278
  ];
@@ -135,8 +288,8 @@ export function detectNodeBuiltinsInClient(meta) {
135
288
  };
136
289
  // (2) Locate which emitted chunk each user-imported builtin reached, via the per-output `inputs`.
137
290
  const findings = new Map();
138
- for (const [outPath, out] of Object.entries(meta?.outputs ?? {})) {
139
- for (const inputKey of Object.keys(out.inputs ?? {})) {
291
+ for (const [outPath, out] of Object.entries(graph.chunks)) {
292
+ for (const inputKey of out.modules) {
140
293
  if (userImported.has(inputKey)) {
141
294
  const chunk = basename(outPath);
142
295
  findings.set(`${inputKey}\0${chunk}`, {
@@ -252,8 +405,8 @@ function inputKeyResolver(inputs) {
252
405
  * (which imports nothing) is excluded — only the modules that *opt in* are flagged. Pure + exported for
253
406
  * unit testing. Empty ⇒ clean.
254
407
  */
255
- export function detectServerOnlyInClient(meta) {
256
- const inputs = meta?.inputs ?? {};
408
+ export function detectServerOnlyInClient(graph) {
409
+ const inputs = graph.modules;
257
410
  // (1) The modules that import the marker — the ones the author opted into the guard. The marker
258
411
  // module itself is skipped: it's the import TARGET, not an opt-in (it imports nothing of its own).
259
412
  const marked = new Set();
@@ -268,7 +421,7 @@ export function detectServerOnlyInClient(meta) {
268
421
  // The entry inputs — the chain BFS starts here, so the reported path begins at the file the dev
269
422
  // wrote an `import` in. Same derivation as the node-builtin guard.
270
423
  const entryInputs = [
271
- ...new Set(Object.values(meta?.outputs ?? {})
424
+ ...new Set(Object.values(graph.chunks)
272
425
  .map((o) => o.entryPoint)
273
426
  .filter((e) => e !== undefined && !e.startsWith("node:"))),
274
427
  ];
@@ -284,8 +437,8 @@ export function detectServerOnlyInClient(meta) {
284
437
  };
285
438
  // (2) Locate which emitted chunk each marked module reached, via the per-output `inputs`.
286
439
  const findings = new Map();
287
- for (const [outPath, out] of Object.entries(meta?.outputs ?? {})) {
288
- for (const inputKey of Object.keys(out.inputs ?? {})) {
440
+ for (const [outPath, out] of Object.entries(graph.chunks)) {
441
+ for (const inputKey of out.modules) {
289
442
  if (marked.has(inputKey)) {
290
443
  const chunk = basename(outPath);
291
444
  findings.set(`${inputKey}\0${chunk}`, { chunk, chain: chainFor(inputKey) });
@@ -301,6 +454,35 @@ export function detectServerOnlyInClient(meta) {
301
454
  /** True when an INPUT graph key is the marker module file itself (`…/server-only.{ts,js}` under web).
302
455
  * Excluded from the "marked" set — the marker is the import target, not an opt-in module. */
303
456
  const isServerOnlyMarkerModule = (inputKey) => /(^|\/)server-only\.[cm]?[jt]s$/.test(inputKey);
457
+ // ---------------------------------------------------------------------------------------------------
458
+ // Guard MESSAGES — one owner for both production pipelines. The Bun build (below) and the Vite/Rollup
459
+ // leak-guard plugin (plugins/vite-leak-guard.ts) both call these, so a leak reads IDENTICALLY whichever
460
+ // bundler produced it. A second bundler must not grow a second, subtly-different wording of a security
461
+ // error; that is exactly the "mostly ported" outcome the neutral graph seam exists to prevent.
462
+ // ---------------------------------------------------------------------------------------------------
463
+ /** The build-failing message for `node:` builtins that reached the client bundle. `undefined` ⇒ clean. */
464
+ export function formatNodeBuiltinLeak(findings) {
465
+ if (findings.length === 0)
466
+ return undefined;
467
+ const lines = findings.map((f) => f.chain.length > 1
468
+ ? ` - ${f.builtin} reached the client bundle via ${f.chain.join(" → ")} (chunk: ${f.chunk})`
469
+ : ` - ${f.builtin} reached the client bundle via ${f.chunk}`);
470
+ return (`[nifra/web] Node built-in(s) in the client bundle — move them behind a server-only path ` +
471
+ `(a loader/action runs on the server; import the \`node:\` module there, not at a route's ` +
472
+ `top level):\n${lines.join("\n")}`);
473
+ }
474
+ /** The build-failing message for `server-only`-marked modules that reached the client. `undefined` ⇒ clean. */
475
+ export function formatServerOnlyLeak(findings) {
476
+ if (findings.length === 0)
477
+ return undefined;
478
+ const lines = findings.map((f) => f.chain.length > 1
479
+ ? ` - server-only module reached the client bundle via ${f.chain.join(" → ")} (chunk: ${f.chunk})`
480
+ : ` - server-only module reached the client bundle via ${f.chunk}`);
481
+ return (`[nifra/web] server-only module(s) in the client bundle — a module marked ` +
482
+ `\`import "${SERVER_ONLY_MARKER}"\` reached the browser. Move it behind a server-only path ` +
483
+ `(reach it via a loader/action, or rename it \`*.server.ts\`), so its server logic never ships ` +
484
+ `to the client:\n${lines.join("\n")}`);
485
+ }
304
486
  const basename = (path) => path.slice(path.lastIndexOf("/") + 1);
305
487
  /** `[name]` Bun derives for an entrypoint: basename without extension (`users/[id].tsx` → `[id]`). */
306
488
  const entryName = (file) => {
@@ -572,33 +754,19 @@ export async function buildClient(options) {
572
754
  // per-output `inputs`), so it can't false-positive on a `"node:..."` string literal and survives
573
755
  // minification. Only the client build runs this; the server build's `node:` imports are legitimate.
574
756
  const clientMeta = result.metafile;
575
- const nodeBuiltins = detectNodeBuiltinsInClient(clientMeta);
576
- if (nodeBuiltins.length > 0) {
577
- // Name the offending builtin AND the import chain that pulled it in (entry → … → builtin), so the
578
- // dev sees the exact path from a route file to the leak instead of just the emitted chunk name.
579
- // The chain always starts at a user entry; fall back to the chunk name if it somehow came back empty.
580
- const lines = nodeBuiltins.map((f) => f.chain.length > 1
581
- ? ` - ${f.builtin} reached the client bundle via ${f.chain.join(" → ")} (chunk: ${f.chunk})`
582
- : ` - ${f.builtin} reached the client bundle via ${f.chunk}`);
583
- throw new Error(`[nifra/web] Node built-in(s) in the client bundle — move them behind a server-only path ` +
584
- `(a loader/action runs on the server; import the \`node:\` module there, not at a route's ` +
585
- `top level):\n${lines.join("\n")}`);
586
- }
587
- // §3.3/§5.1: a module that opted into the `server-only` marker (`import "@nifrajs/web/server-only"`)
588
- // yet reached a CLIENT chunk — fail the build with the same chain-bearing, "reached the client bundle
589
- // via" message shape as the node-builtin guard above. This catches pure-server logic (a secret, a
590
- // server-only API call) that carries no `node:` import and isn't named `*.server`, so neither of the
591
- // other two guards fires. Graph-based (the same metafile), so it survives minification.
592
- const serverOnly = detectServerOnlyInClient(clientMeta);
593
- if (serverOnly.length > 0) {
594
- const lines = serverOnly.map((f) => f.chain.length > 1
595
- ? ` - server-only module reached the client bundle via ${f.chain.join(" → ")} (chunk: ${f.chunk})`
596
- : ` - server-only module reached the client bundle via ${f.chunk}`);
597
- throw new Error(`[nifra/web] server-only module(s) in the client bundle — a module marked ` +
598
- `\`import "${SERVER_ONLY_MARKER}"\` reached the browser. Move it behind a server-only path ` +
599
- `(reach it via a loader/action, or rename it \`*.server.ts\`), so its server logic never ships ` +
600
- `to the client:\n${lines.join("\n")}`);
601
- }
757
+ const clientGraph = fromBunMetafile(clientMeta);
758
+ // #4: a `node:` builtin (e.g. `node:crypto`) pulled into a CLIENT chunk builds fine (Bun substitutes a
759
+ // browser polyfill) but breaks/leaks at runtime. Fail with the chain (entry → … → builtin), through the
760
+ // SHARED formatter so the Vite pipeline's identical guard reads byte-for-byte the same.
761
+ const nodeBuiltinLeak = formatNodeBuiltinLeak(detectNodeBuiltinsInClient(clientGraph));
762
+ if (nodeBuiltinLeak !== undefined)
763
+ throw new Error(nodeBuiltinLeak);
764
+ // §3.3/§5.1: a module that opted into the `server-only` marker yet reached a CLIENT chunk — catches
765
+ // pure-server logic (a secret, a server-only API call) carrying no `node:` import and not named
766
+ // `*.server`, so neither other guard fires. Same shared formatter as above.
767
+ const serverOnlyLeak = formatServerOnlyLeak(detectServerOnlyInClient(clientGraph));
768
+ if (serverOnlyLeak !== undefined)
769
+ throw new Error(serverOnlyLeak);
602
770
  // Rename any chunk whose basename isn't URL-safe (dynamic-route files become `[slug]-hash.js`) and
603
771
  // rewrite the references — otherwise the lazy import 404s and the route silently never hydrates.
604
772
  const renamed = sanitizeOutputNames(result.outputs);
@@ -673,10 +841,15 @@ export async function buildClient(options) {
673
841
  if (routeManifest.notFound)
674
842
  routeStyles._404 = stylesFor([routeManifest.notFound.file]);
675
843
  }
844
+ // Copy `public/` into the output next to the hashed assets. A missing directory is normal (most
845
+ // apps have none) and must not fail the build.
846
+ const publicDir = options.publicDir === false ? undefined : (options.publicDir ?? "public");
847
+ const publicFiles = publicDir !== undefined && existsSync(publicDir) ? await copyPublicDir(publicDir, outDir) : [];
676
848
  const manifest = {
677
849
  entry: toUrl(bootstrap.path),
678
850
  assets: result.outputs.map((o) => toUrl(o.path)),
679
851
  routes,
852
+ ...(publicFiles.length > 0 ? { publicFiles } : {}),
680
853
  ...(css.length > 0 ? { css } : {}),
681
854
  ...(Object.keys(routeStyles).length > 0 ? { routeStyles } : {}),
682
855
  };
@@ -867,6 +1040,7 @@ export async function buildServer(options) {
867
1040
  // of bundling, layered after any caller `define` so it can't be overridden). Unbundled Bun runtimes
868
1041
  // (nifra dev/start, nifra_render) never define it, so they still re-root — the dev dual-install fix.
869
1042
  "process.env.NIFRA_SSR_BUNDLED": '"1"',
1043
+ "globalThis.__NIFRA_EDGE_RUNTIME__": edge ? "true" : "false",
870
1044
  },
871
1045
  minify: options.minify ?? true,
872
1046
  // Lazy → one chunk per route (loaded on first request); eager → a single self-contained file.
@@ -915,7 +1089,7 @@ export function isBuildTarget(value) {
915
1089
  * string out) so the generation is unit-testable without a real build.
916
1090
  */
917
1091
  export function generateServerEntry(options) {
918
- const { target, adapterImport, backendImport, title = "nifra" } = options;
1092
+ const { target, adapterImport, backendImport, title = "nifra", publicFiles = [] } = options;
919
1093
  if (target === "static") {
920
1094
  throw new Error("[nifra/web] generateServerEntry: `static` has no server entry (SSG only)");
921
1095
  }
@@ -935,9 +1109,14 @@ export function generateServerEntry(options) {
935
1109
  lines.push('import { serve } from "@nifrajs/node"');
936
1110
  lines.push("", "const app = createWebApp({", " adapter,", " manifest,", " clientEntry,", " styles,", " routeStyles,", ...(backendImport !== undefined ? [" api: inProcessClient(backend),"] : []), ` title: ${JSON.stringify(title)},`, "})", "");
937
1111
  if (target === "cf-pages") {
938
- // Cloudflare Pages advanced mode: `_routes.json` serves /assets/* from the CDN; everything else
939
- // falls through to this fetch handler (SSR). The default export is the handler object.
940
- lines.push("export default toFetchHandler(app)");
1112
+ // Cloudflare Pages advanced mode: `_routes.json` keeps static paths off the worker entirely, and
1113
+ // everything else falls through to this handler (SSR).
1114
+ //
1115
+ // That exclude list cannot always name every public file - Cloudflare caps `_routes.json` at 100
1116
+ // rules - so a static request CAN arrive here. Serve it from the ASSETS binding instead of letting
1117
+ // the router 404 it, which makes correctness independent of how much of the list fit: the exclude
1118
+ // list decides only how many static requests skip the worker, never which files exist.
1119
+ lines.push(`const PUBLIC_FILES = new Set(${JSON.stringify(publicFiles)})`, "const handler = toFetchHandler(app)", "type Assets = { readonly ASSETS?: { fetch(request: Request): Promise<Response> } }", "export default {", " fetch(request: Request, env: Assets, ctx: ExecutionContext) {", " const { pathname } = new URL(request.url)", " // Only the build's own outputs: the hashed bundle prefix and the files it copied.", ' if (env?.ASSETS !== undefined && (pathname.startsWith("/assets/") || PUBLIC_FILES.has(pathname))) {', " return env.ASSETS.fetch(request)", " }", " return handler.fetch(request, env as never, ctx)", " },", "}");
941
1120
  return `${lines.join("\n")}\n`;
942
1121
  }
943
1122
  if (target === "vercel") {
@@ -945,20 +1124,20 @@ export function generateServerEntry(options) {
945
1124
  return `${lines.join("\n")}\n`;
946
1125
  }
947
1126
  // bun / node / deno self-host AND serve the client bundle from disk (it sits next to this entry).
948
- lines.push("// The client bundle lives next to this entry; serve /assets/* from disk, SSR everything else.", 'const ASSETS = new URL("./assets/", import.meta.url)', 'const TYPES = { js: "text/javascript", css: "text/css", map: "application/json" }');
1127
+ lines.push("// The client bundle and public files live next to this entry; serve only manifest-approved paths.", 'const STATIC_ROOT = new URL("./", import.meta.url)', `const PUBLIC_FILES = new Set(${JSON.stringify(publicFiles)})`, "const staticPath = (pathname: string): string | undefined => {", " if (PUBLIC_FILES.has(pathname)) return '.' + pathname", ' if (!pathname.startsWith("/assets/")) return undefined', ' const segments = pathname.slice(1).split("/")', " // `.` and `..` match the character class, and the path is joined onto a base URL that resolves", " // dot segments - so they are rejected explicitly rather than relying on the request URL having", " // been normalized upstream.", " const safe = (segment: string): boolean =>", ' segment !== "." && segment !== ".." && /^[A-Za-z0-9._-]+$/.test(segment)', " return segments.every(safe) ? '.' + pathname : undefined", "}", 'const TYPES = { js: "text/javascript", css: "text/css", map: "application/json" }');
949
1128
  if (target === "bun") {
950
- lines.push("const server = Bun.serve({", " port: Number(Bun.env.PORT ?? 3000),", " async fetch(req) {", " const { pathname } = new URL(req.url)", ' if (pathname.startsWith("/assets/")) {', ' const name = pathname.slice("/assets/".length)', ' if (!/^[A-Za-z0-9._-]+$/.test(name)) return new Response("bad request", { status: 400 })', " const file = Bun.file(new URL(name, ASSETS))", ' if (!(await file.exists())) return new Response("not found", { status: 404 })', ' const ext = name.slice(name.lastIndexOf(".") + 1)', ' return new Response(file, { headers: { "content-type": TYPES[ext] ?? "application/octet-stream" } })', " }", " return app.fetch(req)", " },", "})",
1129
+ lines.push("const server = Bun.serve({", " port: Number(Bun.env.PORT ?? 3000),", " async fetch(req) {", " const { pathname } = new URL(req.url)", " const filePath = staticPath(pathname)", " if (filePath !== undefined) {", " const file = Bun.file(new URL(filePath, STATIC_ROOT))", ' if (!(await file.exists())) return new Response("not found", { status: 404 })', ' const ext = pathname.slice(pathname.lastIndexOf(".") + 1)', ' return new Response(file, { headers: { "content-type": TYPES[ext] ?? "application/octet-stream" } })', " }", " return app.fetch(req)", " },", "})",
951
1130
  // The `${...}` here is literal OUTPUT (a template in the GENERATED file), not a template in this
952
1131
  // source — split so biome's noTemplateCurlyInString doesn't flag it; the emitted line is unchanged.
953
1132
  `console.log(\`nifra (Bun) → http://localhost:$${"{server.port}"}\`)`);
954
1133
  return `${lines.join("\n")}\n`;
955
1134
  }
956
1135
  if (target === "node") {
957
- lines.push('import { readFile } from "node:fs/promises"', "await serve(", " {", " async fetch(req) {", " const { pathname } = new URL(req.url)", ' if (pathname.startsWith("/assets/")) {', ' const name = pathname.slice("/assets/".length)', ' if (!/^[A-Za-z0-9._-]+$/.test(name)) return new Response("bad request", { status: 400 })', " try {", " const body = await readFile(new URL(name, ASSETS))", ' const ext = name.slice(name.lastIndexOf(".") + 1)', ' return new Response(body, { headers: { "content-type": TYPES[ext] ?? "application/octet-stream" } })', " } catch {", ' return new Response("not found", { status: 404 })', " }", " }", " return app.fetch(req)", " },", " },", " { port: Number(process.env.PORT ?? 3000) },", ")");
1136
+ lines.push('import { readFile } from "node:fs/promises"', "await serve(", " {", " async fetch(req) {", " const { pathname } = new URL(req.url)", " const filePath = staticPath(pathname)", " if (filePath !== undefined) {", " try {", " const body = await readFile(new URL(filePath, STATIC_ROOT))", ' const ext = pathname.slice(pathname.lastIndexOf(".") + 1)', ' return new Response(body, { headers: { "content-type": TYPES[ext] ?? "application/octet-stream" } })', " } catch {", ' return new Response("not found", { status: 404 })', " }", " }", " return app.fetch(req)", " },", " },", " { port: Number(process.env.PORT ?? 3000) },", ")");
958
1137
  return `${lines.join("\n")}\n`;
959
1138
  }
960
1139
  // deno
961
- lines.push("const handler = toFetchHandler(app)", "// @ts-ignore — Deno global is present on the Deno runtime this output targets.", 'Deno.serve({ port: Number(Deno.env.get("PORT") ?? "3000") }, async (req) => {', " const { pathname } = new URL(req.url)", ' if (pathname.startsWith("/assets/")) {', ' const name = pathname.slice("/assets/".length)', ' if (!/^[A-Za-z0-9._-]+$/.test(name)) return new Response("bad request", { status: 400 })', " try {", " // @ts-ignore — Deno.readFile is present on the Deno runtime.", " const body = await Deno.readFile(new URL(name, ASSETS))", ' const ext = name.slice(name.lastIndexOf(".") + 1)', ' return new Response(body, { headers: { "content-type": TYPES[ext] ?? "application/octet-stream" } })', " } catch {", ' return new Response("not found", { status: 404 })', " }", " }", " return handler.fetch(req)", "})");
1140
+ lines.push("const handler = toFetchHandler(app)", "// @ts-ignore — Deno global is present on the Deno runtime this output targets.", 'Deno.serve({ port: Number(Deno.env.get("PORT") ?? "3000") }, async (req) => {', " const { pathname } = new URL(req.url)", " const filePath = staticPath(pathname)", " if (filePath !== undefined) {", " try {", " // @ts-ignore — Deno.readFile is present on the Deno runtime.", " const body = await Deno.readFile(new URL(filePath, STATIC_ROOT))", ' const ext = pathname.slice(pathname.lastIndexOf(".") + 1)', ' return new Response(body, { headers: { "content-type": TYPES[ext] ?? "application/octet-stream" } })', " } catch {", ' return new Response("not found", { status: 404 })', " }", " }", " return handler.fetch(req)", "})");
962
1141
  return `${lines.join("\n")}\n`;
963
1142
  }
964
1143
  /** The Bun.build `target` each deploy target compiles its server with (mirrors `buildServer`'s docs). */
@@ -984,22 +1163,39 @@ async function measureOutputs(paths) {
984
1163
  }
985
1164
  return chunks;
986
1165
  }
1166
+ /** The default (Bun) strategy — `buildClient`/`buildServer` from this module. */
1167
+ export const bunBundler = {
1168
+ buildClient: (input) => buildClient({
1169
+ routesDir: input.routesDir,
1170
+ outDir: input.outDir,
1171
+ clientModule: input.clientModule,
1172
+ ...(input.plugins ? { plugins: input.plugins } : {}),
1173
+ ...(input.conditions ? { conditions: input.conditions } : {}),
1174
+ ...(input.define ? { define: input.define } : {}),
1175
+ ...(input.publicDir !== undefined ? { publicDir: input.publicDir } : {}),
1176
+ ...(input.publicEnvPrefix !== undefined ? { publicEnvPrefix: input.publicEnvPrefix } : {}),
1177
+ }),
1178
+ buildServer: (input) => buildServer({
1179
+ routesDir: input.routesDir,
1180
+ serverEntry: input.serverEntry,
1181
+ outDir: input.outDir,
1182
+ clientEntry: input.clientEntry,
1183
+ target: input.target,
1184
+ ...(input.plugins ? { plugins: input.plugins } : {}),
1185
+ ...(input.define ? { define: input.define } : {}),
1186
+ }),
1187
+ };
1188
+ /** Build a full deploy dir for `target` using the default Bun bundler. See {@link buildTargetWith}. */
1189
+ export async function buildTarget(target, options) {
1190
+ return buildTargetWith(target, options, bunBundler);
1191
+ }
987
1192
  /**
988
- * Build a full deploy directory for `target` from a file-routed nifra app. Emits the client bundle to
989
- * `<outDir>/assets/*`, then per target:
990
- * - `static`: prerenders opted-in routes (`prerenderRoutes`) to `<outDir>/<path>/index.html` (+
991
- * `_data.json`); needs `prerenderApp`. No server.
992
- * - `cf-pages`: a `_worker.js` (edge bundle) + a `_routes.json` excluding /assets/* from the worker.
993
- * - `vercel`: a `.vercel/output`-shaped function isn't emitted here — `vercel` emits the bundled edge
994
- * entry as `<outDir>/index.js` (the CLI's docs point at `vercel`'s Build Output wrapper). [see note]
995
- * - `deno`/`node`/`bun`: the self-hosting server bundle (`server.js`) next to the assets.
996
- * The server entry is GENERATED (`generateServerEntry`) and bundled (`buildServer`); the app supplies
997
- * only adapter/backend/routes. Returns the manifest + a size report. Throws on any build failure.
998
- *
999
- * Note: the heavier platform wrappers (`.vercel/output` v3 layout, wrangler ISR `find_additional_modules`)
1000
- * remain app-owned scripts; this command targets the common single-bundle deploys. See the CLI docs.
1193
+ * The bundler-agnostic deploy orchestrator: everything `buildTarget` does EXCEPT the two bundling steps,
1194
+ * which come from `bundler`. `buildTarget` passes {@link bunBundler}; `buildTargetVite`
1195
+ * (`@nifrajs/web/build-vite`) passes the Vite strategy. One orchestrator, so the deploy-dir shape,
1196
+ * server-entry codegen, prerender and size report are identical across pipelines.
1001
1197
  */
1002
- export async function buildTarget(target, options) {
1198
+ export async function buildTargetWith(target, options, bundler) {
1003
1199
  const { routesDir, outDir, workDir } = options;
1004
1200
  const { rmSync } = await import("node:fs");
1005
1201
  rmSync(outDir, { recursive: true, force: true });
@@ -1008,14 +1204,25 @@ export async function buildTarget(target, options) {
1008
1204
  mkdirSync(assetsDir, { recursive: true });
1009
1205
  mkdirSync(workDir, { recursive: true });
1010
1206
  // (1) Client bundle → <outDir>/assets/* (every target ships the same hashed client bundle).
1011
- const client = await buildClient({
1207
+ let client = await bundler.buildClient({
1012
1208
  routesDir,
1013
1209
  outDir: assetsDir,
1014
1210
  clientModule: options.clientModule,
1015
1211
  ...(options.clientPlugins ? { plugins: options.clientPlugins } : {}),
1016
1212
  ...(options.conditions ? { conditions: options.conditions } : {}),
1017
1213
  define: { "process.env.NODE_ENV": '"production"', ...(options.define ?? {}) },
1214
+ publicDir: false,
1215
+ ...(options.publicEnvPrefix !== undefined ? { publicEnvPrefix: options.publicEnvPrefix } : {}),
1216
+ root: resolvePath(dirname(routesDir)),
1018
1217
  });
1218
+ const publicDir = options.publicDir === false
1219
+ ? undefined
1220
+ : resolvePath(options.publicDir ?? join(dirname(routesDir), "public"));
1221
+ const publicFiles = publicDir !== undefined && existsSync(publicDir) ? await copyPublicDir(publicDir, outDir) : [];
1222
+ if (publicFiles.length > 0) {
1223
+ client = { ...client, publicFiles };
1224
+ writeFileSync(`${assetsDir}/manifest.json`, JSON.stringify(client, null, 2));
1225
+ }
1019
1226
  if (target === "static") {
1020
1227
  if (options.prerenderApp === undefined) {
1021
1228
  throw new Error("[nifra/web] buildTarget(static) requires `prerenderApp` (a factory `(client) => createWebApp`)");
@@ -1055,9 +1262,10 @@ export async function buildTarget(target, options) {
1055
1262
  adapterImport: options.adapterImport,
1056
1263
  ...(options.backendImport !== undefined ? { backendImport: options.backendImport } : {}),
1057
1264
  ...(options.title !== undefined ? { title: options.title } : {}),
1265
+ ...(publicFiles.length > 0 ? { publicFiles } : {}),
1058
1266
  }));
1059
1267
  const serverTarget = SERVER_BUILD_TARGET[target];
1060
- const { worker } = await buildServer({
1268
+ const { worker } = await bundler.buildServer({
1061
1269
  routesDir,
1062
1270
  serverEntry: serverEntryPath,
1063
1271
  outDir: `${workDir}/server`,
@@ -1065,13 +1273,24 @@ export async function buildTarget(target, options) {
1065
1273
  target: serverTarget,
1066
1274
  ...(options.serverPlugins ? { plugins: options.serverPlugins } : {}),
1067
1275
  define: { "process.env.NODE_ENV": '"production"', ...(options.define ?? {}) },
1276
+ root: resolvePath(dirname(routesDir)),
1068
1277
  });
1069
1278
  // (3) Assemble the deploy dir for the target.
1070
1279
  const { cpSync } = await import("node:fs");
1071
1280
  let run;
1072
1281
  if (target === "cf-pages") {
1073
1282
  cpSync(worker, `${outDir}/_worker.js`);
1074
- writeFileSync(`${outDir}/_routes.json`, `${JSON.stringify({ version: 1, include: ["/*"], exclude: ["/assets/*"] }, null, 2)}\n`);
1283
+ // The app's real patterns, so a directory is only collapsed into a glob once the route table
1284
+ // proves nothing can be served beneath it.
1285
+ const rules = cloudflareRouteRules(publicFiles, discoverRoutes(routesDir).routes.map((route) => route.pattern));
1286
+ writeFileSync(`${outDir}/_routes.json`, `${JSON.stringify({ version: 1, include: rules.include, exclude: rules.exclude }, null, 2)}\n`);
1287
+ // Never cap silently: an omitted file still serves (the worker's ASSETS fallback), but it costs a
1288
+ // worker invocation, and that trade should be visible rather than inferred from a latency graph.
1289
+ if (rules.omitted.length > 0) {
1290
+ console.log(`[nifra/web] _routes.json holds ${rules.exclude.length} of ${publicFiles.length + 1} static rules ` +
1291
+ `(Cloudflare allows ${CF_MAX_ROUTE_RULES}). The other ${rules.omitted.length} file(s) are served by the ` +
1292
+ "worker via ASSETS instead of the CDN directly - correct, just one invocation each.");
1293
+ }
1075
1294
  run = `Cloudflare Pages → ${outDir} (deploy: wrangler pages deploy ${basename(outDir)})`;
1076
1295
  }
1077
1296
  else if (target === "vercel") {