@nifrajs/web 2.14.1 → 3.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 (145) hide show
  1. package/dist/boundary.d.ts +158 -0
  2. package/dist/boundary.d.ts.map +1 -0
  3. package/dist/boundary.js +237 -0
  4. package/dist/boundary.js.map +1 -0
  5. package/dist/build-plan.d.ts +228 -0
  6. package/dist/build-plan.d.ts.map +1 -0
  7. package/dist/build-plan.js +514 -0
  8. package/dist/build-plan.js.map +1 -0
  9. package/dist/build-vite.d.ts +2 -1
  10. package/dist/build-vite.d.ts.map +1 -1
  11. package/dist/build-vite.js +5 -3
  12. package/dist/build-vite.js.map +1 -1
  13. package/dist/build.d.ts +2 -197
  14. package/dist/build.d.ts.map +1 -1
  15. package/dist/build.js +92 -515
  16. package/dist/build.js.map +1 -1
  17. package/dist/chunk-names.d.ts.map +1 -1
  18. package/dist/chunk-names.js +3 -2
  19. package/dist/chunk-names.js.map +1 -1
  20. package/dist/dev-ssr-graph.d.ts.map +1 -1
  21. package/dist/dev-ssr-graph.js +63 -28
  22. package/dist/dev-ssr-graph.js.map +1 -1
  23. package/dist/diagnostic.d.ts.map +1 -1
  24. package/dist/diagnostic.js +9 -2
  25. package/dist/diagnostic.js.map +1 -1
  26. package/dist/fonts-google.d.ts.map +1 -1
  27. package/dist/fonts-google.js +3 -1
  28. package/dist/fonts-google.js.map +1 -1
  29. package/dist/fs.d.ts.map +1 -1
  30. package/dist/fs.js +2 -1
  31. package/dist/fs.js.map +1 -1
  32. package/dist/index.d.ts +16 -589
  33. package/dist/index.d.ts.map +1 -1
  34. package/dist/index.js +23 -1824
  35. package/dist/index.js.map +1 -1
  36. package/dist/internal/codegen.d.ts +68 -0
  37. package/dist/internal/codegen.d.ts.map +1 -0
  38. package/dist/internal/codegen.js +317 -0
  39. package/dist/internal/codegen.js.map +1 -0
  40. package/dist/internal/fetcher-runtime.d.ts +19 -0
  41. package/dist/internal/fetcher-runtime.d.ts.map +1 -0
  42. package/dist/internal/fetcher-runtime.js +26 -0
  43. package/dist/internal/fetcher-runtime.js.map +1 -0
  44. package/dist/internal/head-merge.d.ts +2 -0
  45. package/dist/internal/head-merge.d.ts.map +1 -1
  46. package/dist/internal/head-merge.js +17 -1
  47. package/dist/internal/head-merge.js.map +1 -1
  48. package/dist/internal/identity-policy.d.ts +51 -0
  49. package/dist/internal/identity-policy.d.ts.map +1 -0
  50. package/dist/internal/identity-policy.js +67 -0
  51. package/dist/internal/identity-policy.js.map +1 -0
  52. package/dist/internal/parity.d.ts +104 -8
  53. package/dist/internal/parity.d.ts.map +1 -1
  54. package/dist/internal/parity.js +472 -56
  55. package/dist/internal/parity.js.map +1 -1
  56. package/dist/internal/query-runtime.d.ts +8 -0
  57. package/dist/internal/query-runtime.d.ts.map +1 -0
  58. package/dist/internal/query-runtime.js +29 -0
  59. package/dist/internal/query-runtime.js.map +1 -0
  60. package/dist/internal/render-document.d.ts +372 -0
  61. package/dist/internal/render-document.d.ts.map +1 -0
  62. package/dist/internal/render-document.js +744 -0
  63. package/dist/internal/render-document.js.map +1 -0
  64. package/dist/internal/runtime-contract.d.ts +48 -0
  65. package/dist/internal/runtime-contract.d.ts.map +1 -0
  66. package/dist/internal/runtime-contract.js +27 -0
  67. package/dist/internal/runtime-contract.js.map +1 -0
  68. package/dist/internal/web-app.d.ts +171 -0
  69. package/dist/internal/web-app.d.ts.map +1 -0
  70. package/dist/internal/web-app.js +844 -0
  71. package/dist/internal/web-app.js.map +1 -0
  72. package/dist/islands.d.ts +50 -2
  73. package/dist/islands.d.ts.map +1 -1
  74. package/dist/islands.js +96 -36
  75. package/dist/islands.js.map +1 -1
  76. package/dist/manifest.d.ts +41 -6
  77. package/dist/manifest.d.ts.map +1 -1
  78. package/dist/manifest.js.map +1 -1
  79. package/dist/module-graph.d.ts.map +1 -1
  80. package/dist/module-graph.js +22 -9
  81. package/dist/module-graph.js.map +1 -1
  82. package/dist/nano.d.ts +125 -0
  83. package/dist/nano.d.ts.map +1 -0
  84. package/dist/nano.js +190 -0
  85. package/dist/nano.js.map +1 -0
  86. package/dist/plugins/css-modules.js +2 -2
  87. package/dist/plugins/css-modules.js.map +1 -1
  88. package/dist/plugins/kit.d.ts +12 -0
  89. package/dist/plugins/kit.d.ts.map +1 -1
  90. package/dist/plugins/kit.js +62 -12
  91. package/dist/plugins/kit.js.map +1 -1
  92. package/dist/plugins/postcss.js +2 -2
  93. package/dist/plugins/postcss.js.map +1 -1
  94. package/dist/plugins/scss.js +2 -2
  95. package/dist/plugins/scss.js.map +1 -1
  96. package/dist/plugins/stylex.d.ts +70 -0
  97. package/dist/plugins/stylex.d.ts.map +1 -0
  98. package/dist/plugins/stylex.js +166 -0
  99. package/dist/plugins/stylex.js.map +1 -0
  100. package/dist/plugins/svg.js +2 -2
  101. package/dist/plugins/svg.js.map +1 -1
  102. package/dist/render-seam.d.ts +5 -0
  103. package/dist/render-seam.d.ts.map +1 -1
  104. package/dist/render-seam.js +10 -0
  105. package/dist/render-seam.js.map +1 -1
  106. package/dist/router.d.ts +11 -1
  107. package/dist/router.d.ts.map +1 -1
  108. package/dist/router.js +254 -47
  109. package/dist/router.js.map +1 -1
  110. package/dist/vite.d.ts.map +1 -1
  111. package/dist/vite.js +34 -16
  112. package/dist/vite.js.map +1 -1
  113. package/package.json +49 -2
  114. package/src/boundary.ts +407 -0
  115. package/src/build-plan.ts +741 -0
  116. package/src/build-vite.ts +6 -7
  117. package/src/build.ts +120 -730
  118. package/src/chunk-names.ts +3 -2
  119. package/src/dev-ssr-graph.ts +63 -28
  120. package/src/diagnostic.ts +14 -2
  121. package/src/fonts-google.ts +3 -1
  122. package/src/fs.ts +2 -1
  123. package/src/index.ts +107 -2553
  124. package/src/internal/codegen.ts +400 -0
  125. package/src/internal/fetcher-runtime.ts +36 -0
  126. package/src/internal/head-merge.ts +19 -1
  127. package/src/internal/identity-policy.ts +94 -0
  128. package/src/internal/parity.ts +603 -71
  129. package/src/internal/query-runtime.ts +31 -0
  130. package/src/internal/render-document.ts +1116 -0
  131. package/src/internal/runtime-contract.ts +54 -0
  132. package/src/internal/web-app.ts +1114 -0
  133. package/src/islands.ts +106 -40
  134. package/src/manifest.ts +50 -3
  135. package/src/module-graph.ts +23 -9
  136. package/src/nano.ts +246 -0
  137. package/src/plugins/css-modules.ts +2 -2
  138. package/src/plugins/kit.ts +65 -12
  139. package/src/plugins/postcss.ts +2 -2
  140. package/src/plugins/scss.ts +2 -2
  141. package/src/plugins/stylex.ts +298 -0
  142. package/src/plugins/svg.ts +2 -2
  143. package/src/render-seam.ts +6 -0
  144. package/src/router.ts +293 -51
  145. package/src/vite.ts +41 -15
