@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/src/build.ts CHANGED
@@ -7,11 +7,40 @@
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 {
11
+ dirname,
12
+ join,
13
+ basename as pathBasename,
14
+ relative,
15
+ resolve as resolvePath,
16
+ sep,
17
+ } from "node:path"
18
+ import {
19
+ singleCopyPlugin as declaredSingleCopyPlugin,
20
+ readSingleCopyDeclaration,
21
+ } from "@nifrajs/core/single-copy"
11
22
  import { type BunPlugin, Glob } from "bun"
23
+ import {
24
+ aggregateSizeReport,
25
+ type BuildManifest,
26
+ type BuildTarget,
27
+ type Bundler,
28
+ type ChunkSize,
29
+ detectNodeBuiltinsInClient,
30
+ detectServerOnlyInClient,
31
+ formatNodeBuiltinLeak,
32
+ formatServerOnlyLeak,
33
+ parseManifestClientEntry,
34
+ parseManifestRouteStyles,
35
+ parseManifestStyles,
36
+ planBuildTarget,
37
+ type ServerBuild,
38
+ type SizeReport,
39
+ } from "./build-plan.ts"
12
40
  import { sanitizeOutputNames } from "./chunk-names.ts"
13
41
  import { discoverRoutes } from "./fs.ts"
14
42
  import { generateClientEntry, generateServerManifest } from "./index.ts"