package/dist/build.js CHANGED
@@ -7,16 +7,31 @@
7
7
  */
8
8
  import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
9
9
  import { cp, lstat, mkdir, realpath } from "node:fs/promises";
10
- import { dirname, join, relative, resolve as resolvePath, sep } from "node:path";
10
+ import { dirname, join, basename as pathBasename, relative, resolve as resolvePath, sep, } from "node:path";
11
+ import { singleCopyPlugin as declaredSingleCopyPlugin, readSingleCopyDeclaration, } from "@nifrajs/core/single-copy";
11
12
  import { Glob } from "bun";
13
+ import { aggregateSizeReport, detectNodeBuiltinsInClient, detectServerOnlyInClient, formatNodeBuiltinLeak, formatServerOnlyLeak, parseManifestClientEntry, parseManifestRouteStyles, parseManifestStyles, planBuildTarget, } from "./build-plan.js";
12
14
  import { sanitizeOutputNames } from "./chunk-names.js";
13
15
  import { discoverRoutes } from "./fs.js";
14
16
  import { generateClientEntry, generateServerManifest } from "./index.js";
17
+ import { dedupePolicyFor } from "./internal/identity-policy.js";
15
18
  import { assertDevelopmentProductionParity, assertIdentityParity, collectDevelopmentParityInput, } from "./internal/parity.js";
16
19
  import { generateServerFnStub, SERVER_FN_MODULE, SERVER_ONLY_MODULE, SERVER_ONLY_REPLACEMENT, serverFnNamespace, } from "./internal/server-boundary.js";
17
20
  // `buildTarget(static)` drives the SSG prerender engine directly (it's also re-exported below).
18
21
  import { fromBunMetafile } from "./module-graph.js";
19
22
  import { prerenderRoutes } from "./prerender.js";
23
+ export * from "./build-plan.js";
24
+ const basename = (path) => pathBasename(path);
25
+ // The lazy shape declares `const loaders`, the eager shape `const modules`. Anchored at a line start,
26
+ // NOT `.includes("const loaders =")`: the real emit is `const loaders: Record<...> = {`, whose `:` type
27
+ // annotation sits between the name and the `=`, so the ` =` substring never matched and every lazy
28
+ // manifest was re-synced as eager (silently converting `import()` splitting into a single eager bundle).
29
+ const MANIFEST_IS_LAZY = /^const loaders\b/m;
30
+ const entryName = (file) => {
31
+ const base = basename(file);
32
+ const dot = base.lastIndexOf(".");
33
+ return dot === -1 ? base : base.slice(0, dot);
34
+ };
20
35
  // Build-time SSG: prerender opted-in static + dynamic routes to `index.html` (+ static `_data.json`),
21
36
  // run after `buildClient`.
22
37
  export { cloudflarePagesRoutes, dataFileFor, htmlFileFor, prerenderRoutes, } from "./prerender.js";
@@ -189,442 +204,6 @@ export async function copyPublicDir(from, to) {
189
204
  }
190
205
  return copied.sort();
191
206
  }
192
- /** The `node:` specifier of an import edge, or undefined if it isn't a Node built-in. Reads the
193
- * `original` (as-written) specifier first, falling back to the resolved `path` (an `external` node:
194
- * import keeps `node:` in its path). */
195
- const nodeBuiltinOf = (im) => {
196
- if (im.original?.startsWith("node:"))
197
- return im.original;
198
- if (im.path?.startsWith("node:"))
199
- return im.path;
200
- return undefined;
201
- };
202
- /**
203
- * BFS the metafile import graph for the SHORTEST user-module path that pulls `builtin` into the
204
- * bundle, returning it as display labels `[entryFile, …as-written specifiers…, builtin]`. The frontier
205
- * starts at every `entryInput` (the route/user entrypoints) so the reported chain begins where the dev
206
- * actually wrote `import` - the actionable root, not an arbitrary internal module. Traversal crosses
207
- * only NON-`node:` edges (so Bun's polyfill chain never extends the path) and stops at the first edge
208
- * whose target is the builtin. Returns `[builtin]` if no entry reaches it (defensive; a flagged builtin
209
- * is reachable by construction). Pure - operates on the graph, never the emitted text.
210
- */
211
- function shortestBuiltinChain(inputs, entryInputs, builtin) {
212
- // Each queue item carries the resolved-path node + the display chain to reach it (entry + the
213
- // as-written specifier of every hop crossed so far). `seen` dedupes nodes so the BFS is linear and
214
- // the first time we touch the builtin's importer is via a shortest path.
215
- const seen = new Set(entryInputs);
216
- let frontier = entryInputs.map((node) => ({
217
- node,
218
- chain: [node],
219
- }));
220
- while (frontier.length > 0) {
221
- const next = [];
222
- for (const { node, chain } of frontier) {
223
- for (const im of inputs[node]?.imports ?? []) {
224
- // The builtin reached via THIS edge → the chain ends here (append the builtin label). Match on
225
- // the same `node:` detection the finding used (`original` first, then resolved `path`).
226
- if (nodeBuiltinOf(im) === builtin)
227
- return [...chain, builtin];
228
- const target = im.path;
229
- // Only follow edges into user (non-`node:`) modules that exist in the input graph and haven't
230
- // been visited - so the polyfill subtree can't lengthen the path and there are no cycles.
231
- if (target === undefined ||
232
- target.startsWith("node:") ||
233
- inputs[target] === undefined ||
234
- seen.has(target)) {
235
- continue;
236
- }
237
- seen.add(target);
238
- // Display the hop by its as-written specifier (`../db/client.ts`, `postgres`), falling back to
239
- // the resolved path - that's what the dev recognizes in their source, not the resolved path.
240
- next.push({ node: target, chain: [...chain, im.original ?? target] });
241
- }
242
- }
243
- frontier = next;
244
- }
245
- return [builtin]; // unreachable from any entry - degrade to just the builtin (shouldn't happen)
246
- }
247
- /**
248
- * Scan a build's metafile for any `node:` builtin that a USER module pulled into a CLIENT output
249
- * chunk, returning a sorted, deduped list of {@link NodeBuiltinFinding}s. Three graph facts combine so
250
- * the report is precise AND actionable:
251
- * 1. **What the user wrote** - only builtins imported by a NON-`node:` input count, so Bun's own
252
- * polyfill chain (`node:crypto` → `node:buffer`/`node:stream`/…) doesn't bury the real cause.
253
- * 2. **Where it landed** - the chunk is read from the per-output `inputs`, so the error names the
254
- * emitted file to look at.
255
- * 3. **How it got there** - the shortest import chain from a user entry to the builtin
256
- * (`shortestBuiltinChain`), so the error points straight at the offending `import` line instead of
257
- * leaving the dev to grep the dependency tree (the DX gap this closes).
258
- * Graph-based (never the emitted text), so it survives minification and can't be fooled by a string
259
- * literal that merely contains `"node:crypto"`. Pure + exported for unit testing. Empty ⇒ clean.
260
- */
261
- export function detectNodeBuiltinsInClient(graph) {
262
- const inputs = graph.modules;
263
- // (1) The builtins a user (non-polyfill) module imports directly - the ones the author controls.
264
- const userImported = new Set();
265
- for (const [inputKey, input] of Object.entries(inputs)) {
266
- if (inputKey.startsWith("node:"))
267
- continue; // a polyfill importing another builtin - not the cause
268
- for (const im of input.imports ?? []) {
269
- const builtin = nodeBuiltinOf(im);
270
- if (builtin !== undefined)
271
- userImported.add(builtin);
272
- }
273
- }
274
- // The entry inputs (route/user entrypoints) - the chain BFS starts here so the reported path begins
275
- // at the file the dev wrote an `import` in. Each output's `entryPoint` is one such input.
276
- const entryInputs = [
277
- ...new Set(Object.values(graph.chunks)
278
- .map((o) => o.entryPoint)
279
- .filter((e) => e !== undefined && !e.startsWith("node:"))),
280
- ];
281
- // The chain per builtin is independent of the chunk, so compute it once per builtin (memoized).
282
- const chainCache = new Map();
283
- const chainFor = (builtin) => {
284
- const cached = chainCache.get(builtin);
285
- if (cached !== undefined)
286
- return cached;
287
- const chain = shortestBuiltinChain(inputs, entryInputs, builtin);
288
- chainCache.set(builtin, chain);
289
- return chain;
290
- };
291
- // (2) Locate which emitted chunk each user-imported builtin reached, via the per-output `inputs`.
292
- const findings = new Map();
293
- for (const [outPath, out] of Object.entries(graph.chunks)) {
294
- for (const inputKey of out.modules) {
295
- if (userImported.has(inputKey)) {
296
- const chunk = basename(outPath);
297
- findings.set(`${inputKey}\0${chunk}`, {
298
- builtin: inputKey,
299
- chunk,
300
- chain: chainFor(inputKey),
301
- });
302
- }
303
- }
304
- }
305
- return [...findings.values()].sort((a, b) => a.builtin === b.builtin ? a.chunk.localeCompare(b.chunk) : a.builtin.localeCompare(b.builtin));
306
- }
307
- // ---------------------------------------------------------------------------------------------------
308
- // `server-only` poison-import guard (§3.3/§5.1). The complement to the `.server` convention + the
309
- // node-builtin guard: a module of PURE server logic with NO `node:` import (a secret-bearing constant,
310
- // a server-only API call) that an author wants to FAIL LOUD if it reaches the client opts in with a
311
- // side-effect `import "@nifrajs/web/server-only"` (Next's `import "server-only"`). On the SERVER build
312
- // the marker is an empty no-op; the CLIENT build detects - via the SAME Bun metafile graph the
313
- // node-builtin guard walks - any module that imports the marker AND lands in a client chunk, and fails
314
- // the build with the import chain. Graph-based (never the emitted text), so it survives minification.
315
- // ---------------------------------------------------------------------------------------------------
316
- /** The marker specifier an author imports to opt a module into the client-leak guard. Matched on the
317
- * import edge's *as-written* `original` first (the robust signal: it's exactly what the author typed,
318
- * before Bun resolves it to `src/server-only.ts` / `dist/server-only.js`). */
319
- export const SERVER_ONLY_MARKER = "@nifrajs/web/server-only";
320
- /** True when an import edge is the `server-only` marker import. Reads the as-written `original` (the
321
- * specifier the author typed); falls back to the resolved `path`'s basename so a pre-resolved edge
322
- * (no `original`) - or a workspace-relative resolution - is still recognised. */
323
- const isServerOnlyMarkerImport = (im) => {
324
- if (im.original === SERVER_ONLY_MARKER)
325
- return true;
326
- // Defensive fallback: an edge that lost its `original` but resolved to the marker module file. The
327
- // marker is the only `server-only.{ts,js}` under `@nifrajs/web`, so the basename is unambiguous.
328
- const path = im.path;
329
- return path !== undefined && /(^|\/)server-only\.[cm]?[jt]s$/.test(path);
330
- };
331
- /**
332
- * BFS the metafile import graph for the SHORTEST user-module path that reaches a module which imports
333
- * the `server-only` marker, returning it as display labels `[entryFile, …as-written specifiers…,
334
- * "<module> (marked server-only)"]`. Mirrors {@link shortestBuiltinChain}: the frontier starts at
335
- * every `entryInput` so the chain begins at the file the dev wrote an `import` in; traversal crosses
336
- * only NON-`node:` user edges (no cycles via `seen`); it stops at the first node whose import set
337
- * contains the marker (the marked module - the actionable tail), labelling that node by the as-written
338
- * specifier the previous hop used to reach it. Pure - operates on the graph, never the emitted text.
339
- */
340
- function shortestServerOnlyChain(inputs, entryInputs, markedModule, resolveTarget) {
341
- // The label for the marked module's tail: its as-written specifier (filled when we cross the edge
342
- // that reaches it) suffixed with `(marked server-only)`; the entry case uses the entry key itself.
343
- const tail = (label) => `${label} (marked server-only)`;
344
- // An entry that is ITSELF the marked module - the chain is just that one node.
345
- if (entryInputs.includes(markedModule))
346
- return [tail(markedModule)];
347
- const seen = new Set(entryInputs);
348
- let frontier = entryInputs.map((node) => ({
349
- node,
350
- chain: [node],
351
- }));
352
- while (frontier.length > 0) {
353
- const next = [];
354
- for (const { node, chain } of frontier) {
355
- for (const im of inputs[node]?.imports ?? []) {
356
- // Resolve the edge's `path` to the matching INPUT-GRAPH KEY - in a real build the edge `path`
357
- // is absolute while the input keys are cwd-relative, so a raw equality/lookup would miss every
358
- // multi-hop user edge (and the chain would degrade to just the tail). The resolver maps both.
359
- const target = resolveTarget(im);
360
- if (target === undefined || target.startsWith("node:"))
361
- continue;
362
- // The marked module reached via THIS edge → the chain ends here (label it as the marked tail
363
- // using the as-written specifier the author wrote, falling back to the resolved key).
364
- if (target === markedModule)
365
- return [...chain, tail(im.original ?? target)];
366
- if (seen.has(target))
367
- continue;
368
- seen.add(target);
369
- next.push({ node: target, chain: [...chain, im.original ?? target] });
370
- }
371
- }
372
- frontier = next;
373
- }
374
- return [tail(markedModule)]; // unreachable from any entry (defensive; a flagged module is reachable)
375
- }
376
- /**
377
- * Build a resolver from an import EDGE to its INPUT-GRAPH KEY. Bun's metafile records edge `path`s as
378
- * ABSOLUTE paths but keys `inputs` by CWD-RELATIVE paths, so a raw `inputs[im.path]` lookup misses
379
- * every user edge in a real build. The resolver: (1) an exact key match (the synthetic-metafile / unit
380
- * case); else (2) the input key the absolute path ends with (`…/secrets.ts` → `packages/web/.../secrets.ts`).
381
- * The longest-suffix match is taken so a shorter key can't shadow a more specific one. Pure.
382
- */
383
- function inputKeyResolver(inputs) {
384
- const keys = Object.keys(inputs);
385
- return (im) => {
386
- const path = im.path;
387
- if (path === undefined)
388
- return undefined;
389
- if (inputs[path] !== undefined)
390
- return path; // exact (synthetic keys, or already-relative paths)
391
- let best;
392
- for (const key of keys) {
393
- // `/<key>` so a suffix match aligns on a path boundary (never a partial segment), and the longest
394
- // such key wins (the most specific file).
395
- if (path.endsWith(`/${key}`) && (best === undefined || key.length > best.length))
396
- best = key;
397
- }
398
- return best;
399
- };
400
- }
401
- /**
402
- * Scan a build's metafile for any module that opts into the `server-only` marker (a side-effect
403
- * `import "@nifrajs/web/server-only"`) yet landed in a CLIENT output chunk, returning a sorted, deduped
404
- * list of {@link ServerOnlyFinding}s. Mirrors {@link detectNodeBuiltinsInClient}: it reads the SAME
405
- * graph facts - which inputs import the marker (the "marked" modules), which chunk each landed in (the
406
- * per-output `inputs`), and the shortest import chain from a user entry to it. The marker module ITSELF
407
- * (which imports nothing) is excluded - only the modules that *opt in* are flagged. Pure + exported for
408
- * unit testing. Empty ⇒ clean.
409
- */
410
- export function detectServerOnlyInClient(graph) {
411
- const inputs = graph.modules;
412
- // (1) The modules that import the marker - the ones the author opted into the guard. The marker
413
- // module itself is skipped: it's the import TARGET, not an opt-in (it imports nothing of its own).
414
- const marked = new Set();
415
- for (const [inputKey, input] of Object.entries(inputs)) {
416
- if (isServerOnlyMarkerModule(inputKey))
417
- continue;
418
- if ((input.imports ?? []).some(isServerOnlyMarkerImport))
419
- marked.add(inputKey);
420
- }
421
- if (marked.size === 0)
422
- return [];
423
- // The entry inputs - the chain BFS starts here, so the reported path begins at the file the dev
424
- // wrote an `import` in. Same derivation as the node-builtin guard.
425
- const entryInputs = [
426
- ...new Set(Object.values(graph.chunks)
427
- .map((o) => o.entryPoint)
428
- .filter((e) => e !== undefined && !e.startsWith("node:"))),
429
- ];
430
- const resolveTarget = inputKeyResolver(inputs);
431
- const chainCache = new Map();
432
- const chainFor = (markedModule) => {
433
- const cached = chainCache.get(markedModule);
434
- if (cached !== undefined)
435
- return cached;
436
- const chain = shortestServerOnlyChain(inputs, entryInputs, markedModule, resolveTarget);
437
- chainCache.set(markedModule, chain);
438
- return chain;
439
- };
440
- // (2) Locate which emitted chunk each marked module reached, via the per-output `inputs`.
441
- const findings = new Map();
442
- for (const [outPath, out] of Object.entries(graph.chunks)) {
443
- for (const inputKey of out.modules) {
444
- if (marked.has(inputKey)) {
445
- const chunk = basename(outPath);
446
- findings.set(`${inputKey}\0${chunk}`, { chunk, chain: chainFor(inputKey) });
447
- }
448
- }
449
- }
450
- return [...findings.values()].sort((a, b) => {
451
- const am = a.chain[a.chain.length - 1] ?? "";
452
- const bm = b.chain[b.chain.length - 1] ?? "";
453
- return am === bm ? a.chunk.localeCompare(b.chunk) : am.localeCompare(bm);
454
- });
455
- }
456
- /** True when an INPUT graph key is the marker module file itself (`…/server-only.{ts,js}` under web).
457
- * Excluded from the "marked" set - the marker is the import target, not an opt-in module. */
458
- const isServerOnlyMarkerModule = (inputKey) => /(^|\/)server-only\.[cm]?[jt]s$/.test(inputKey);
459
- // ---------------------------------------------------------------------------------------------------
460
- // Guard MESSAGES - one owner for both production pipelines. The Bun build (below) and the Vite/Rollup
461
- // leak-guard plugin (plugins/vite-leak-guard.ts) both call these, so a leak reads IDENTICALLY whichever
462
- // bundler produced it. A second bundler must not grow a second, subtly-different wording of a security
463
- // error; that is exactly the "mostly ported" outcome the neutral graph seam exists to prevent.
464
- // ---------------------------------------------------------------------------------------------------
465
- /** The build-failing message for `node:` builtins that reached the client bundle. `undefined` ⇒ clean. */
466
- export function formatNodeBuiltinLeak(findings) {
467
- if (findings.length === 0)
468
- return undefined;
469
- const lines = findings.map((f) => f.chain.length > 1
470
- ? ` - ${f.builtin} reached the client bundle via ${f.chain.join(" → ")} (chunk: ${f.chunk})`
471
- : ` - ${f.builtin} reached the client bundle via ${f.chunk}`);
472
- return (`[nifra/web] Node built-in(s) in the client bundle - move them behind a server-only path ` +
473
- `(a loader/action runs on the server; import the \`node:\` module there, not at a route's ` +
474
- `top level):\n${lines.join("\n")}`);
475
- }
476
- /** The build-failing message for `server-only`-marked modules that reached the client. `undefined` ⇒ clean. */
477
- export function formatServerOnlyLeak(findings) {
478
- if (findings.length === 0)
479
- return undefined;
480
- const lines = findings.map((f) => f.chain.length > 1
481
- ? ` - server-only module reached the client bundle via ${f.chain.join(" → ")} (chunk: ${f.chunk})`
482
- : ` - server-only module reached the client bundle via ${f.chunk}`);
483
- return (`[nifra/web] server-only module(s) in the client bundle - a module marked ` +
484
- `\`import "${SERVER_ONLY_MARKER}"\` reached the browser. Move it behind a server-only path ` +
485
- `(reach it via a loader/action, or rename it \`*.server.ts\`), so its server logic never ships ` +
486
- `to the client:\n${lines.join("\n")}`);
487
- }
488
- const basename = (path) => path.slice(path.lastIndexOf("/") + 1);
489
- /** `[name]` Bun derives for an entrypoint: basename without extension (`users/[id].tsx` → `[id]`). */
490
- const entryName = (file) => {
491
- const base = basename(file);
492
- const dot = base.lastIndexOf(".");
493
- return dot === -1 ? base : base.slice(0, dot);
494
- };
495
- /**
496
- * Aggregate a list of measured chunks into a {@link SizeReport}: sort biggest-gzip-first (ties broken
497
- * by raw bytes, then name for stable output) and sum the totals. Pure - the measurement (reading the
498
- * file + `Bun.gzipSync`) happens in the orchestrator; this is the deterministic, unit-testable core.
499
- */
500
- export function aggregateSizeReport(chunks) {
501
- const sorted = [...chunks].sort((a, b) => b.gzip - a.gzip || b.bytes - a.bytes || a.name.localeCompare(b.name));
502
- let totalBytes = 0;
503
- let totalGzip = 0;
504
- for (const c of sorted) {
505
- totalBytes += c.bytes;
506
- totalGzip += c.gzip;
507
- }
508
- return { chunks: sorted, totalBytes, totalGzip };
509
- }
510
- /** Human-readable byte count: `B`/`KB`/`MB` with one decimal above 1 KB (e.g. `12.3 KB`). Pure. */
511
- export function formatBytes(bytes) {
512
- if (bytes < 1024)
513
- return `${bytes} B`;
514
- if (bytes < 1024 * 1024)
515
- return `${(bytes / 1024).toFixed(1)} KB`;
516
- return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
517
- }
518
- /**
519
- * Render a {@link SizeReport} as a terse aligned table (biggest first) with a totals row - the text
520
- * `nifra build --report` prints. Pure (string in, string out) so the formatting is unit-testable.
521
- */
522
- export function renderSizeReport(report) {
523
- const rows = report.chunks.map((c) => ({
524
- name: c.name,
525
- raw: formatBytes(c.bytes),
526
- gz: formatBytes(c.gzip),
527
- }));
528
- // Column widths from the header + every row + the totals label, so the table never clips a value.
529
- const nameW = Math.max(5, ...rows.map((r) => r.name.length), "Total".length);
530
- const rawW = Math.max(3, ...rows.map((r) => r.raw.length), formatBytes(report.totalBytes).length);
531
- const gzW = Math.max(4, ...rows.map((r) => r.gz.length), formatBytes(report.totalGzip).length);
532
- const padEnd = (s, w) => s + " ".repeat(Math.max(0, w - s.length));
533
- const padStart = (s, w) => " ".repeat(Math.max(0, w - s.length)) + s;
534
- const line = (name, raw, gz) => ` ${padEnd(name, nameW)} ${padStart(raw, rawW)} ${padStart(gz, gzW)}`;
535
- const out = [
536
- line("Chunk", "Raw", "Gzip"),
537
- line("-".repeat(nameW), "-".repeat(rawW), "-".repeat(gzW)),
538
- ...rows.map((r) => line(r.name, r.raw, r.gz)),
539
- line("Total", formatBytes(report.totalBytes), formatBytes(report.totalGzip)),
540
- ];
541
- return out.join("\n");
542
- }
543
- // The route-relative file keys the generated manifest's route map declares. Both shapes are matched by
544
- // their VALUE: eager ` "routes/x.tsx": m0,` and lazy ` "routes/x.tsx": () => import("./routes/x"),`.
545
- // The KEY (not the import specifier) is the route identity `discoverRoutes` produces, extension and all;
546
- // the import specifier is emitted extensionless (so a bare `tsc` compiles the manifest) and could not be
547
- // mapped back to the exact `.tsx`/`.ts` key. The baked `routeStyles` JSON is a single line, so its keys
548
- // are never at an indented line start and never match. `[ \t]+` (not `\s+`) so the `m` anchor stays on
549
- // the same line and one route-map entry cannot span two lines of source.
550
- const MANIFEST_ROUTE_ENTRY = /^[ \t]+(["'])([^"'\n]+)\1\s*:\s*(?:m\d+\b|\(\s*\)\s*=>\s*import\b)/gm;
551
- // The lazy shape declares `const loaders`, the eager shape `const modules`. Anchored at a line start,
552
- // NOT `.includes("const loaders =")`: the real emit is `const loaders: Record<...> = {`, whose `:` type
553
- // annotation sits between the name and the `=`, so the ` =` substring never matched and every lazy
554
- // manifest was re-synced as eager (silently converting `import()` splitting into a single eager bundle).
555
- const MANIFEST_IS_LAZY = /^const loaders\b/m;
556
- // The baked client-entry line: `export const clientEntry = "…"`.
557
- const MANIFEST_CLIENT_ENTRY = /export\s+const\s+clientEntry\s*=\s*["']([^"']+)["']/;
558
- /**
559
- * Extract the route-relative file list a committed server-manifest declares, as the same
560
- * `routes/`-relative keys `discoverRoutes` produces (e.g. `docs/index.tsx`). Reads the route map's KEYS,
561
- * which carry the file extension and no directory prefix - exactly discovery's shape - so the result is
562
- * independent of the specifier prefix the manifest happened to import with. The `@nifrajs/web` import and
563
- * the baked `clientEntry`/`styles`/`routeStyles` lines carry no route-map entry and are ignored.
564
- *
565
- * `_routesPrefix` is accepted for call-site compatibility but no longer needed: the keys are already
566
- * prefix-free. Pure - operates on source text.
567
- */
568
- export function parseManifestRouteFiles(source, _routesPrefix) {
569
- const files = new Set();
570
- for (const match of source.matchAll(MANIFEST_ROUTE_ENTRY)) {
571
- if (match[2] !== undefined)
572
- files.add(match[2]);
573
- }
574
- return [...files].sort();
575
- }
576
- /** The baked `clientEntry` URL in a committed server-manifest, or `undefined` if absent. Pure. */
577
- export function parseManifestClientEntry(source) {
578
- return MANIFEST_CLIENT_ENTRY.exec(source)?.[1];
579
- }
580
- // The baked asset lines `generateServerManifest` emits: `export const styles = […]` then
581
- // `export const routeStyles = {…}`, each a JSON literal running to the next `export const`. Extracted
582
- // with `indexOf` slicing rather than a lazy `[\s\S]*?` regex: the slice is multi-line by nature (a
583
- // formatter can wrap a long `routeStyles` map, and missing it would silently DROP every baked
584
- // stylesheet on re-sync), and a lazy scan re-walks the file per unterminated opener - quadratic.
585
- function bakedManifestLiteral(source, name) {
586
- const opener = `export const ${name} = `;
587
- const start = source.indexOf(opener);
588
- if (start === -1)
589
- return undefined;
590
- const from = start + opener.length;
591
- const end = source.indexOf("\nexport const ", from);
592
- return end === -1 ? undefined : source.slice(from, end);
593
- }
594
- /** Parse a baked JSON literal captured from a committed manifest, tolerating a formatter's TRAILING COMMAS
595
- * (biome/prettier add one when wrapping a multi-line array/object; strict `JSON.parse` would reject it).
596
- * Only a comma immediately before a closing `]`/`}` is stripped, so commas inside string values are safe. */
597
- function parseManifestLiteral(raw) {
598
- return JSON.parse(raw.replace(/,(\s*[\]}])/g, "$1"));
599
- }
600
- /** The baked top-level `styles` array in a committed server-manifest (empty if absent/unparseable). Pure. */
601
- export function parseManifestStyles(source) {
602
- const raw = bakedManifestLiteral(source, "styles");
603
- if (raw === undefined)
604
- return [];
605
- try {
606
- const value = parseManifestLiteral(raw);
607
- return Array.isArray(value) ? value : [];
608
- }
609
- catch {
610
- return [];
611
- }
612
- }
613
- /** The baked per-route `routeStyles` map in a committed server-manifest (empty if absent/unparseable). Pure. */
614
- export function parseManifestRouteStyles(source) {
615
- const raw = bakedManifestLiteral(source, "routeStyles");
616
- if (raw === undefined)
617
- return {};
618
- try {
619
- const value = parseManifestLiteral(raw);
620
- return value !== null && typeof value === "object" && !Array.isArray(value)
621
- ? value
622
- : {};
623
- }
624
- catch {
625
- return {};
626
- }
627
- }
628
207
  /**
629
208
  * Re-emit a committed server-manifest from a freshly-discovered route tree, PRESERVING its baked
630
209
  * client-asset references (`clientEntry` / `styles` / `routeStyles`) and its eager-vs-lazy shape. This is
@@ -649,37 +228,6 @@ export function resyncServerManifestSource(source, manifest, routesPrefix) {
649
228
  * supplies both file lists (the committed source is parsed via {@link parseManifestRouteFiles}; the
650
229
  * fresh list comes from `discoverRoutes`). Lists need not be pre-sorted; the result is sorted.
651
230
  */