43
+ import { dedupePolicyFor } from "./internal/identity-policy.ts"
15
44
  import {
16
45
  assertDevelopmentProductionParity,
17
46
  assertIdentityParity,
@@ -25,9 +54,32 @@ import {
25
54
  serverFnNamespace,
26
55
  } from "./internal/server-boundary.ts"
27
56
  // `buildTarget(static)` drives the SSG prerender engine directly (it's also re-exported below).
28
- import { type ClientModuleGraph, fromBunMetafile } from "./module-graph.ts"
57
+ import { fromBunMetafile } from "./module-graph.ts"
29
58
  import { prerenderRoutes } from "./prerender.ts"
30
59
 
60
+ export * from "./build-plan.ts"
61
+
62
+ const basename = (path: string): string => pathBasename(path)
63
+ // The lazy shape declares `const loaders`, the eager shape `const modules`. Anchored at a line start,
64
+ // NOT `.includes("const loaders =")`: the real emit is `const loaders: Record<...> = {`, whose `:` type
65
+ // annotation sits between the name and the `=`, so the ` =` substring never matched and every lazy
66
+ // manifest was re-synced as eager (silently converting `import()` splitting into a single eager bundle).
67
+ const MANIFEST_IS_LAZY = /^const loaders\b/m
68
+ const entryName = (file: string): string => {
69
+ const base = basename(file)
70
+ const dot = base.lastIndexOf(".")
71
+ return dot === -1 ? base : base.slice(0, dot)
72
+ }
73
+
74
+ /** The slice of Bun's build metafile this build path reads: per JS OUTPUT, its source `entryPoint` and
75
+ * the `cssBundle` Bun emitted for that entry. The guard-facing import graph is adapted separately by
76
+ * `fromBunMetafile`; this smaller view is only for the per-route stylesheet map. */
77
+ interface BunMetafile {
78
+ readonly outputs?: Readonly<
79
+ Record<string, { readonly entryPoint?: string; readonly cssBundle?: string }>
80
+ >
81
+ }
82
+
31
83
  // Build-time SSG: prerender opted-in static + dynamic routes to `index.html` (+ static `_data.json`),
32
84
  // run after `buildClient`.
33
85
  export {
@@ -285,596 +337,6 @@ export async function copyPublicDir(from: string, to: string): Promise<string[]>
285
337
  return copied.sort()
286
338
  }
287
339
 
288
- export interface BuildManifest {
289
- /** URL of the client entry module (content-hashed). */
290
- readonly entry: string
291
- /** URLs of every emitted asset (entry + chunks) - for serving + preloading. */
292
- readonly assets: readonly string[]
293
- /** `routeId → [layout chunk URLs…, own chunk URL]` - the chunks a route needs, for `createWebApp`'s
294
- * `routePreload` (`<link rel="modulepreload">` the matched route alongside the entry). Each route +
295
- * layout is also a build entrypoint, so it gets a named chunk the bootstrap's lazy import dedupes to. */
296
- readonly routes: Readonly<Record<string, readonly string[]>>
297
- /** URL paths copied from `publicDir`, sorted. Lets the server entry serve them without scanning a
298
- * directory per request, and lets an adapter that needs a file list (CDN upload, platform static
299
- * assets) consume one. Omitted when there is no `public/`. */
300
- readonly publicFiles?: readonly string[]
301
- /** The app's bundled, content-hashed stylesheet(s) - the bootstrap's **aggregate** CSS (every
302
- * `import './x.css'` reachable from the app). The complete stylesheet regardless of which file
303
- * imported the CSS; the always-safe fallback `createWebApp` links when a route has no per-route entry
304
- * in {@link routeStyles}. Omitted when the app imports no CSS. */
305
- readonly css?: readonly string[]
306
- /** `routeId → [chain CSS URLs]` - only the stylesheets the matched route's layout chain + own file
307
- * actually use (Bun emits a per-entrypoint CSS bundle per route/layout, with shared-component CSS
308
- * inlined into each consumer). `createWebApp` links these instead of the aggregate, so a page ships
309
- * only its own CSS. A route is omitted (→ aggregate fallback) when its `[name]` collides with another
310
- * route's basename (ambiguous CSS↔route) or the build emitted orphan shared-chunk CSS - correctness
311
- * over minimality. Absent entirely when the app imports no CSS. */
312
- readonly routeStyles?: Readonly<Record<string, readonly string[]>>
313
- }
314
-
315
- /** One edge in the metafile's import graph: the resolved `path` + the `original` specifier as written
316
- * (so `import "node:crypto"` records `original: "node:crypto"` even after Bun resolves it to a
317
- * polyfill path). Other Bun fields (`kind`, `external`) are ignored here. */
318
- interface BunMetafileImport {
319
- readonly path?: string
320
- readonly original?: string
321
- }
322
- /** The slice of Bun's build metafile nifra reads: per INPUT module, the specifiers it `imports`
323
- * (for the `node:` guard); per JS OUTPUT, its source `entryPoint`, the `cssBundle` Bun emitted for
324
- * that entry, and the `inputs` (graph keys) that landed in it (for the per-route CSS map + locating
325
- * which chunk a flagged builtin reached). Not yet in `@types/bun`; shape per the Bun bundler docs. */
326
- interface BunMetafileOutput {
327
- readonly entryPoint?: string
328
- readonly cssBundle?: string
329
- readonly inputs?: Readonly<Record<string, unknown>>
330
- }
331
- interface BunMetafile {
332
- readonly inputs?: Readonly<Record<string, { readonly imports?: readonly BunMetafileImport[] }>>
333
- readonly outputs: Readonly<Record<string, BunMetafileOutput>>
334
- }
335
-
336
- /** The `node:` specifier of an import edge, or undefined if it isn't a Node built-in. Reads the
337
- * `original` (as-written) specifier first, falling back to the resolved `path` (an `external` node:
338
- * import keeps `node:` in its path). */
339
- const nodeBuiltinOf = (im: BunMetafileImport): string | undefined => {
340
- if (im.original?.startsWith("node:")) return im.original
341
- if (im.path?.startsWith("node:")) return im.path
342
- return undefined
343
- }
344
-
345
- /** One `node:`-builtin-in-the-client finding: the offending builtin, the emitted chunk it landed in,
346
- * and the shortest USER-module import chain that pulled it there (entry → … → builtin). */
347
- export interface NodeBuiltinFinding {
348
- readonly builtin: string
349
- readonly chunk: string
350
- /** The shortest import path from a user entry to the builtin, as a list of display labels:
351
- * `[entryFile, ...as-written specifiers along the way, builtin]`, e.g.
352
- * `["routes/article/[slug].tsx", "../data.ts", "../db/client.ts", "postgres", "node:tls"]`. The
353
- * entry is its graph key (the route file); each hop is the import's *as-written* specifier; the tail
354
- * is the builtin. Empty only if the builtin module isn't reachable from any traced input (it always
355
- * is when flagged). */
356
- readonly chain: readonly string[]
357
- }
358
-
359
- /**
360
- * BFS the metafile import graph for the SHORTEST user-module path that pulls `builtin` into the
361
- * bundle, returning it as display labels `[entryFile, …as-written specifiers…, builtin]`. The frontier
362
- * starts at every `entryInput` (the route/user entrypoints) so the reported chain begins where the dev
363
- * actually wrote `import` - the actionable root, not an arbitrary internal module. Traversal crosses
364
- * only NON-`node:` edges (so Bun's polyfill chain never extends the path) and stops at the first edge
365
- * whose target is the builtin. Returns `[builtin]` if no entry reaches it (defensive; a flagged builtin
366
- * is reachable by construction). Pure - operates on the graph, never the emitted text.
367
- */
368
- function shortestBuiltinChain(
369
- inputs: Readonly<Record<string, { readonly imports?: readonly BunMetafileImport[] }>>,
370
- entryInputs: readonly string[],
371
- builtin: string,
372
- ): string[] {
373
- // Each queue item carries the resolved-path node + the display chain to reach it (entry + the
374
- // as-written specifier of every hop crossed so far). `seen` dedupes nodes so the BFS is linear and
375
- // the first time we touch the builtin's importer is via a shortest path.
376
- const seen = new Set<string>(entryInputs)
377
- let frontier: Array<{ node: string; chain: string[] }> = entryInputs.map((node) => ({
378
- node,
379
- chain: [node],
380
- }))
381
- while (frontier.length > 0) {
382
- const next: Array<{ node: string; chain: string[] }> = []
383
- for (const { node, chain } of frontier) {
384
- for (const im of inputs[node]?.imports ?? []) {
385
- // The builtin reached via THIS edge → the chain ends here (append the builtin label). Match on
386
- // the same `node:` detection the finding used (`original` first, then resolved `path`).
387
- if (nodeBuiltinOf(im) === builtin) return [...chain, builtin]
388
- const target = im.path
389
- // Only follow edges into user (non-`node:`) modules that exist in the input graph and haven't
390
- // been visited - so the polyfill subtree can't lengthen the path and there are no cycles.
391
- if (
392
- target === undefined ||
393
- target.startsWith("node:") ||
394
- inputs[target] === undefined ||
395
- seen.has(target)
396
- ) {
397
- continue
398
- }
399
- seen.add(target)
400
- // Display the hop by its as-written specifier (`../db/client.ts`, `postgres`), falling back to
401
- // the resolved path - that's what the dev recognizes in their source, not the resolved path.
402
- next.push({ node: target, chain: [...chain, im.original ?? target] })
403
- }
404
- }
405
- frontier = next
406
- }
407
- return [builtin] // unreachable from any entry - degrade to just the builtin (shouldn't happen)
408
- }
409
-
410
- /**
411
- * Scan a build's metafile for any `node:` builtin that a USER module pulled into a CLIENT output
412
- * chunk, returning a sorted, deduped list of {@link NodeBuiltinFinding}s. Three graph facts combine so
413
- * the report is precise AND actionable:
414
- * 1. **What the user wrote** - only builtins imported by a NON-`node:` input count, so Bun's own
415
- * polyfill chain (`node:crypto` → `node:buffer`/`node:stream`/…) doesn't bury the real cause.
416
- * 2. **Where it landed** - the chunk is read from the per-output `inputs`, so the error names the
417
- * emitted file to look at.
418
- * 3. **How it got there** - the shortest import chain from a user entry to the builtin
419
- * (`shortestBuiltinChain`), so the error points straight at the offending `import` line instead of
420
- * leaving the dev to grep the dependency tree (the DX gap this closes).
421
- * Graph-based (never the emitted text), so it survives minification and can't be fooled by a string
422
- * literal that merely contains `"node:crypto"`. Pure + exported for unit testing. Empty ⇒ clean.
423
- */
424
- export function detectNodeBuiltinsInClient(
425
- graph: ClientModuleGraph,
426
- ): ReadonlyArray<NodeBuiltinFinding> {
427
- const inputs = graph.modules
428
- // (1) The builtins a user (non-polyfill) module imports directly - the ones the author controls.
429
- const userImported = new Set<string>()
430
- for (const [inputKey, input] of Object.entries(inputs)) {
431
- if (inputKey.startsWith("node:")) continue // a polyfill importing another builtin - not the cause
432
- for (const im of input.imports ?? []) {
433
- const builtin = nodeBuiltinOf(im)
434
- if (builtin !== undefined) userImported.add(builtin)
435
- }
436
- }
437
- // The entry inputs (route/user entrypoints) - the chain BFS starts here so the reported path begins
438
- // at the file the dev wrote an `import` in. Each output's `entryPoint` is one such input.
439
- const entryInputs = [
440
- ...new Set(
441
- Object.values(graph.chunks)
442
- .map((o) => o.entryPoint)
443
- .filter((e): e is string => e !== undefined && !e.startsWith("node:")),
444
- ),
445
- ]
446
- // The chain per builtin is independent of the chunk, so compute it once per builtin (memoized).
447
- const chainCache = new Map<string, readonly string[]>()
448
- const chainFor = (builtin: string): readonly string[] => {
449
- const cached = chainCache.get(builtin)
450
- if (cached !== undefined) return cached
451
- const chain = shortestBuiltinChain(inputs, entryInputs, builtin)
452
- chainCache.set(builtin, chain)
453
- return chain
454
- }
455
- // (2) Locate which emitted chunk each user-imported builtin reached, via the per-output `inputs`.
456
- const findings = new Map<string, NodeBuiltinFinding>()
457
- for (const [outPath, out] of Object.entries(graph.chunks)) {
458
- for (const inputKey of out.modules) {
459
- if (userImported.has(inputKey)) {
460
- const chunk = basename(outPath)
461
- findings.set(`${inputKey}\0${chunk}`, {
462
- builtin: inputKey,
463
- chunk,
464
- chain: chainFor(inputKey),
465
- })
466
- }
467
- }
468
- }
469
- return [...findings.values()].sort((a, b) =>
470
- a.builtin === b.builtin ? a.chunk.localeCompare(b.chunk) : a.builtin.localeCompare(b.builtin),
471
- )
472
- }
473
-
474
- // ---------------------------------------------------------------------------------------------------
475
- // `server-only` poison-import guard (§3.3/§5.1). The complement to the `.server` convention + the
476
- // node-builtin guard: a module of PURE server logic with NO `node:` import (a secret-bearing constant,
477
- // a server-only API call) that an author wants to FAIL LOUD if it reaches the client opts in with a
478
- // side-effect `import "@nifrajs/web/server-only"` (Next's `import "server-only"`). On the SERVER build
479
- // the marker is an empty no-op; the CLIENT build detects - via the SAME Bun metafile graph the
480
- // node-builtin guard walks - any module that imports the marker AND lands in a client chunk, and fails
481
- // the build with the import chain. Graph-based (never the emitted text), so it survives minification.
482
- // ---------------------------------------------------------------------------------------------------
483
-
484
- /** The marker specifier an author imports to opt a module into the client-leak guard. Matched on the
485
- * import edge's *as-written* `original` first (the robust signal: it's exactly what the author typed,
486
- * before Bun resolves it to `src/server-only.ts` / `dist/server-only.js`). */
487
- export const SERVER_ONLY_MARKER = "@nifrajs/web/server-only"
488
-
489
- /** True when an import edge is the `server-only` marker import. Reads the as-written `original` (the
490
- * specifier the author typed); falls back to the resolved `path`'s basename so a pre-resolved edge
491
- * (no `original`) - or a workspace-relative resolution - is still recognised. */
492
- const isServerOnlyMarkerImport = (im: BunMetafileImport): boolean => {
493
- if (im.original === SERVER_ONLY_MARKER) return true
494
- // Defensive fallback: an edge that lost its `original` but resolved to the marker module file. The
495
- // marker is the only `server-only.{ts,js}` under `@nifrajs/web`, so the basename is unambiguous.
496
- const path = im.path
497
- return path !== undefined && /(^|\/)server-only\.[cm]?[jt]s$/.test(path)
498
- }
499
-
500
- /** One `server-only`-module-in-the-client finding: the offending module (the as-written marker-import
501
- * chain's tail before the marker), the emitted chunk it landed in, and the shortest USER-module import
502
- * chain that pulled it there (entry → … → the server-only module). */
503
- export interface ServerOnlyFinding {
504
- /** The emitted client chunk the server-only module landed in (basename). */
505
- readonly chunk: string
506
- /** The shortest import path from a user entry to the server-only module, as display labels:
507
- * `[entryFile, ...as-written specifiers…, "<module> (marked server-only)"]`. Mirrors
508
- * {@link NodeBuiltinFinding.chain}; the tail names the marked module so the message reads
509
- * `routes/x.tsx → ../secrets.ts (marked server-only)`. */
510
- readonly chain: readonly string[]
511
- }
512
-
513
- /**
514
- * BFS the metafile import graph for the SHORTEST user-module path that reaches a module which imports
515
- * the `server-only` marker, returning it as display labels `[entryFile, …as-written specifiers…,
516
- * "<module> (marked server-only)"]`. Mirrors {@link shortestBuiltinChain}: the frontier starts at
517
- * every `entryInput` so the chain begins at the file the dev wrote an `import` in; traversal crosses
518
- * only NON-`node:` user edges (no cycles via `seen`); it stops at the first node whose import set
519
- * contains the marker (the marked module - the actionable tail), labelling that node by the as-written
520
- * specifier the previous hop used to reach it. Pure - operates on the graph, never the emitted text.
521
- */
522
- function shortestServerOnlyChain(
523
- inputs: Readonly<Record<string, { readonly imports?: readonly BunMetafileImport[] }>>,
524
- entryInputs: readonly string[],
525
- markedModule: string,
526
- resolveTarget: (im: BunMetafileImport) => string | undefined,
527
- ): string[] {
528
- // The label for the marked module's tail: its as-written specifier (filled when we cross the edge
529
- // that reaches it) suffixed with `(marked server-only)`; the entry case uses the entry key itself.
530
- const tail = (label: string): string => `${label} (marked server-only)`
531
- // An entry that is ITSELF the marked module - the chain is just that one node.
532
- if (entryInputs.includes(markedModule)) return [tail(markedModule)]
533
- const seen = new Set<string>(entryInputs)
534
- let frontier: Array<{ node: string; chain: string[] }> = entryInputs.map((node) => ({
535
- node,
536
- chain: [node],
537
- }))
538
- while (frontier.length > 0) {
539
- const next: Array<{ node: string; chain: string[] }> = []
540
- for (const { node, chain } of frontier) {
541
- for (const im of inputs[node]?.imports ?? []) {
542
- // Resolve the edge's `path` to the matching INPUT-GRAPH KEY - in a real build the edge `path`
543
- // is absolute while the input keys are cwd-relative, so a raw equality/lookup would miss every
544
- // multi-hop user edge (and the chain would degrade to just the tail). The resolver maps both.
545
- const target = resolveTarget(im)
546
- if (target === undefined || target.startsWith("node:")) continue
547
- // The marked module reached via THIS edge → the chain ends here (label it as the marked tail
548
- // using the as-written specifier the author wrote, falling back to the resolved key).
549
- if (target === markedModule) return [...chain, tail(im.original ?? target)]
550
- if (seen.has(target)) continue
551
- seen.add(target)
552
- next.push({ node: target, chain: [...chain, im.original ?? target] })
553
- }
554
- }
555
- frontier = next
556
- }
557
- return [tail(markedModule)] // unreachable from any entry (defensive; a flagged module is reachable)
558
- }
559
-
560
- /**
561
- * Build a resolver from an import EDGE to its INPUT-GRAPH KEY. Bun's metafile records edge `path`s as
562
- * ABSOLUTE paths but keys `inputs` by CWD-RELATIVE paths, so a raw `inputs[im.path]` lookup misses
563
- * every user edge in a real build. The resolver: (1) an exact key match (the synthetic-metafile / unit
564
- * case); else (2) the input key the absolute path ends with (`…/secrets.ts` → `packages/web/.../secrets.ts`).
565
- * The longest-suffix match is taken so a shorter key can't shadow a more specific one. Pure.
566
- */
567
- function inputKeyResolver(
568
- inputs: Readonly<Record<string, unknown>>,
569
- ): (im: BunMetafileImport) => string | undefined {
570
- const keys = Object.keys(inputs)
571
- return (im) => {
572
- const path = im.path
573
- if (path === undefined) return undefined
574
- if (inputs[path] !== undefined) return path // exact (synthetic keys, or already-relative paths)
575
- let best: string | undefined
576
- for (const key of keys) {
577
- // `/<key>` so a suffix match aligns on a path boundary (never a partial segment), and the longest
578
- // such key wins (the most specific file).
579
- if (path.endsWith(`/${key}`) && (best === undefined || key.length > best.length)) best = key
580
- }
581
- return best
582
- }
583
- }
584
-
585
- /**
586
- * Scan a build's metafile for any module that opts into the `server-only` marker (a side-effect
587
- * `import "@nifrajs/web/server-only"`) yet landed in a CLIENT output chunk, returning a sorted, deduped
588
- * list of {@link ServerOnlyFinding}s. Mirrors {@link detectNodeBuiltinsInClient}: it reads the SAME
589
- * graph facts - which inputs import the marker (the "marked" modules), which chunk each landed in (the
590
- * per-output `inputs`), and the shortest import chain from a user entry to it. The marker module ITSELF
591
- * (which imports nothing) is excluded - only the modules that *opt in* are flagged. Pure + exported for
592
- * unit testing. Empty ⇒ clean.
593
- */
594
- export function detectServerOnlyInClient(
595
- graph: ClientModuleGraph,
596
- ): ReadonlyArray<ServerOnlyFinding> {
597
- const inputs = graph.modules
598
- // (1) The modules that import the marker - the ones the author opted into the guard. The marker
599
- // module itself is skipped: it's the import TARGET, not an opt-in (it imports nothing of its own).
600
- const marked = new Set<string>()
601
- for (const [inputKey, input] of Object.entries(inputs)) {
602
- if (isServerOnlyMarkerModule(inputKey)) continue
603
- if ((input.imports ?? []).some(isServerOnlyMarkerImport)) marked.add(inputKey)
604
- }
605
- if (marked.size === 0) return []
606
- // The entry inputs - the chain BFS starts here, so the reported path begins at the file the dev
607
- // wrote an `import` in. Same derivation as the node-builtin guard.
608
- const entryInputs = [
609
- ...new Set(
610
- Object.values(graph.chunks)
611
- .map((o) => o.entryPoint)
612
- .filter((e): e is string => e !== undefined && !e.startsWith("node:")),
613
- ),
614
- ]
615
- const resolveTarget = inputKeyResolver(inputs)
616
- const chainCache = new Map<string, readonly string[]>()
617
- const chainFor = (markedModule: string): readonly string[] => {
618
- const cached = chainCache.get(markedModule)
619
- if (cached !== undefined) return cached
620
- const chain = shortestServerOnlyChain(inputs, entryInputs, markedModule, resolveTarget)
621
- chainCache.set(markedModule, chain)
622
- return chain
623
- }
624
- // (2) Locate which emitted chunk each marked module reached, via the per-output `inputs`.
625
- const findings = new Map<string, ServerOnlyFinding>()
626
- for (const [outPath, out] of Object.entries(graph.chunks)) {
627
- for (const inputKey of out.modules) {
628
- if (marked.has(inputKey)) {
629
- const chunk = basename(outPath)
630
- findings.set(`${inputKey}\0${chunk}`, { chunk, chain: chainFor(inputKey) })
631
- }
632
- }
633
- }
634
- return [...findings.values()].sort((a, b) => {
635
- const am = a.chain[a.chain.length - 1] ?? ""
636
- const bm = b.chain[b.chain.length - 1] ?? ""
637
- return am === bm ? a.chunk.localeCompare(b.chunk) : am.localeCompare(bm)
638
- })
639
- }
640
-
641
- /** True when an INPUT graph key is the marker module file itself (`…/server-only.{ts,js}` under web).
642
- * Excluded from the "marked" set - the marker is the import target, not an opt-in module. */
643
- const isServerOnlyMarkerModule = (inputKey: string): boolean =>
644
- /(^|\/)server-only\.[cm]?[jt]s$/.test(inputKey)
645
-
646
- // ---------------------------------------------------------------------------------------------------
647
- // Guard MESSAGES - one owner for both production pipelines. The Bun build (below) and the Vite/Rollup
648
- // leak-guard plugin (plugins/vite-leak-guard.ts) both call these, so a leak reads IDENTICALLY whichever
649
- // bundler produced it. A second bundler must not grow a second, subtly-different wording of a security
650
- // error; that is exactly the "mostly ported" outcome the neutral graph seam exists to prevent.
651
- // ---------------------------------------------------------------------------------------------------
652
-
653
- /** The build-failing message for `node:` builtins that reached the client bundle. `undefined` ⇒ clean. */
654
- export function formatNodeBuiltinLeak(
655
- findings: ReadonlyArray<NodeBuiltinFinding>,
656
- ): string | undefined {
657
- if (findings.length === 0) return undefined
658
- const lines = findings.map((f) =>
659
- f.chain.length > 1
660
- ? ` - ${f.builtin} reached the client bundle via ${f.chain.join(" → ")} (chunk: ${f.chunk})`
661
- : ` - ${f.builtin} reached the client bundle via ${f.chunk}`,
662
- )
663
- return (
664
- `[nifra/web] Node built-in(s) in the client bundle - move them behind a server-only path ` +
665
- `(a loader/action runs on the server; import the \`node:\` module there, not at a route's ` +
666
- `top level):\n${lines.join("\n")}`
667
- )
668
- }
669
-
670
- /** The build-failing message for `server-only`-marked modules that reached the client. `undefined` ⇒ clean. */
671
- export function formatServerOnlyLeak(
672
- findings: ReadonlyArray<ServerOnlyFinding>,
673
- ): string | undefined {
674
- if (findings.length === 0) return undefined
675
- const lines = findings.map((f) =>
676
- f.chain.length > 1
677
- ? ` - server-only module reached the client bundle via ${f.chain.join(" → ")} (chunk: ${f.chunk})`
678
- : ` - server-only module reached the client bundle via ${f.chunk}`,
679
- )
680
- return (
681
- `[nifra/web] server-only module(s) in the client bundle - a module marked ` +
682
- `\`import "${SERVER_ONLY_MARKER}"\` reached the browser. Move it behind a server-only path ` +
683
- `(reach it via a loader/action, or rename it \`*.server.ts\`), so its server logic never ships ` +
684
- `to the client:\n${lines.join("\n")}`
685
- )
686
- }
687
-
688
- const basename = (path: string): string => path.slice(path.lastIndexOf("/") + 1)
689
- /** `[name]` Bun derives for an entrypoint: basename without extension (`users/[id].tsx` → `[id]`). */
690
- const entryName = (file: string): string => {
691
- const base = basename(file)
692
- const dot = base.lastIndexOf(".")
693
- return dot === -1 ? base : base.slice(0, dot)
694
- }
695
-
696
- // ---------------------------------------------------------------------------------------------------
697
- // Bundle-size report (`nifra build --report`). The raw byte + gzip size of each emitted chunk lets a
698
- // dev catch a bundle regression at build time. Pure aggregation/formatting helpers (no fs, no Bun
699
- // build) so they're unit-testable in isolation; the orchestrator below wires them to real outputs.
700
- // ---------------------------------------------------------------------------------------------------
701
-
702
- /** One emitted chunk's measured size, in raw bytes + gzipped bytes (over-the-wire weight). */
703
- export interface ChunkSize {
704
- /** The emitted file's basename (e.g. `index-abc123.js`). */
705
- readonly name: string
706
- /** Raw byte length of the file. */
707
- readonly bytes: number
708
- /** Gzipped byte length (what the client actually downloads, modulo brotli). */
709
- readonly gzip: number
710
- }
711
-
712
- /** A whole build's size report - every chunk (largest first) + the totals. */
713
- export interface SizeReport {
714
- /** Per-chunk sizes, sorted biggest gzip first (the regression you want to see at the top). */
715
- readonly chunks: readonly ChunkSize[]
716
- /** Sum of every chunk's raw bytes. */
717
- readonly totalBytes: number
718
- /** Sum of every chunk's gzip bytes. */
719
- readonly totalGzip: number
720
- }
721
-
722
- /**
723
- * Aggregate a list of measured chunks into a {@link SizeReport}: sort biggest-gzip-first (ties broken
724
- * by raw bytes, then name for stable output) and sum the totals. Pure - the measurement (reading the
725
- * file + `Bun.gzipSync`) happens in the orchestrator; this is the deterministic, unit-testable core.
726
- */
727
- export function aggregateSizeReport(chunks: readonly ChunkSize[]): SizeReport {
728
- const sorted = [...chunks].sort(
729
- (a, b) => b.gzip - a.gzip || b.bytes - a.bytes || a.name.localeCompare(b.name),
730
- )
731
- let totalBytes = 0
732
- let totalGzip = 0
733
- for (const c of sorted) {
734
- totalBytes += c.bytes
735
- totalGzip += c.gzip
736
- }
737
- return { chunks: sorted, totalBytes, totalGzip }
738
- }
739
-
740
- /** Human-readable byte count: `B`/`KB`/`MB` with one decimal above 1 KB (e.g. `12.3 KB`). Pure. */
741
- export function formatBytes(bytes: number): string {
742
- if (bytes < 1024) return `${bytes} B`
743
- if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
744
- return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
745
- }
746
-
747
- /**
748
- * Render a {@link SizeReport} as a terse aligned table (biggest first) with a totals row - the text
749
- * `nifra build --report` prints. Pure (string in, string out) so the formatting is unit-testable.
750
- */
751
- export function renderSizeReport(report: SizeReport): string {
752
- const rows = report.chunks.map((c) => ({
753
- name: c.name,
754
- raw: formatBytes(c.bytes),
755
- gz: formatBytes(c.gzip),
756
- }))
757
- // Column widths from the header + every row + the totals label, so the table never clips a value.
758
- const nameW = Math.max(5, ...rows.map((r) => r.name.length), "Total".length)
759
- const rawW = Math.max(3, ...rows.map((r) => r.raw.length), formatBytes(report.totalBytes).length)
760
- const gzW = Math.max(4, ...rows.map((r) => r.gz.length), formatBytes(report.totalGzip).length)
761
- const padEnd = (s: string, w: number): string => s + " ".repeat(Math.max(0, w - s.length))
762
- const padStart = (s: string, w: number): string => " ".repeat(Math.max(0, w - s.length)) + s
763
- const line = (name: string, raw: string, gz: string): string =>
764
- ` ${padEnd(name, nameW)} ${padStart(raw, rawW)} ${padStart(gz, gzW)}`
765
- const out = [
766
- line("Chunk", "Raw", "Gzip"),
767
- line("-".repeat(nameW), "-".repeat(rawW), "-".repeat(gzW)),
768
- ...rows.map((r) => line(r.name, r.raw, r.gz)),
769
- line("Total", formatBytes(report.totalBytes), formatBytes(report.totalGzip)),
770
- ]
771
- return out.join("\n")
772
- }
773
-
774
- // ---------------------------------------------------------------------------------------------------
775
- // Server-manifest drift detection (#7). `server-manifest.ts` is a committed generated file: it bakes
776
- // the route list + the client-entry hash for a disk-less worker (`generateServerManifest`). If `routes/`
777
- // changes but the manifest isn't regenerated, the worker serves a stale route table - a silent edge
778
- // break. These pure helpers diff the COMMITTED manifest source against the freshly-discovered routes so
779
- // `nifra check` (and `buildServer`) can fail with a named, actionable error before the drift ships.
780
- // ---------------------------------------------------------------------------------------------------
781
-
782
- /** A drift finding between a committed server-manifest and the live `routes/` tree. */
783
- export interface ManifestDrift {
784
- /** Route files present in `routes/` but ABSENT from the committed manifest (the manifest is stale -
785
- * the new route won't be served by the worker). */
786
- readonly missing: readonly string[]
787
- /** Route files the committed manifest imports that no longer exist in `routes/` (a deleted/renamed
788
- * route still wired into the worker - a build/runtime break). */
789
- readonly extra: readonly string[]
790
- }
791
-
792
- // The route-relative file keys the generated manifest's route map declares. Both shapes are matched by
793
- // their VALUE: eager ` "routes/x.tsx": m0,` and lazy ` "routes/x.tsx": () => import("./routes/x"),`.
794
- // The KEY (not the import specifier) is the route identity `discoverRoutes` produces, extension and all;
795
- // the import specifier is emitted extensionless (so a bare `tsc` compiles the manifest) and could not be
796
- // mapped back to the exact `.tsx`/`.ts` key. The baked `routeStyles` JSON is a single line, so its keys
797
- // are never at an indented line start and never match. `[ \t]+` (not `\s+`) so the `m` anchor stays on
798
- // the same line and one route-map entry cannot span two lines of source.
799
- const MANIFEST_ROUTE_ENTRY = /^[ \t]+(["'])([^"'\n]+)\1\s*:\s*(?:m\d+\b|\(\s*\)\s*=>\s*import\b)/gm
800
- // The lazy shape declares `const loaders`, the eager shape `const modules`. Anchored at a line start,
801
- // NOT `.includes("const loaders =")`: the real emit is `const loaders: Record<...> = {`, whose `:` type
802
- // annotation sits between the name and the `=`, so the ` =` substring never matched and every lazy
803
- // manifest was re-synced as eager (silently converting `import()` splitting into a single eager bundle).
804
- const MANIFEST_IS_LAZY = /^const loaders\b/m
805
- // The baked client-entry line: `export const clientEntry = "…"`.
806
- const MANIFEST_CLIENT_ENTRY = /export\s+const\s+clientEntry\s*=\s*["']([^"']+)["']/
807
-
808
- /**
809
- * Extract the route-relative file list a committed server-manifest declares, as the same
810
- * `routes/`-relative keys `discoverRoutes` produces (e.g. `docs/index.tsx`). Reads the route map's KEYS,
811
- * which carry the file extension and no directory prefix - exactly discovery's shape - so the result is
812
- * independent of the specifier prefix the manifest happened to import with. The `@nifrajs/web` import and
813
- * the baked `clientEntry`/`styles`/`routeStyles` lines carry no route-map entry and are ignored.
814
- *
815
- * `_routesPrefix` is accepted for call-site compatibility but no longer needed: the keys are already
816
- * prefix-free. Pure - operates on source text.
817
- */
818
- export function parseManifestRouteFiles(source: string, _routesPrefix?: string): string[] {
819
- const files = new Set<string>()
820
- for (const match of source.matchAll(MANIFEST_ROUTE_ENTRY)) {
821
- if (match[2] !== undefined) files.add(match[2])
822
- }
823
- return [...files].sort()
824
- }
825
-
826
- /** The baked `clientEntry` URL in a committed server-manifest, or `undefined` if absent. Pure. */
827
- export function parseManifestClientEntry(source: string): string | undefined {
828
- return MANIFEST_CLIENT_ENTRY.exec(source)?.[1]
829
- }
830
-
831
- // The baked asset lines `generateServerManifest` emits: `export const styles = […]` then
832
- // `export const routeStyles = {…}`, each a JSON literal running to the next `export const`. Extracted
833
- // with `indexOf` slicing rather than a lazy `[\s\S]*?` regex: the slice is multi-line by nature (a
834
- // formatter can wrap a long `routeStyles` map, and missing it would silently DROP every baked
835
- // stylesheet on re-sync), and a lazy scan re-walks the file per unterminated opener - quadratic.
836
- function bakedManifestLiteral(source: string, name: string): string | undefined {
837
- const opener = `export const ${name} = `
838
- const start = source.indexOf(opener)
839
- if (start === -1) return undefined
840
- const from = start + opener.length
841
- const end = source.indexOf("\nexport const ", from)
842
- return end === -1 ? undefined : source.slice(from, end)
843
- }
844
-
845
- /** Parse a baked JSON literal captured from a committed manifest, tolerating a formatter's TRAILING COMMAS
846
- * (biome/prettier add one when wrapping a multi-line array/object; strict `JSON.parse` would reject it).
847
- * Only a comma immediately before a closing `]`/`}` is stripped, so commas inside string values are safe. */
848
- function parseManifestLiteral(raw: string): unknown {
849
- return JSON.parse(raw.replace(/,(\s*[\]}])/g, "$1"))
850
- }
851
-
852
- /** The baked top-level `styles` array in a committed server-manifest (empty if absent/unparseable). Pure. */
853
- export function parseManifestStyles(source: string): string[] {
854
- const raw = bakedManifestLiteral(source, "styles")
855
- if (raw === undefined) return []
856
- try {
857
- const value = parseManifestLiteral(raw)
858
- return Array.isArray(value) ? (value as string[]) : []
859
- } catch {
860
- return []
861
- }
862
- }
863
-
864
- /** The baked per-route `routeStyles` map in a committed server-manifest (empty if absent/unparseable). Pure. */
865
- export function parseManifestRouteStyles(source: string): Record<string, string[]> {
866
- const raw = bakedManifestLiteral(source, "routeStyles")
867
- if (raw === undefined) return {}
868
- try {
869
- const value = parseManifestLiteral(raw)
870
- return value !== null && typeof value === "object" && !Array.isArray(value)
871
- ? (value as Record<string, string[]>)
872
- : {}
873
- } catch {
874
- return {}
875
- }
876
- }
877
-
878
340
  /**
879
341
  * Re-emit a committed server-manifest from a freshly-discovered route tree, PRESERVING its baked
880
342
  * client-asset references (`clientEntry` / `styles` / `routeStyles`) and its eager-vs-lazy shape. This is
@@ -904,49 +366,6 @@ export function resyncServerManifestSource(
904
366
  * supplies both file lists (the committed source is parsed via {@link parseManifestRouteFiles}; the
905
367
  * fresh list comes from `discoverRoutes`). Lists need not be pre-sorted; the result is sorted.
906
368
  */
907
- export function diffManifestRoutes(
908
- manifestFiles: readonly string[],
909
- discoveredFiles: readonly string[],
910
- ): ManifestDrift {
911
- const inManifest = new Set(manifestFiles)
912
- const inRoutes = new Set(discoveredFiles)
913
- const missing = discoveredFiles.filter((f) => !inManifest.has(f)).sort()
914
- const extra = manifestFiles.filter((f) => !inRoutes.has(f)).sort()
915
- return { missing, extra }
916
- }
917
-
918
- /** True when a drift report is clean (no missing + no extra routes). */
919
- export function isManifestInSync(drift: ManifestDrift): boolean {
920
- return drift.missing.length === 0 && drift.extra.length === 0
921
- }
922
-
923
- /**
924
- * Format a {@link ManifestDrift} as a named, actionable error message, or `undefined` when in sync.
925
- * Names the exact missing/extra routes + the one fix (regenerate the manifest by re-running the build).
926
- * `manifestPath` is shown for the dev to locate the stale file. Pure.
927
- */
928
- export function formatManifestDrift(
929
- drift: ManifestDrift,
930
- manifestPath = "server-manifest.ts",
931
- ): string | undefined {
932
- if (isManifestInSync(drift)) return undefined
933
- const lines: string[] = [
934
- `[nifra/web] server-manifest drift - \`${manifestPath}\` is out of sync with routes/.`,
935
- ]
936
- if (drift.missing.length > 0) {
937
- lines.push(
938
- ` Missing (in routes/, not in the manifest - these routes won't be served): ${drift.missing.join(", ")}`,
939
- )
940
- }
941
- if (drift.extra.length > 0) {
942
- lines.push(
943
- ` Extra (imported by the manifest, gone from routes/ - a dangling import): ${drift.extra.join(", ")}`,
944
- )
945
- }
946
- lines.push(" Fix: re-run the build to regenerate the server manifest, then commit it.")
947
- return lines.join("\n")
948
- }
949
-
950
369
  /**
951
370
  * Build the client bundle for a file-routed app. Writes the hashed assets + `manifest.json` to
952
371
  * `outDir` and returns the manifest. Throws (with the bundler logs) on build failure - never
@@ -958,6 +377,24 @@ export async function buildClient(options: BuildClientOptions): Promise<BuildMan
958
377
  await assertIdentityParity(root)
959
378
  const resolve = options.resolve ?? ((file: string) => `${routesDir}/${file}`)
960
379
  const publicPath = options.publicPath ?? "/assets/"
380
+
381
+ // outDir inside publicDir folds the build output back into the public copy on the next run (the
382
+ // bundle lands at public/assets/assets/*), and the dev/prod parity assert then throws on paths no
383
+ // one authored. Reject the overlap up front - no copy behavior is correct for this layout, and the
384
+ // parity symptom names neither option so the cause is unrecoverable from the message.
385
+ const publicRoot = options.publicDir === false ? undefined : (options.publicDir ?? "public")
386
+ if (publicRoot !== undefined) {
387
+ const publicResolved = resolvePath(publicRoot)
388
+ const outResolved = resolvePath(outDir)
389
+ if (outResolved === publicResolved || outResolved.startsWith(publicResolved + sep)) {
390
+ throw new Error(
391
+ `[nifra/web] outDir (${outResolved}) is inside publicDir (${publicResolved}). The public ` +
392
+ `copy would fold the build output back into itself. Move outDir outside publicDir, or ` +
393
+ `pass publicDir: false if the app has no hand-authored static files.`,
394
+ )
395
+ }
396
+ }
397
+
961
398
  mkdirSync(outDir, { recursive: true })
962
399
 
963
400
  const routeManifest = discoverRoutes(routesDir)
@@ -1015,6 +452,7 @@ export async function buildClient(options: BuildClientOptions): Promise<BuildMan
1015
452
  ...buildExtras,
1016
453
  minify: options.minify ?? true,
1017
454
  plugins: [
455
+ ...declaredSingleCopyPlugins(root),
1018
456
  reactDedupePlugin(routesDir),
1019
457
  preactDedupePlugin(routesDir),
1020
458
  svelteDedupePlugin(routesDir),
@@ -1105,12 +543,17 @@ export async function buildClient(options: BuildClientOptions): Promise<BuildMan
1105
543
  const base = basename(path)
1106
544
  return base.slice(0, base.lastIndexOf("-")) // strip `-${hash}.css`
1107
545
  }
546
+ // `css` is the union of every emitted stylesheet, matching the Vite pipeline (build-vite.ts) so the
547
+ // field means the same thing on both bundlers. The bootstrap aggregate lists first, preserving the
548
+ // fallback link order `createWebApp` uses (whole-app stylesheet before any route-scoped one); the
549
+ // per-route `cssBundle` outputs follow. Dropping those per-route outputs (the old `aggregate`-only
550
+ // shape) left them in `manifest.assets` but not `css`, so a route-scoped stylesheet normalized to
551
+ // `asset:css` and tripped the module-graph parity contract - a bundler-dependent divergence.
1108
552
  const cssAssets = result.outputs.filter((o) => o.kind === "asset" && o.path.endsWith(".css"))
1109
- const aggregate = cssAssets
1110
- .filter((o) => cssNameOf(o.path) === bootstrapName)
1111
- .map((o) => toUrl(o.path))
1112
- const css: readonly string[] =
1113
- aggregate.length > 0 ? aggregate : cssAssets.map((o) => toUrl(o.path))
553
+ const css: readonly string[] = [
554
+ ...cssAssets.filter((o) => cssNameOf(o.path) === bootstrapName),
555
+ ...cssAssets.filter((o) => cssNameOf(o.path) !== bootstrapName),
556
+ ].map((o) => toUrl(o.path))
1114
557
 
1115
558
  // CSS - per-route: each route/layout file is its own entrypoint, so the build metafile records its
1116
559
  // `cssBundle` - exactly the CSS that file's subtree uses (shared-component CSS is inlined into each
@@ -1132,7 +575,12 @@ export async function buildClient(options: BuildClientOptions): Promise<BuildMan
1132
575
  return [...new Set(urls)]
1133
576
  }
1134
577
  const routeStyles: Record<string, readonly string[]> = {}
1135
- if (css.length > 0 && cssByEntry.size > 0) {
578
+ // A compiler plugin may attach a global stylesheet to the generated bootstrap rather than to a
579
+ // route entry (StyleX's atomic CSS is one example). In that shape there is no useful per-route map:
580
+ // emitting `routeId: []` would suppress the aggregate `styles` fallback in `createWebApp` and render
581
+ // SSR pages unstyled. Keep routeStyles absent until at least one authored route entry owns CSS.
582
+ const routeCssEntries = [...cssByEntry.keys()].filter((entry) => entry !== resolvePath(entryFile))
583
+ if (css.length > 0 && routeCssEntries.length > 0) {
1136
584
  for (const route of routeManifest.routes) {
1137
585
  routeStyles[route.id] = stylesFor([
1138
586
  ...route.layoutIds.map((id) => routeManifest.layouts[id]?.file ?? ""),
@@ -1210,13 +658,6 @@ export interface BuildServerOptions {
1210
658
  }
1211
659
 
1212
660
  /** The built worker bundle - point your `wrangler.toml`'s `main` at `worker`. */
1213
- export interface ServerBuild {
1214
- /** Path to the bundled, self-contained worker entry. */
1215
- readonly worker: string
1216
- /** Paths of every emitted output (entry + any code-split chunks) - what to ship to the platform. */
1217
- readonly outputs: readonly string[]
1218
- }
1219
-
1220
661
  /**
1221
662
  * react-dom's `exports["./server"]` maps the `bun` condition to a Bun-API server build that crashes
1222
663
  * on workerd, and `Bun.build` always applies the `bun` condition (it wins over `workerd`/`edge-light`),
@@ -1244,7 +685,7 @@ const reactDomEdgePlugin = (from: string): BunPlugin => ({
1244
685
  * condition-agnostic, so pinning it doesn't disturb the edge/browser/server conditions that select
1245
686
  * react-dom's build.
1246
687
  */
1247
- const REACT_DEDUPE_SPECS = ["react", "react/jsx-runtime", "react/jsx-dev-runtime"] as const
688
+ const REACT_DEDUPE_SPECS = dedupePolicyFor("react").bunSpecs ?? []
1248
689
  export const reactDedupePlugin = (from: string): BunPlugin => ({
1249
690
  name: "nifra-react-dedupe",
1250
691
  setup(build) {
@@ -1272,13 +713,7 @@ export const reactDedupePlugin = (from: string): BunPlugin => ({
1272
713
  * skipped). Preact core is condition-agnostic, so pinning it doesn't disturb any condition selection - and
1273
714
  * unlike react-dom there is no edge-vs-server build to preserve, so pinning the subpaths is safe.
1274
715
  */
1275
- const PREACT_DEDUPE_SPECS = [
1276
- "preact",
1277
- "preact/hooks",
1278
- "preact/compat",
1279
- "preact/jsx-runtime",
1280
- "preact/jsx-dev-runtime",
1281
- ] as const
716
+ const PREACT_DEDUPE_SPECS = dedupePolicyFor("preact").bunSpecs ?? []
1282
717
  export const preactDedupePlugin = (from: string): BunPlugin => ({
1283
718
  name: "nifra-preact-dedupe",
1284
719
  setup(build) {
@@ -1295,6 +730,7 @@ export const preactDedupePlugin = (from: string): BunPlugin => ({
1295
730
  },
1296
731
  })
1297
732
 
733
+ const SVELTE_DEDUPE_PATTERN = dedupePolicyFor("svelte").bunPattern ?? /^svelte($|\/internal\/)/
1298
734
  /**
1299
735
  * Dedupe Svelte to a single copy - the Svelte analogue of `reactDedupePlugin`/`preactDedupePlugin`, closing
1300
736
  * the same class of bug for Svelte (which had NO build-time dedup before). A workspace- or file-linked
@@ -1313,7 +749,7 @@ export const preactDedupePlugin = (from: string): BunPlugin => ({
1313
749
  export const svelteDedupePlugin = (from: string): BunPlugin => ({
1314
750
  name: "nifra-svelte-dedupe",
1315
751
  setup(build) {
1316
- build.onResolve({ filter: /^svelte($|\/internal\/)/ }, (args) => {
752
+ build.onResolve({ filter: SVELTE_DEDUPE_PATTERN }, (args) => {
1317
753
  try {
1318
754
  return { path: Bun.resolveSync(args.path, from) }
1319
755
  } catch {
@@ -1323,6 +759,21 @@ export const svelteDedupePlugin = (from: string): BunPlugin => ({
1323
759
  },
1324
760
  })
1325
761
 
762
+ /**
763
+ * The app's declared single-copy rule, applied to the bundle.
764
+ *
765
+ * The three plugins above cover the framework runtimes by name, which is the case that bites hardest
766
+ * and needs no configuration. This one covers what a name list cannot know: `@nifrajs/*` (two copies
767
+ * of `@nifrajs/core` are two `Server` classes, and `Server` has private members, so `.merge()` stops
768
+ * accepting the other's app) and whatever else the app named in
769
+ * `"nifra": { "singleCopy": [...] }`.
770
+ *
771
+ * Nothing declared means no plugin at all - the plan does filesystem work, so it is not built for the
772
+ * majority of apps that have a single copy of everything and never think about this.
773
+ */
774
+ const declaredSingleCopyPlugins = (root: string): readonly BunPlugin[] =>
775
+ readSingleCopyDeclaration(root) === undefined ? [] : [declaredSingleCopyPlugin({ cwd: root })]
776
+
1326
777
  /**
1327
778
  * Remix-style `.server` convention for the CLIENT build. A module named `*.server.ts(x)` (`db.server.ts`,
1328
779
  * `auth.server.ts`, …) is server-only - empty it in the browser bundle so its (possibly `node:` / native /
@@ -1439,6 +890,7 @@ export async function buildServer(options: BuildServerOptions): Promise<ServerBu
1439
890
  // Lazy → one chunk per route (loaded on first request); eager → a single self-contained file.
1440
891
  splitting: lazy,
1441
892
  plugins: [
893
+ ...declaredSingleCopyPlugins(resolvePath(dirname(routesDir))),
1442
894
  ...(edge ? [reactDomEdgePlugin(entryDir), solidWebServerPlugin(entryDir)] : []),
1443
895
  reactDedupePlugin(entryDir),
1444
896
  preactDedupePlugin(entryDir),
@@ -1466,15 +918,6 @@ export async function buildServer(options: BuildServerOptions): Promise<ServerBu
1466
918
  // …) - so we GENERATE it here (per target) instead of asking each app to ship five near-identical files.
1467
919
  // ===================================================================================================
1468
920
 
1469
- /** A deploy target `nifra build --target <t>` can emit. `static` is pure SSG (no server). */
1470
- export const BUILD_TARGETS = ["bun", "node", "deno", "cf-pages", "vercel", "static"] as const
1471
- export type BuildTarget = (typeof BUILD_TARGETS)[number]
1472
-
1473
- /** A type guard narrowing an arbitrary string to a {@link BuildTarget}. */
1474
- export function isBuildTarget(value: string): value is BuildTarget {
1475
- return (BUILD_TARGETS as readonly string[]).includes(value)
1476
- }
1477
-
1478
921
  /**
1479
922
  * Codegen the per-target **server entry** module (source text) for `buildServer` to bundle. It imports
1480
923
  * the app's `adapter` (from `framework.ts`), the optional `backend` (from `backend.ts`), and the
@@ -1662,15 +1105,6 @@ export function generateServerEntry(options: {
1662
1105
  return `${lines.join("\n")}\n`
1663
1106
  }
1664
1107
 
1665
- /** The Bun.build `target` each deploy target compiles its server with (mirrors `buildServer`'s docs). */
1666
- const SERVER_BUILD_TARGET: Record<Exclude<BuildTarget, "static">, "browser" | "node" | "bun"> = {
1667
- "cf-pages": "browser", // edge conditions (workerd/edge-light)
1668
- vercel: "browser", // Vercel Edge runtime
1669
- deno: "browser", // Deno's Web-standard runtime runs the edge bundle
1670
- node: "node", // node:* external; react-dom → its Node SSR build
1671
- bun: "bun", // Bun's flagship runtime
1672
- }
1673
-
1674
1108
  /** Measure each emitted output file's raw + gzip size. Reads the file off disk and gzips it with
1675
1109
  * `Bun.gzipSync` (the over-the-wire weight). Async only because it reads files; the aggregation is the
1676
1110
  * pure {@link aggregateSizeReport}. */
@@ -1762,41 +1196,6 @@ export interface BuildTargetResult {
1762
1196
  * Note: the heavier platform wrappers (`.vercel/output` v3 layout, wrangler ISR `find_additional_modules`)
1763
1197
  * remain app-owned scripts; this command targets the common single-bundle deploys. See the CLI docs.
1764
1198
  */
1765
- /**
1766
- * A build-tool STRATEGY for {@link buildTargetWith} - the two bundling steps, and nothing else. Everything
1767
- * around them (server-entry codegen, deploy assembly, prerender, size report) is bundler-agnostic and
1768
- * lives in `buildTargetWith`, so a second bundler (Vite) is this interface, not a second orchestrator.
1769
- *
1770
- * Plugin lists are `readonly unknown[]` because a Bun plugin and a Vite plugin are different types; each
1771
- * strategy casts to its own. `buildTargetWith` only forwards them.
1772
- */
1773
- export interface Bundler {
1774
- /** Build the client bundle → the shared {@link BuildManifest}. */
1775
- buildClient(input: {
1776
- readonly routesDir: string
1777
- readonly outDir: string
1778
- readonly clientModule: string
1779
- readonly plugins?: readonly unknown[]
1780
- readonly conditions?: readonly string[]
1781
- readonly define?: Readonly<Record<string, string>>
1782
- readonly publicDir?: string | false
1783
- readonly publicEnvPrefix?: string
1784
- /** Project root (Vite needs it; the Bun strategy ignores it). */
1785
- readonly root?: string
1786
- }): Promise<BuildManifest>
1787
- /** Build the server worker → the shared {@link ServerBuild}. */
1788
- buildServer(input: {
1789
- readonly routesDir: string
1790
- readonly serverEntry: string
1791
- readonly outDir: string
1792
- readonly clientEntry: string
1793
- readonly target: "browser" | "node" | "bun"
1794
- readonly plugins?: readonly unknown[]
1795
- readonly define?: Readonly<Record<string, string>>
1796
- readonly root?: string
1797
- }): Promise<ServerBuild>
1798
- }
1799
-
1800
1199
  /** The default (Bun) strategy - `buildClient`/`buildServer` from this module. */
1801
1200
  export const bunBundler: Bundler = {
1802
1201
  buildClient: (input) =>
@@ -1842,6 +1241,7 @@ export async function buildTargetWith(
1842
1241
  bundler: Bundler,
1843
1242
  ): Promise<BuildTargetResult> {
1844
1243
  const { routesDir, outDir, workDir } = options
1244
+ const targetPlan = planBuildTarget(target, outDir)
1845
1245
  const { rmSync } = await import("node:fs")
1846
1246
  rmSync(outDir, { recursive: true, force: true })
1847
1247
  rmSync(workDir, { recursive: true, force: true })
@@ -1872,7 +1272,7 @@ export async function buildTargetWith(
1872
1272
  writeFileSync(`${assetsDir}/manifest.json`, JSON.stringify(client, null, 2))
1873
1273
  }
1874
1274
 
1875
- if (target === "static") {
1275
+ if (targetPlan.kind === "static") {
1876
1276
  if (options.prerenderApp === undefined) {
1877
1277
  throw new Error(
1878
1278
  "[nifra/web] buildTarget(static) requires `prerenderApp` (a factory `(client) => createWebApp`)",
@@ -1904,7 +1304,7 @@ export async function buildTargetWith(
1904
1304
  target,
1905
1305
  outDir,
1906
1306
  client,
1907
- run: `static site → ${outDir} (serve the directory with any static host)`,
1307
+ run: targetPlan.run,
1908
1308
  size,
1909
1309
  }
1910
1310
  }
@@ -1924,13 +1324,12 @@ export async function buildTargetWith(
1924
1324
  ...(publicFiles.length > 0 ? { publicFiles } : {}),
1925
1325
  }),
1926
1326
  )
1927
- const serverTarget = SERVER_BUILD_TARGET[target]
1928
1327
  const { worker } = await bundler.buildServer({
1929
1328
  routesDir,
1930
1329
  serverEntry: serverEntryPath,
1931
1330
  outDir: `${workDir}/server`,
1932
1331
  clientEntry: client.entry,
1933
- target: serverTarget,
1332
+ target: targetPlan.serverTarget,
1934
1333
  ...(options.serverPlugins ? { plugins: options.serverPlugins } : {}),
1935
1334
  define: { "process.env.NODE_ENV": '"production"', ...(options.define ?? {}) },
1936
1335
  root: resolvePath(dirname(routesDir)),
@@ -1938,9 +1337,8 @@ export async function buildTargetWith(
1938
1337
 
1939
1338
  // (3) Assemble the deploy dir for the target.
1940
1339
  const { cpSync } = await import("node:fs")
1941
- let run: string
1942
- if (target === "cf-pages") {
1943
- cpSync(worker, `${outDir}/_worker.js`)
1340
+ if (targetPlan.target === "cf-pages") {
1341
+ cpSync(worker, `${outDir}/${targetPlan.outputFile}`)
1944
1342
  // The app's real patterns, so a directory is only collapsed into a glob once the route table
1945
1343
  // proves nothing can be served beneath it.
1946
1344
  const rules = cloudflareRouteRules(
@@ -1960,26 +1358,18 @@ export async function buildTargetWith(
1960
1358
  "worker via ASSETS instead of the CDN directly - correct, just one invocation each.",
1961
1359
  )
1962
1360
  }
1963
- run = `Cloudflare Pages → ${outDir} (deploy: wrangler pages deploy ${basename(outDir)})`
1964
- } else if (target === "vercel") {
1965
- cpSync(worker, `${outDir}/index.js`)
1966
- run = `Vercel edge function → ${outDir}/index.js (wrap with your vercel.json or Build Output API)`
1361
+ } else if (targetPlan.target === "vercel") {
1362
+ cpSync(worker, `${outDir}/${targetPlan.outputFile}`)
1967
1363
  } else {
1968
- // bun / node / deno: the runnable server bundle next to its /assets.
1969
- cpSync(worker, `${outDir}/server.js`)
1970
- const cmd =
1971
- target === "node"
1972
- ? `node ${basename(outDir)}/server.js`
1973
- : `${target} ${basename(outDir)}/server.js`
1974
- run = `${target} server → ${outDir} (run: ${cmd})`
1364
+ cpSync(worker, `${outDir}/${targetPlan.outputFile}`)
1975
1365
  }
1976
1366
  rmSync(workDir, { recursive: true, force: true })
1977
1367
 
1978
1368
  // Size report over the client assets + the server bundle (its parse cost matters on the edge).
1979
1369
  const clientPaths = client.assets.map((u) => assetUrlToPath(u, assetsDir))
1980
- const serverPath = `${outDir}/${target === "cf-pages" ? "_worker.js" : target === "vercel" ? "index.js" : "server.js"}`
1370
+ const serverPath = `${outDir}/${targetPlan.outputFile}`
1981
1371
  const size = aggregateSizeReport(await measureOutputs([...clientPaths, serverPath]))
1982
- return { target, outDir, client, run, size }
1372
+ return { target, outDir, client, run: targetPlan.run, size }
1983
1373
  }
1984
1374
 
1985
1375
  /** Map a client asset URL (`/assets/x-hash.js`) back to its on-disk path under `assetsDir`. The