652
- export function diffManifestRoutes(manifestFiles, discoveredFiles) {
653
- const inManifest = new Set(manifestFiles);
654
- const inRoutes = new Set(discoveredFiles);
655
- const missing = discoveredFiles.filter((f) => !inManifest.has(f)).sort();
656
- const extra = manifestFiles.filter((f) => !inRoutes.has(f)).sort();
657
- return { missing, extra };
658
- }
659
- /** True when a drift report is clean (no missing + no extra routes). */
660
- export function isManifestInSync(drift) {
661
- return drift.missing.length === 0 && drift.extra.length === 0;
662
- }
663
- /**
664
- * Format a {@link ManifestDrift} as a named, actionable error message, or `undefined` when in sync.
665
- * Names the exact missing/extra routes + the one fix (regenerate the manifest by re-running the build).
666
- * `manifestPath` is shown for the dev to locate the stale file. Pure.
667
- */
668
- export function formatManifestDrift(drift, manifestPath = "server-manifest.ts") {
669
- if (isManifestInSync(drift))
670
- return undefined;
671
- const lines = [
672
- `[nifra/web] server-manifest drift - \`${manifestPath}\` is out of sync with routes/.`,
673
- ];
674
- if (drift.missing.length > 0) {
675
- lines.push(` Missing (in routes/, not in the manifest - these routes won't be served): ${drift.missing.join(", ")}`);
676
- }
677
- if (drift.extra.length > 0) {
678
- lines.push(` Extra (imported by the manifest, gone from routes/ - a dangling import): ${drift.extra.join(", ")}`);
679
- }
680
- lines.push(" Fix: re-run the build to regenerate the server manifest, then commit it.");
681
- return lines.join("\n");
682
- }
683
231
  /**
684
232
  * Build the client bundle for a file-routed app. Writes the hashed assets + `manifest.json` to
685
233
  * `outDir` and returns the manifest. Throws (with the bundler logs) on build failure - never
@@ -691,6 +239,20 @@ export async function buildClient(options) {
691
239
  await assertIdentityParity(root);
692
240
  const resolve = options.resolve ?? ((file) => `${routesDir}/${file}`);
693
241
  const publicPath = options.publicPath ?? "/assets/";
242
+ // outDir inside publicDir folds the build output back into the public copy on the next run (the
243
+ // bundle lands at public/assets/assets/*), and the dev/prod parity assert then throws on paths no
244
+ // one authored. Reject the overlap up front - no copy behavior is correct for this layout, and the
245
+ // parity symptom names neither option so the cause is unrecoverable from the message.
246
+ const publicRoot = options.publicDir === false ? undefined : (options.publicDir ?? "public");
247
+ if (publicRoot !== undefined) {
248
+ const publicResolved = resolvePath(publicRoot);
249
+ const outResolved = resolvePath(outDir);
250
+ if (outResolved === publicResolved || outResolved.startsWith(publicResolved + sep)) {
251
+ throw new Error(`[nifra/web] outDir (${outResolved}) is inside publicDir (${publicResolved}). The public ` +
252
+ `copy would fold the build output back into itself. Move outDir outside publicDir, or ` +
253
+ `pass publicDir: false if the app has no hand-authored static files.`);
254
+ }
255
+ }
694
256
  mkdirSync(outDir, { recursive: true });
695
257
  const routeManifest = discoverRoutes(routesDir);
696
258
  // The bootstrap's filename is `_nifra`-namespaced (not `entry.ts`) so its `[name]` can't collide with
@@ -742,6 +304,7 @@ export async function buildClient(options) {
742
304
  ...buildExtras,
743
305
  minify: options.minify ?? true,
744
306
  plugins: [
307
+ ...declaredSingleCopyPlugins(root),
745
308
  reactDedupePlugin(routesDir),
746
309
  preactDedupePlugin(routesDir),
747
310
  svelteDedupePlugin(routesDir),
@@ -828,11 +391,17 @@ export async function buildClient(options) {
828
391
  const base = basename(path);
829
392
  return base.slice(0, base.lastIndexOf("-")); // strip `-${hash}.css`
830
393
  };
394
+ // `css` is the union of every emitted stylesheet, matching the Vite pipeline (build-vite.ts) so the
395
+ // field means the same thing on both bundlers. The bootstrap aggregate lists first, preserving the
396
+ // fallback link order `createWebApp` uses (whole-app stylesheet before any route-scoped one); the
397
+ // per-route `cssBundle` outputs follow. Dropping those per-route outputs (the old `aggregate`-only
398
+ // shape) left them in `manifest.assets` but not `css`, so a route-scoped stylesheet normalized to
399
+ // `asset:css` and tripped the module-graph parity contract - a bundler-dependent divergence.
831
400
  const cssAssets = result.outputs.filter((o) => o.kind === "asset" && o.path.endsWith(".css"));
832
- const aggregate = cssAssets
833
- .filter((o) => cssNameOf(o.path) === bootstrapName)
834
- .map((o) => toUrl(o.path));
835
- const css = aggregate.length > 0 ? aggregate : cssAssets.map((o) => toUrl(o.path));
401
+ const css = [
402
+ ...cssAssets.filter((o) => cssNameOf(o.path) === bootstrapName),
403
+ ...cssAssets.filter((o) => cssNameOf(o.path) !== bootstrapName),
404
+ ].map((o) => toUrl(o.path));
836
405
  // CSS - per-route: each route/layout file is its own entrypoint, so the build metafile records its
837
406
  // `cssBundle` - exactly the CSS that file's subtree uses (shared-component CSS is inlined into each
838
407
  // consumer; verified). Keyed by the metafile's unique source `entryPoint`, so it survives
@@ -853,7 +422,12 @@ export async function buildClient(options) {
853
422
  return [...new Set(urls)];
854
423
  };
855
424
  const routeStyles = {};
856
- if (css.length > 0 && cssByEntry.size > 0) {
425
+ // A compiler plugin may attach a global stylesheet to the generated bootstrap rather than to a
426
+ // route entry (StyleX's atomic CSS is one example). In that shape there is no useful per-route map:
427
+ // emitting `routeId: []` would suppress the aggregate `styles` fallback in `createWebApp` and render
428
+ // SSR pages unstyled. Keep routeStyles absent until at least one authored route entry owns CSS.
429
+ const routeCssEntries = [...cssByEntry.keys()].filter((entry) => entry !== resolvePath(entryFile));
430
+ if (css.length > 0 && routeCssEntries.length > 0) {
857
431
  for (const route of routeManifest.routes) {
858
432
  routeStyles[route.id] = stylesFor([
859
433
  ...route.layoutIds.map((id) => routeManifest.layouts[id]?.file ?? ""),
@@ -879,6 +453,7 @@ export async function buildClient(options) {
879
453
  writeFileSync(`${outDir}/manifest.json`, JSON.stringify(manifest, null, 2));
880
454
  return manifest;
881
455
  }
456
+ /** The built worker bundle - point your `wrangler.toml`'s `main` at `worker`. */
882
457
  /**
883
458
  * react-dom's `exports["./server"]` maps the `bun` condition to a Bun-API server build that crashes
884
459
  * on workerd, and `Bun.build` always applies the `bun` condition (it wins over `workerd`/`edge-light`),
@@ -905,7 +480,7 @@ const reactDomEdgePlugin = (from) => ({
905
480
  * condition-agnostic, so pinning it doesn't disturb the edge/browser/server conditions that select
906
481
  * react-dom's build.
907
482
  */
908
- const REACT_DEDUPE_SPECS = ["react", "react/jsx-runtime", "react/jsx-dev-runtime"];
483
+ const REACT_DEDUPE_SPECS = dedupePolicyFor("react").bunSpecs ?? [];
909
484
  export const reactDedupePlugin = (from) => ({
910
485
  name: "nifra-react-dedupe",
911
486
  setup(build) {
@@ -933,13 +508,7 @@ export const reactDedupePlugin = (from) => ({
933
508
  * skipped). Preact core is condition-agnostic, so pinning it doesn't disturb any condition selection - and
934
509
  * unlike react-dom there is no edge-vs-server build to preserve, so pinning the subpaths is safe.
935
510
  */
936
- const PREACT_DEDUPE_SPECS = [
937
- "preact",
938
- "preact/hooks",
939
- "preact/compat",
940
- "preact/jsx-runtime",
941
- "preact/jsx-dev-runtime",
942
- ];
511
+ const PREACT_DEDUPE_SPECS = dedupePolicyFor("preact").bunSpecs ?? [];
943
512
  export const preactDedupePlugin = (from) => ({
944
513
  name: "nifra-preact-dedupe",
945
514
  setup(build) {
@@ -956,6 +525,7 @@ export const preactDedupePlugin = (from) => ({
956
525
  }
957
526
  },
958
527
  });
528
+ const SVELTE_DEDUPE_PATTERN = dedupePolicyFor("svelte").bunPattern ?? /^svelte($|\/internal\/)/;
959
529
  /**
960
530
  * Dedupe Svelte to a single copy - the Svelte analogue of `reactDedupePlugin`/`preactDedupePlugin`, closing
961
531
  * the same class of bug for Svelte (which had NO build-time dedup before). A workspace- or file-linked
@@ -974,7 +544,7 @@ export const preactDedupePlugin = (from) => ({
974
544
  export const svelteDedupePlugin = (from) => ({
975
545
  name: "nifra-svelte-dedupe",
976
546
  setup(build) {
977
- build.onResolve({ filter: /^svelte($|\/internal\/)/ }, (args) => {
547
+ build.onResolve({ filter: SVELTE_DEDUPE_PATTERN }, (args) => {
978
548
  try {
979
549
  return { path: Bun.resolveSync(args.path, from) };
980
550
  }
@@ -984,6 +554,19 @@ export const svelteDedupePlugin = (from) => ({
984
554
  });
985
555
  },
986
556
  });
557
+ /**
558
+ * The app's declared single-copy rule, applied to the bundle.
559
+ *
560
+ * The three plugins above cover the framework runtimes by name, which is the case that bites hardest
561
+ * and needs no configuration. This one covers what a name list cannot know: `@nifrajs/*` (two copies
562
+ * of `@nifrajs/core` are two `Server` classes, and `Server` has private members, so `.merge()` stops
563
+ * accepting the other's app) and whatever else the app named in
564
+ * `"nifra": { "singleCopy": [...] }`.
565
+ *
566
+ * Nothing declared means no plugin at all - the plan does filesystem work, so it is not built for the
567
+ * majority of apps that have a single copy of everything and never think about this.
568
+ */
569
+ const declaredSingleCopyPlugins = (root) => readSingleCopyDeclaration(root) === undefined ? [] : [declaredSingleCopyPlugin({ cwd: root })];
987
570
  /**
988
571
  * Remix-style `.server` convention for the CLIENT build. A module named `*.server.ts(x)` (`db.server.ts`,
989
572
  * `auth.server.ts`, …) is server-only - empty it in the browser bundle so its (possibly `node:` / native /
@@ -1089,6 +672,7 @@ export async function buildServer(options) {
1089
672
  // Lazy → one chunk per route (loaded on first request); eager → a single self-contained file.
1090
673
  splitting: lazy,
1091
674
  plugins: [
675
+ ...declaredSingleCopyPlugins(resolvePath(dirname(routesDir))),
1092
676
  ...(edge ? [reactDomEdgePlugin(entryDir), solidWebServerPlugin(entryDir)] : []),
1093
677
  reactDedupePlugin(entryDir),
1094
678
  preactDedupePlugin(entryDir),
@@ -1112,12 +696,6 @@ export async function buildServer(options) {
1112
696
  // The ONLY thing apps used to hand-write per target was the server entry (`_worker.ts`, `server-bun.ts`,
1113
697
  // …) - so we GENERATE it here (per target) instead of asking each app to ship five near-identical files.
1114
698
  // ===================================================================================================
1115
- /** A deploy target `nifra build --target <t>` can emit. `static` is pure SSG (no server). */
1116
- export const BUILD_TARGETS = ["bun", "node", "deno", "cf-pages", "vercel", "static"];
1117
- /** A type guard narrowing an arbitrary string to a {@link BuildTarget}. */
1118
- export function isBuildTarget(value) {
1119
- return BUILD_TARGETS.includes(value);
1120
- }
1121
699
  /**
1122
700
  * Codegen the per-target **server entry** module (source text) for `buildServer` to bundle. It imports
1123
701
  * the app's `adapter` (from `framework.ts`), the optional `backend` (from `backend.ts`), and the
@@ -1185,14 +763,6 @@ export function generateServerEntry(options) {
1185
763
  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)", "})");
1186
764
  return `${lines.join("\n")}\n`;
1187
765
  }
1188
- /** The Bun.build `target` each deploy target compiles its server with (mirrors `buildServer`'s docs). */
1189
- const SERVER_BUILD_TARGET = {
1190
- "cf-pages": "browser", // edge conditions (workerd/edge-light)
1191
- vercel: "browser", // Vercel Edge runtime
1192
- deno: "browser", // Deno's Web-standard runtime runs the edge bundle
1193
- node: "node", // node:* external; react-dom → its Node SSR build
1194
- bun: "bun", // Bun's flagship runtime
1195
- };
1196
766
  /** Measure each emitted output file's raw + gzip size. Reads the file off disk and gzips it with
1197
767
  * `Bun.gzipSync` (the over-the-wire weight). Async only because it reads files; the aggregation is the
1198
768
  * pure {@link aggregateSizeReport}. */
@@ -1208,6 +778,21 @@ async function measureOutputs(paths) {
1208
778
  }
1209
779
  return chunks;
1210
780
  }
781
+ /**
782
+ * Build a full deploy directory for `target` from a file-routed nifra app. Emits the client bundle to
783
+ * `<outDir>/assets/*`, then per target:
784
+ * - `static`: prerenders opted-in routes (`prerenderRoutes`) to `<outDir>/<path>/index.html` (+
785
+ * `_data.json`); needs `prerenderApp`. No server.
786
+ * - `cf-pages`: a `_worker.js` (edge bundle) + a `_routes.json` excluding /assets/* from the worker.
787
+ * - `vercel`: a `.vercel/output`-shaped function isn't emitted here - `vercel` emits the bundled edge
788
+ * entry as `<outDir>/index.js` (the CLI's docs point at `vercel`'s Build Output wrapper). [see note]
789
+ * - `deno`/`node`/`bun`: the self-hosting server bundle (`server.js`) next to the assets.
790
+ * The server entry is GENERATED (`generateServerEntry`) and bundled (`buildServer`); the app supplies
791
+ * only adapter/backend/routes. Returns the manifest + a size report. Throws on any build failure.
792
+ *
793
+ * Note: the heavier platform wrappers (`.vercel/output` v3 layout, wrangler ISR `find_additional_modules`)
794
+ * remain app-owned scripts; this command targets the common single-bundle deploys. See the CLI docs.
795
+ */
1211
796
  /** The default (Bun) strategy - `buildClient`/`buildServer` from this module. */
1212
797
  export const bunBundler = {
1213
798
  buildClient: (input) => buildClient({
@@ -1242,6 +827,7 @@ export async function buildTarget(target, options) {
1242
827
  */
1243
828
  export async function buildTargetWith(target, options, bundler) {
1244
829
  const { routesDir, outDir, workDir } = options;
830
+ const targetPlan = planBuildTarget(target, outDir);
1245
831
  const { rmSync } = await import("node:fs");
1246
832
  rmSync(outDir, { recursive: true, force: true });
1247
833
  rmSync(workDir, { recursive: true, force: true });
@@ -1268,7 +854,7 @@ export async function buildTargetWith(target, options, bundler) {
1268
854
  client = { ...client, publicFiles };
1269
855
  writeFileSync(`${assetsDir}/manifest.json`, JSON.stringify(client, null, 2));
1270
856
  }
1271
- if (target === "static") {
857
+ if (targetPlan.kind === "static") {
1272
858
  if (options.prerenderApp === undefined) {
1273
859
  throw new Error("[nifra/web] buildTarget(static) requires `prerenderApp` (a factory `(client) => createWebApp`)");
1274
860
  }
@@ -1294,7 +880,7 @@ export async function buildTargetWith(target, options, bundler) {
1294
880
  target,
1295
881
  outDir,
1296
882
  client,
1297
- run: `static site → ${outDir} (serve the directory with any static host)`,
883
+ run: targetPlan.run,
1298
884
  size,
1299
885
  };
1300
886
  }
@@ -1310,22 +896,20 @@ export async function buildTargetWith(target, options, bundler) {
1310
896
  ...(options.title !== undefined ? { title: options.title } : {}),
1311
897
  ...(publicFiles.length > 0 ? { publicFiles } : {}),
1312
898
  }));
1313
- const serverTarget = SERVER_BUILD_TARGET[target];
1314
899
  const { worker } = await bundler.buildServer({
1315
900
  routesDir,
1316
901
  serverEntry: serverEntryPath,
1317
902
  outDir: `${workDir}/server`,
1318
903
  clientEntry: client.entry,
1319
- target: serverTarget,
904
+ target: targetPlan.serverTarget,
1320
905
  ...(options.serverPlugins ? { plugins: options.serverPlugins } : {}),
1321
906
  define: { "process.env.NODE_ENV": '"production"', ...(options.define ?? {}) },
1322
907
  root: resolvePath(dirname(routesDir)),
1323
908
  });
1324
909
  // (3) Assemble the deploy dir for the target.
1325
910
  const { cpSync } = await import("node:fs");
1326
- let run;
1327
- if (target === "cf-pages") {
1328
- cpSync(worker, `${outDir}/_worker.js`);
911
+ if (targetPlan.target === "cf-pages") {
912
+ cpSync(worker, `${outDir}/${targetPlan.outputFile}`);
1329
913
  // The app's real patterns, so a directory is only collapsed into a glob once the route table
1330
914
  // proves nothing can be served beneath it.
1331
915
  const rules = cloudflareRouteRules(publicFiles, discoverRoutes(routesDir).routes.map((route) => route.pattern));
@@ -1337,26 +921,19 @@ export async function buildTargetWith(target, options, bundler) {
1337
921
  `(Cloudflare allows ${CF_MAX_ROUTE_RULES}). The other ${rules.omitted.length} file(s) are served by the ` +
1338
922
  "worker via ASSETS instead of the CDN directly - correct, just one invocation each.");
1339
923
  }
1340
- run = `Cloudflare Pages → ${outDir} (deploy: wrangler pages deploy ${basename(outDir)})`;
1341
924
  }
1342
- else if (target === "vercel") {
1343
- cpSync(worker, `${outDir}/index.js`);
1344
- run = `Vercel edge function → ${outDir}/index.js (wrap with your vercel.json or Build Output API)`;
925
+ else if (targetPlan.target === "vercel") {
926
+ cpSync(worker, `${outDir}/${targetPlan.outputFile}`);
1345
927
  }
1346
928
  else {
1347
- // bun / node / deno: the runnable server bundle next to its /assets.
1348
- cpSync(worker, `${outDir}/server.js`);
1349
- const cmd = target === "node"
1350
- ? `node ${basename(outDir)}/server.js`
1351
- : `${target} ${basename(outDir)}/server.js`;
1352
- run = `${target} server → ${outDir} (run: ${cmd})`;
929
+ cpSync(worker, `${outDir}/${targetPlan.outputFile}`);
1353
930
  }
1354
931
  rmSync(workDir, { recursive: true, force: true });
1355
932
  // Size report over the client assets + the server bundle (its parse cost matters on the edge).
1356
933
  const clientPaths = client.assets.map((u) => assetUrlToPath(u, assetsDir));
1357
- const serverPath = `${outDir}/${target === "cf-pages" ? "_worker.js" : target === "vercel" ? "index.js" : "server.js"}`;
934
+ const serverPath = `${outDir}/${targetPlan.outputFile}`;
1358
935
  const size = aggregateSizeReport(await measureOutputs([...clientPaths, serverPath]));
1359
- return { target, outDir, client, run, size };
936
+ return { target, outDir, client, run: targetPlan.run, size };
1360
937
  }
1361
938
  /** Map a client asset URL (`/assets/x-hash.js`) back to its on-disk path under `assetsDir`. The
1362
939
  * `publicPath` prefix is always `/assets/` for these builds, so strip it and rejoin. */