@nifrajs/web 3.1.0 → 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 (114) hide show
  1. package/dist/build-plan.d.ts +228 -0
  2. package/dist/build-plan.d.ts.map +1 -0
  3. package/dist/build-plan.js +514 -0
  4. package/dist/build-plan.js.map +1 -0
  5. package/dist/build-vite.d.ts +2 -1
  6. package/dist/build-vite.d.ts.map +1 -1
  7. package/dist/build-vite.js.map +1 -1
  8. package/dist/build.d.ts +2 -197
  9. package/dist/build.d.ts.map +1 -1
  10. package/dist/build.js +61 -502
  11. package/dist/build.js.map +1 -1
  12. package/dist/chunk-names.d.ts.map +1 -1
  13. package/dist/chunk-names.js +3 -2
  14. package/dist/chunk-names.js.map +1 -1
  15. package/dist/dev-ssr-graph.d.ts.map +1 -1
  16. package/dist/dev-ssr-graph.js +63 -28
  17. package/dist/dev-ssr-graph.js.map +1 -1
  18. package/dist/diagnostic.d.ts.map +1 -1
  19. package/dist/diagnostic.js +9 -2
  20. package/dist/diagnostic.js.map +1 -1
  21. package/dist/fonts-google.d.ts.map +1 -1
  22. package/dist/fonts-google.js +3 -1
  23. package/dist/fonts-google.js.map +1 -1
  24. package/dist/fs.d.ts.map +1 -1
  25. package/dist/fs.js +2 -1
  26. package/dist/fs.js.map +1 -1
  27. package/dist/index.d.ts +15 -609
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/index.js +22 -1935
  30. package/dist/index.js.map +1 -1
  31. package/dist/internal/codegen.d.ts +68 -0
  32. package/dist/internal/codegen.d.ts.map +1 -0
  33. package/dist/internal/codegen.js +317 -0
  34. package/dist/internal/codegen.js.map +1 -0
  35. package/dist/internal/head-merge.d.ts +2 -0
  36. package/dist/internal/head-merge.d.ts.map +1 -1
  37. package/dist/internal/head-merge.js +17 -1
  38. package/dist/internal/head-merge.js.map +1 -1
  39. package/dist/internal/parity.d.ts +7 -0
  40. package/dist/internal/parity.d.ts.map +1 -1
  41. package/dist/internal/parity.js +212 -30
  42. package/dist/internal/parity.js.map +1 -1
  43. package/dist/internal/render-document.d.ts +372 -0
  44. package/dist/internal/render-document.d.ts.map +1 -0
  45. package/dist/internal/render-document.js +744 -0
  46. package/dist/internal/render-document.js.map +1 -0
  47. package/dist/internal/runtime-contract.d.ts +48 -0
  48. package/dist/internal/runtime-contract.d.ts.map +1 -0
  49. package/dist/internal/runtime-contract.js +27 -0
  50. package/dist/internal/runtime-contract.js.map +1 -0
  51. package/dist/internal/web-app.d.ts +171 -0
  52. package/dist/internal/web-app.d.ts.map +1 -0
  53. package/dist/internal/web-app.js +844 -0
  54. package/dist/internal/web-app.js.map +1 -0
  55. package/dist/islands.d.ts +48 -0
  56. package/dist/islands.d.ts.map +1 -1
  57. package/dist/islands.js +79 -0
  58. package/dist/islands.js.map +1 -1
  59. package/dist/module-graph.d.ts.map +1 -1
  60. package/dist/module-graph.js +22 -9
  61. package/dist/module-graph.js.map +1 -1
  62. package/dist/nano.d.ts +125 -0
  63. package/dist/nano.d.ts.map +1 -0
  64. package/dist/nano.js +190 -0
  65. package/dist/nano.js.map +1 -0
  66. package/dist/plugins/css-modules.js +2 -2
  67. package/dist/plugins/css-modules.js.map +1 -1
  68. package/dist/plugins/kit.d.ts +12 -0
  69. package/dist/plugins/kit.d.ts.map +1 -1
  70. package/dist/plugins/kit.js +62 -12
  71. package/dist/plugins/kit.js.map +1 -1
  72. package/dist/plugins/postcss.js +2 -2
  73. package/dist/plugins/postcss.js.map +1 -1
  74. package/dist/plugins/scss.js +2 -2
  75. package/dist/plugins/scss.js.map +1 -1
  76. package/dist/plugins/stylex.d.ts +70 -0
  77. package/dist/plugins/stylex.d.ts.map +1 -0
  78. package/dist/plugins/stylex.js +166 -0
  79. package/dist/plugins/stylex.js.map +1 -0
  80. package/dist/plugins/svg.js +2 -2
  81. package/dist/plugins/svg.js.map +1 -1
  82. package/dist/router.d.ts +1 -1
  83. package/dist/router.js +23 -23
  84. package/dist/router.js.map +1 -1
  85. package/dist/vite.d.ts.map +1 -1
  86. package/dist/vite.js +26 -10
  87. package/dist/vite.js.map +1 -1
  88. package/package.json +39 -3
  89. package/src/build-plan.ts +741 -0
  90. package/src/build-vite.ts +1 -4
  91. package/src/build.ts +84 -716
  92. package/src/chunk-names.ts +3 -2
  93. package/src/dev-ssr-graph.ts +63 -28
  94. package/src/diagnostic.ts +14 -2
  95. package/src/fonts-google.ts +3 -1
  96. package/src/fs.ts +2 -1
  97. package/src/index.ts +73 -2705
  98. package/src/internal/codegen.ts +400 -0
  99. package/src/internal/head-merge.ts +19 -1
  100. package/src/internal/parity.ts +215 -35
  101. package/src/internal/render-document.ts +1116 -0
  102. package/src/internal/runtime-contract.ts +54 -0
  103. package/src/internal/web-app.ts +1114 -0
  104. package/src/islands.ts +85 -0
  105. package/src/module-graph.ts +23 -9
  106. package/src/nano.ts +246 -0
  107. package/src/plugins/css-modules.ts +2 -2
  108. package/src/plugins/kit.ts +65 -12
  109. package/src/plugins/postcss.ts +2 -2
  110. package/src/plugins/scss.ts +2 -2
  111. package/src/plugins/stylex.ts +298 -0
  112. package/src/plugins/svg.ts +2 -2
  113. package/src/router.ts +24 -24
  114. package/src/vite.ts +33 -9
@@ -0,0 +1,741 @@
1
+ import { basename as pathBasename } from "node:path"
2
+ import type { ClientModuleGraph } from "./module-graph.ts"
3
+
4
+ export interface BuildManifest {
5
+ /** URL of the client entry module (content-hashed). */
6
+ readonly entry: string
7
+ /** URLs of every emitted asset (entry + chunks) - for serving + preloading. */
8
+ readonly assets: readonly string[]
9
+ /** `routeId → [layout chunk URLs…, own chunk URL]` - the chunks a route needs, for `createWebApp`'s
10
+ * `routePreload` (`<link rel="modulepreload">` the matched route alongside the entry). Each route +
11
+ * layout is also a build entrypoint, so it gets a named chunk the bootstrap's lazy import dedupes to. */
12
+ readonly routes: Readonly<Record<string, readonly string[]>>
13
+ /** URL paths copied from `publicDir`, sorted. Lets the server entry serve them without scanning a
14
+ * directory per request, and lets an adapter that needs a file list (CDN upload, platform static
15
+ * assets) consume one. Omitted when there is no `public/`. */
16
+ readonly publicFiles?: readonly string[]
17
+ /** The app's bundled, content-hashed stylesheet(s) - the bootstrap's **aggregate** CSS (every
18
+ * `import './x.css'` reachable from the app). The complete stylesheet regardless of which file
19
+ * imported the CSS; the always-safe fallback `createWebApp` links when a route has no per-route entry
20
+ * in {@link routeStyles}. Omitted when the app imports no CSS. */
21
+ readonly css?: readonly string[]
22
+ /** `routeId → [chain CSS URLs]` - only the stylesheets the matched route's layout chain + own file
23
+ * actually use (Bun emits a per-entrypoint CSS bundle per route/layout, with shared-component CSS
24
+ * inlined into each consumer). `createWebApp` links these instead of the aggregate, so a page ships
25
+ * only its own CSS. A route is omitted (→ aggregate fallback) when its `[name]` collides with another
26
+ * route's basename (ambiguous CSS↔route) or the build emitted orphan shared-chunk CSS - correctness
27
+ * over minimality. Absent entirely when the app imports no CSS. */
28
+ readonly routeStyles?: Readonly<Record<string, readonly string[]>>
29
+ }
30
+
31
+ /** The built worker bundle - point your `wrangler.toml`'s `main` at `worker`. */
32
+ export interface ServerBuild {
33
+ /** Path to the bundled, self-contained worker entry. */
34
+ readonly worker: string
35
+ /** Paths of every emitted output (entry + any code-split chunks) - what to ship to the platform. */
36
+ readonly outputs: readonly string[]
37
+ }
38
+
39
+ // ===================================================================================================
40
+ // `nifra build --target` - package the engine above into one command that emits a full deploy dir.
41
+ //
42
+ // An app already declares everything the build needs through nifra's conventions: `adapter` +
43
+ // `clientModule` (nifra.config.ts / framework.ts), an optional `backend` (backend.ts), and `routes/`.
44
+ // The ONLY thing apps used to hand-write per target was the server entry (`_worker.ts`, `server-bun.ts`,
45
+ // …) - so we GENERATE it here (per target) instead of asking each app to ship five near-identical files.
46
+ // ===================================================================================================
47
+
48
+ /** A deploy target `nifra build --target <t>` can emit. `static` is pure SSG (no server). */
49
+ export const BUILD_TARGETS = ["bun", "node", "deno", "cf-pages", "vercel", "static"] as const
50
+ export type BuildTarget = (typeof BUILD_TARGETS)[number]
51
+
52
+ /** A type guard narrowing an arbitrary string to a {@link BuildTarget}. */
53
+ export function isBuildTarget(value: string): value is BuildTarget {
54
+ return (BUILD_TARGETS as readonly string[]).includes(value)
55
+ }
56
+
57
+ export type ServerBuildTarget = "browser" | "node" | "bun"
58
+
59
+ export interface StaticBuildTargetPlan {
60
+ readonly target: "static"
61
+ readonly kind: "static"
62
+ readonly serverTarget: undefined
63
+ readonly outputFile: undefined
64
+ readonly run: string
65
+ }
66
+
67
+ export interface ServerBuildTargetPlan {
68
+ readonly target: Exclude<BuildTarget, "static">
69
+ readonly kind: "server"
70
+ readonly serverTarget: ServerBuildTarget
71
+ /** The worker's final filename inside the assembled deploy directory. */
72
+ readonly outputFile: "_worker.js" | "index.js" | "server.js"
73
+ readonly run: string
74
+ }
75
+
76
+ export type BuildTargetPlan = StaticBuildTargetPlan | ServerBuildTargetPlan
77
+
78
+ const finalPathSegment = (path: string): string => {
79
+ // Do not use a repeated, unanchored regex here: a separator-heavy path followed by a
80
+ // non-separator can make the regex engine retry every possible start position (ReDoS).
81
+ let end = path.length
82
+ while (end > 0) {
83
+ const code = path.charCodeAt(end - 1)
84
+ if (code !== 0x2f && code !== 0x5c) break
85
+ end -= 1
86
+ }
87
+ const trimmed = path.slice(0, end)
88
+ const slash = Math.max(trimmed.lastIndexOf("/"), trimmed.lastIndexOf("\\"))
89
+ return slash === -1 ? trimmed : trimmed.slice(slash + 1)
90
+ }
91
+
92
+ /**
93
+ * Resolve the target-specific deploy shape before any bundling starts. Keeping this decision pure
94
+ * means Bun and Vite strategies share the same output filename, server target, and hand-off text;
95
+ * the filesystem emitter only has to execute the plan.
96
+ */
97
+ export function planBuildTarget(target: BuildTarget, outDir: string): BuildTargetPlan {
98
+ if (target === "static") {
99
+ return {
100
+ target,
101
+ kind: "static",
102
+ serverTarget: undefined,
103
+ outputFile: undefined,
104
+ run: `static site → ${outDir} (serve the directory with any static host)`,
105
+ }
106
+ }
107
+ if (target === "cf-pages") {
108
+ return {
109
+ target,
110
+ kind: "server",
111
+ serverTarget: "browser",
112
+ outputFile: "_worker.js",
113
+ run: `Cloudflare Pages → ${outDir} (deploy: wrangler pages deploy ${finalPathSegment(outDir)})`,
114
+ }
115
+ }
116
+ if (target === "vercel") {
117
+ return {
118
+ target,
119
+ kind: "server",
120
+ serverTarget: "browser",
121
+ outputFile: "index.js",
122
+ run: `Vercel edge function → ${outDir}/index.js (wrap with your vercel.json or Build Output API)`,
123
+ }
124
+ }
125
+ return {
126
+ target,
127
+ kind: "server",
128
+ serverTarget: target === "node" ? "node" : target === "bun" ? "bun" : "browser",
129
+ outputFile: "server.js",
130
+ run: `${target} server → ${outDir} (run: ${target === "node" ? "node" : target} ${finalPathSegment(outDir)}/server.js)`,
131
+ }
132
+ }
133
+
134
+ /**
135
+ * A build-tool STRATEGY for {@link buildTargetWith} - the two bundling steps, and nothing else. Everything
136
+ * around them (server-entry codegen, deploy assembly, prerender, size report) is bundler-agnostic and
137
+ * lives in `buildTargetWith`, so a second bundler (Vite) is this interface, not a second orchestrator.
138
+ *
139
+ * Plugin lists are `readonly unknown[]` because a Bun plugin and a Vite plugin are different types; each
140
+ * strategy casts to its own. `buildTargetWith` only forwards them.
141
+ */
142
+ export interface Bundler {
143
+ /** Build the client bundle → the shared {@link BuildManifest}. */
144
+ buildClient(input: {
145
+ readonly routesDir: string
146
+ readonly outDir: string
147
+ readonly clientModule: string
148
+ readonly plugins?: readonly unknown[]
149
+ readonly conditions?: readonly string[]
150
+ readonly define?: Readonly<Record<string, string>>
151
+ readonly publicDir?: string | false
152
+ readonly publicEnvPrefix?: string
153
+ /** Project root (Vite needs it; the Bun strategy ignores it). */
154
+ readonly root?: string
155
+ }): Promise<BuildManifest>
156
+ /** Build the server worker → the shared {@link ServerBuild}. */
157
+ buildServer(input: {
158
+ readonly routesDir: string
159
+ readonly serverEntry: string
160
+ readonly outDir: string
161
+ readonly clientEntry: string
162
+ readonly target: "browser" | "node" | "bun"
163
+ readonly plugins?: readonly unknown[]
164
+ readonly define?: Readonly<Record<string, string>>
165
+ readonly root?: string
166
+ }): Promise<ServerBuild>
167
+ }
168
+
169
+ interface GraphImport {
170
+ readonly path?: string
171
+ readonly original?: string
172
+ }
173
+
174
+ interface GraphInput {
175
+ readonly imports?: readonly GraphImport[]
176
+ }
177
+
178
+ const basename = (path: string): string => pathBasename(path)
179
+
180
+ const nodeBuiltinOf = (im: GraphImport): string | undefined => {
181
+ if (im.original?.startsWith("node:")) return im.original
182
+ if (im.path?.startsWith("node:")) return im.path
183
+ return undefined
184
+ }
185
+
186
+ /** One `node:`-builtin-in-the-client finding: the offending builtin, the emitted chunk it landed in,
187
+ * and the shortest USER-module import chain that pulled it there (entry → … → builtin). */
188
+ export interface NodeBuiltinFinding {
189
+ readonly builtin: string
190
+ readonly chunk: string
191
+ /** The shortest import path from a user entry to the builtin, as a list of display labels:
192
+ * `[entryFile, ...as-written specifiers along the way, builtin]`, e.g.
193
+ * `["routes/article/[slug].tsx", "../data.ts", "../db/client.ts", "postgres", "node:tls"]`. The
194
+ * entry is its graph key (the route file); each hop is the import's *as-written* specifier; the tail
195
+ * is the builtin. Empty only if the builtin module isn't reachable from any traced input (it always
196
+ * is when flagged). */
197
+ readonly chain: readonly string[]
198
+ }
199
+
200
+ /**
201
+ * BFS the metafile import graph for the SHORTEST user-module path that pulls `builtin` into the
202
+ * bundle, returning it as display labels `[entryFile, …as-written specifiers…, builtin]`. The frontier
203
+ * starts at every `entryInput` (the route/user entrypoints) so the reported chain begins where the dev
204
+ * actually wrote `import` - the actionable root, not an arbitrary internal module. Traversal crosses
205
+ * only NON-`node:` edges (so Bun's polyfill chain never extends the path) and stops at the first edge
206
+ * whose target is the builtin. Returns `[builtin]` if no entry reaches it (defensive; a flagged builtin
207
+ * is reachable by construction). Pure - operates on the graph, never the emitted text.
208
+ */
209
+ function shortestBuiltinChain(
210
+ inputs: Readonly<Record<string, GraphInput>>,
211
+ entryInputs: readonly string[],
212
+ builtin: string,
213
+ ): string[] {
214
+ const seen = new Set(entryInputs)
215
+ let frontier = entryInputs.map((node) => ({ node, chain: [node] }))
216
+ while (frontier.length > 0) {
217
+ const next: Array<{ node: string; chain: string[] }> = []
218
+ for (const { node, chain } of frontier) {
219
+ for (const im of inputs[node]?.imports ?? []) {
220
+ // The builtin reached via THIS edge → the chain ends here (append the builtin label). Match on
221
+ // the same `node:` detection the finding used (`original` first, then resolved `path`).
222
+ if (nodeBuiltinOf(im) === builtin) return [...chain, builtin]
223
+ const target = im.path
224
+ // Only follow edges into user (non-`node:`) modules that exist in the input graph and haven't
225
+ // been visited - so the polyfill subtree can't lengthen the path and there are no cycles.
226
+ if (
227
+ target === undefined ||
228
+ target.startsWith("node:") ||
229
+ inputs[target] === undefined ||
230
+ seen.has(target)
231
+ ) {
232
+ continue
233
+ }
234
+ seen.add(target)
235
+ // Display the hop by its as-written specifier (`../db/client.ts`, `postgres`), falling back to
236
+ // the resolved path - that's what the dev recognizes in their source, not the resolved path.
237
+ next.push({ node: target, chain: [...chain, im.original ?? target] })
238
+ }
239
+ }
240
+ frontier = next
241
+ }
242
+ return [builtin]
243
+ }
244
+
245
+ /**
246
+ * Scan a build's metafile for any `node:` builtin that a USER module pulled into a CLIENT output
247
+ * chunk, returning a sorted, deduped list of {@link NodeBuiltinFinding}s. Three graph facts combine so
248
+ * the report is precise AND actionable:
249
+ * 1. **What the user wrote** - only builtins imported by a NON-`node:` input count, so Bun's own
250
+ * polyfill chain (`node:crypto` → `node:buffer`/`node:stream`/…) doesn't bury the real cause.
251
+ * 2. **Where it landed** - the chunk is read from the per-output `inputs`, so the error names the
252
+ * emitted file to look at.
253
+ * 3. **How it got there** - the shortest import chain from a user entry to the builtin
254
+ * (`shortestBuiltinChain`), so the error points straight at the offending `import` line instead of
255
+ * leaving the dev to grep the dependency tree (the DX gap this closes).
256
+ * Graph-based (never the emitted text), so it survives minification and can't be fooled by a string
257
+ * literal that merely contains `"node:crypto"`. Pure + exported for unit testing. Empty ⇒ clean.
258
+ */
259
+ export function detectNodeBuiltinsInClient(
260
+ graph: ClientModuleGraph,
261
+ ): ReadonlyArray<NodeBuiltinFinding> {
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<string>()
265
+ for (const [inputKey, input] of Object.entries(inputs)) {
266
+ if (inputKey.startsWith("node:")) continue // a polyfill importing another builtin - not the cause
267
+ for (const im of input.imports ?? []) {
268
+ const builtin = nodeBuiltinOf(im)
269
+ if (builtin !== undefined) userImported.add(builtin)
270
+ }
271
+ }
272
+ // The entry inputs (route/user entrypoints) - the chain BFS starts here so the reported path begins
273
+ // at the file the dev wrote an `import` in. Each output's `entryPoint` is one such input.
274
+ const entryInputs = [
275
+ ...new Set(
276
+ Object.values(graph.chunks)
277
+ .map((output) => output.entryPoint)
278
+ .filter((entry): entry is string => entry !== undefined && !entry.startsWith("node:")),
279
+ ),
280
+ ]
281
+ // The chain per builtin is independent of the chunk, so compute it once per builtin (memoized).
282
+ const chains = new Map<string, readonly string[]>()
283
+ const chainFor = (builtin: string): readonly string[] => {
284
+ const cached = chains.get(builtin)
285
+ if (cached !== undefined) return cached
286
+ const chain = shortestBuiltinChain(inputs, entryInputs, builtin)
287
+ chains.set(builtin, chain)
288
+ return chain
289
+ }
290
+ // (2) Locate which emitted chunk each user-imported builtin reached, via the per-output `inputs`.
291
+ const findings = new Map<string, NodeBuiltinFinding>()
292
+ for (const [outputPath, output] of Object.entries(graph.chunks)) {
293
+ for (const inputKey of output.modules) {
294
+ if (!userImported.has(inputKey)) continue
295
+ const chunk = basename(outputPath)
296
+ findings.set(`${inputKey}\0${chunk}`, {
297
+ builtin: inputKey,
298
+ chunk,
299
+ chain: chainFor(inputKey),
300
+ })
301
+ }
302
+ }
303
+ return [...findings.values()].sort((a, b) =>
304
+ a.builtin === b.builtin ? a.chunk.localeCompare(b.chunk) : a.builtin.localeCompare(b.builtin),
305
+ )
306
+ }
307
+
308
+ // ---------------------------------------------------------------------------------------------------
309
+ // `server-only` poison-import guard (§3.3/§5.1). The complement to the `.server` convention + the
310
+ // node-builtin guard: a module of PURE server logic with NO `node:` import (a secret-bearing constant,
311
+ // a server-only API call) that an author wants to FAIL LOUD if it reaches the client opts in with a
312
+ // side-effect `import "@nifrajs/web/server-only"` (Next's `import "server-only"`). On the SERVER build
313
+ // the marker is an empty no-op; the CLIENT build detects - via the SAME Bun metafile graph the
314
+ // node-builtin guard walks - any module that imports the marker AND lands in a client chunk, and fails
315
+ // the build with the import chain. Graph-based (never the emitted text), so it survives minification.
316
+ // ---------------------------------------------------------------------------------------------------
317
+
318
+ /** The marker specifier an author imports to opt a module into the client-leak guard. Matched on the
319
+ * import edge's *as-written* `original` first (the robust signal: it's exactly what the author typed,
320
+ * before Bun resolves it to `src/server-only.ts` / `dist/server-only.js`). */
321
+ export const SERVER_ONLY_MARKER = "@nifrajs/web/server-only"
322
+
323
+ const isServerOnlyMarkerImport = (im: GraphImport): boolean => {
324
+ if (im.original === SERVER_ONLY_MARKER) return true
325
+ return im.path !== undefined && /(^|\/)server-only\.[cm]?[jt]s$/.test(im.path)
326
+ }
327
+
328
+ /** True when an INPUT graph key is the marker module file itself (`…/server-only.{ts,js}` under web).
329
+ * Excluded from the "marked" set - the marker is the import target, not an opt-in module. */
330
+ const isServerOnlyMarkerModule = (inputKey: string): boolean =>
331
+ /(^|\/)server-only\.[cm]?[jt]s$/.test(inputKey)
332
+
333
+ /** One `server-only`-module-in-the-client finding: the offending module (the as-written marker-import
334
+ * chain's tail before the marker), the emitted chunk it landed in, and the shortest USER-module import
335
+ * chain that pulled it there (entry → … → the server-only module). */
336
+ export interface ServerOnlyFinding {
337
+ /** The emitted client chunk the server-only module landed in (basename). */
338
+ readonly chunk: string
339
+ /** The shortest import path from a user entry to the server-only module, as display labels:
340
+ * `[entryFile, ...as-written specifiers…, "<module> (marked server-only)"]`. Mirrors
341
+ * {@link NodeBuiltinFinding.chain}; the tail names the marked module so the message reads
342
+ * `routes/x.tsx → ../secrets.ts (marked server-only)`. */
343
+ readonly chain: readonly string[]
344
+ }
345
+
346
+ /**
347
+ * Build a resolver from an import EDGE to its INPUT-GRAPH KEY. Bun's metafile records edge `path`s as
348
+ * ABSOLUTE paths but keys `inputs` by CWD-RELATIVE paths, so a raw `inputs[im.path]` lookup misses
349
+ * every user edge in a real build. The resolver: (1) an exact key match (the synthetic-metafile / unit
350
+ * case); else (2) the input key the absolute path ends with (`…/secrets.ts` → `packages/web/.../secrets.ts`).
351
+ * The longest-suffix match is taken so a shorter key can't shadow a more specific one. Pure.
352
+ */
353
+ function inputKeyResolver(
354
+ inputs: Readonly<Record<string, unknown>>,
355
+ ): (im: GraphImport) => string | undefined {
356
+ const keys = Object.keys(inputs)
357
+ return (im) => {
358
+ const path = im.path
359
+ if (path === undefined) return undefined
360
+ if (inputs[path] !== undefined) return path
361
+ let best: string | undefined
362
+ for (const key of keys) {
363
+ if (path.endsWith(`/${key}`) && (best === undefined || key.length > best.length)) best = key
364
+ }
365
+ return best
366
+ }
367
+ }
368
+
369
+ /**
370
+ * BFS the metafile import graph for the SHORTEST user-module path that reaches a module which imports
371
+ * the `server-only` marker, returning it as display labels `[entryFile, …as-written specifiers…,
372
+ * "<module> (marked server-only)"]`. Mirrors {@link shortestBuiltinChain}: the frontier starts at
373
+ * every `entryInput` so the chain begins at the file the dev wrote an `import` in; traversal crosses
374
+ * only NON-`node:` user edges (no cycles via `seen`); it stops at the first node whose import set
375
+ * contains the marker (the marked module - the actionable tail), labelling that node by the as-written
376
+ * specifier the previous hop used to reach it. Pure - operates on the graph, never the emitted text.
377
+ */
378
+ function shortestServerOnlyChain(
379
+ inputs: Readonly<Record<string, GraphInput>>,
380
+ entryInputs: readonly string[],
381
+ markedModule: string,
382
+ resolveTarget: (im: GraphImport) => string | undefined,
383
+ ): string[] {
384
+ // The label for the marked module's tail: its as-written specifier (filled when we cross the edge
385
+ // that reaches it) suffixed with `(marked server-only)`; the entry case uses the entry key itself.
386
+ const tail = (label: string): string => `${label} (marked server-only)`
387
+ // An entry that is ITSELF the marked module - the chain is just that one node.
388
+ if (entryInputs.includes(markedModule)) return [tail(markedModule)]
389
+ const seen = new Set(entryInputs)
390
+ let frontier = entryInputs.map((node) => ({ node, chain: [node] }))
391
+ while (frontier.length > 0) {
392
+ const next: Array<{ node: string; chain: string[] }> = []
393
+ for (const { node, chain } of frontier) {
394
+ for (const im of inputs[node]?.imports ?? []) {
395
+ // Resolve the edge's `path` to the matching INPUT-GRAPH KEY - in a real build the edge `path`
396
+ // is absolute while the input keys are cwd-relative, so a raw equality/lookup would miss every
397
+ // multi-hop user edge (and the chain would degrade to just the tail). The resolver maps both.
398
+ const target = resolveTarget(im)
399
+ if (target === undefined || target.startsWith("node:")) continue
400
+ // The marked module reached via THIS edge → the chain ends here (label it as the marked tail
401
+ // using the as-written specifier the author wrote, falling back to the resolved key).
402
+ if (target === markedModule) return [...chain, tail(im.original ?? target)]
403
+ if (seen.has(target)) continue
404
+ seen.add(target)
405
+ next.push({ node: target, chain: [...chain, im.original ?? target] })
406
+ }
407
+ }
408
+ frontier = next
409
+ }
410
+ return [tail(markedModule)]
411
+ }
412
+
413
+ /**
414
+ * Scan a build's metafile for any module that opts into the `server-only` marker (a side-effect
415
+ * `import "@nifrajs/web/server-only"`) yet landed in a CLIENT output chunk, returning a sorted, deduped
416
+ * list of {@link ServerOnlyFinding}s. Mirrors {@link detectNodeBuiltinsInClient}: it reads the SAME
417
+ * graph facts - which inputs import the marker (the "marked" modules), which chunk each landed in (the
418
+ * per-output `inputs`), and the shortest import chain from a user entry to it. The marker module ITSELF
419
+ * (which imports nothing) is excluded - only the modules that *opt in* are flagged. Pure + exported for
420
+ * unit testing. Empty ⇒ clean.
421
+ */
422
+ export function detectServerOnlyInClient(
423
+ graph: ClientModuleGraph,
424
+ ): ReadonlyArray<ServerOnlyFinding> {
425
+ const inputs = graph.modules
426
+ // (1) The modules that import the marker - the ones the author opted into the guard. The marker
427
+ // module itself is skipped: it's the import TARGET, not an opt-in (it imports nothing of its own).
428
+ const marked = new Set<string>()
429
+ for (const [inputKey, input] of Object.entries(inputs)) {
430
+ if (isServerOnlyMarkerModule(inputKey)) continue
431
+ if ((input.imports ?? []).some(isServerOnlyMarkerImport)) marked.add(inputKey)
432
+ }
433
+ if (marked.size === 0) return []
434
+ const entryInputs = [
435
+ ...new Set(
436
+ Object.values(graph.chunks)
437
+ .map((output) => output.entryPoint)
438
+ .filter((entry): entry is string => entry !== undefined && !entry.startsWith("node:")),
439
+ ),
440
+ ]
441
+ // The entry inputs - the chain BFS starts here, so the reported path begins at the file the dev
442
+ // wrote an `import` in. Same derivation as the node-builtin guard.
443
+ const resolveTarget = inputKeyResolver(inputs)
444
+ const chains = new Map<string, readonly string[]>()
445
+ const chainFor = (module: string): readonly string[] => {
446
+ const cached = chains.get(module)
447
+ if (cached !== undefined) return cached
448
+ const chain = shortestServerOnlyChain(inputs, entryInputs, module, resolveTarget)
449
+ chains.set(module, chain)
450
+ return chain
451
+ }
452
+ // (2) Locate which emitted chunk each marked module reached, via the per-output `inputs`.
453
+ const findings = new Map<string, ServerOnlyFinding>()
454
+ for (const [outputPath, output] of Object.entries(graph.chunks)) {
455
+ for (const inputKey of output.modules) {
456
+ if (!marked.has(inputKey)) continue
457
+ const chunk = basename(outputPath)
458
+ findings.set(`${inputKey}\0${chunk}`, { chunk, chain: chainFor(inputKey) })
459
+ }
460
+ }
461
+ return [...findings.values()].sort((a, b) => {
462
+ const am = a.chain[a.chain.length - 1] ?? ""
463
+ const bm = b.chain[b.chain.length - 1] ?? ""
464
+ return am === bm ? a.chunk.localeCompare(b.chunk) : am.localeCompare(bm)
465
+ })
466
+ }
467
+
468
+ // ---------------------------------------------------------------------------------------------------
469
+ // Guard MESSAGES - one owner for both production pipelines. The Bun build and the Vite/Rollup leak-guard
470
+ // plugin both call these, so a leak reads IDENTICALLY whichever bundler produced it. A second bundler
471
+ // must not grow a second, subtly-different wording of a security error; that is exactly the "mostly
472
+ // ported" outcome the neutral graph seam exists to prevent.
473
+ // ---------------------------------------------------------------------------------------------------
474
+
475
+ /** The build-failing message for `node:` builtins that reached the client bundle. `undefined` ⇒ clean. */
476
+ export function formatNodeBuiltinLeak(
477
+ findings: ReadonlyArray<NodeBuiltinFinding>,
478
+ ): string | undefined {
479
+ if (findings.length === 0) return undefined
480
+ const lines = findings.map((finding) =>
481
+ finding.chain.length > 1
482
+ ? ` - ${finding.builtin} reached the client bundle via ${finding.chain.join(" → ")} (chunk: ${finding.chunk})`
483
+ : ` - ${finding.builtin} reached the client bundle via ${finding.chunk}`,
484
+ )
485
+ return (
486
+ `[nifra/web] Node built-in(s) in the client bundle - move them behind a server-only path ` +
487
+ `(a loader/action runs on the server; import the \`node:\` module there, not at a route's ` +
488
+ `top level):\n${lines.join("\n")}`
489
+ )
490
+ }
491
+
492
+ /** The build-failing message for `server-only`-marked modules that reached the client. `undefined` ⇒ clean. */
493
+ export function formatServerOnlyLeak(
494
+ findings: ReadonlyArray<ServerOnlyFinding>,
495
+ ): string | undefined {
496
+ if (findings.length === 0) return undefined
497
+ const lines = findings.map((finding) =>
498
+ finding.chain.length > 1
499
+ ? ` - server-only module reached the client bundle via ${finding.chain.join(" → ")} (chunk: ${finding.chunk})`
500
+ : ` - server-only module reached the client bundle via ${finding.chunk}`,
501
+ )
502
+ return (
503
+ `[nifra/web] server-only module(s) in the client bundle - a module marked ` +
504
+ `\`import "${SERVER_ONLY_MARKER}"\` reached the browser. Move it behind a server-only path ` +
505
+ `(reach it via a loader/action, or rename it \`*.server.ts\`), so its server logic never ships ` +
506
+ `to the client:\n${lines.join("\n")}`
507
+ )
508
+ }
509
+
510
+ // ---------------------------------------------------------------------------------------------------
511
+ // Bundle-size report (`nifra build --report`). The raw byte + gzip size of each emitted chunk lets a
512
+ // dev catch a bundle regression at build time. Pure aggregation/formatting helpers (no fs, no Bun
513
+ // build) so they're unit-testable in isolation; the orchestrator below wires them to real outputs.
514
+ // ---------------------------------------------------------------------------------------------------
515
+
516
+ /** One emitted chunk's measured size, in raw bytes + gzipped bytes (over-the-wire weight). */
517
+ export interface ChunkSize {
518
+ /** The emitted file's basename (e.g. `index-abc123.js`). */
519
+ readonly name: string
520
+ /** Raw byte length of the file. */
521
+ readonly bytes: number
522
+ /** Gzipped byte length (what the client actually downloads, modulo brotli). */
523
+ readonly gzip: number
524
+ }
525
+
526
+ /** A whole build's size report - every chunk (largest first) + the totals. */
527
+ export interface SizeReport {
528
+ /** Per-chunk sizes, sorted biggest gzip first (the regression you want to see at the top). */
529
+ readonly chunks: readonly ChunkSize[]
530
+ /** Sum of every chunk's raw bytes. */
531
+ readonly totalBytes: number
532
+ /** Sum of every chunk's gzip bytes. */
533
+ readonly totalGzip: number
534
+ }
535
+
536
+ /**
537
+ * Aggregate a list of measured chunks into a {@link SizeReport}: sort biggest-gzip-first (ties broken
538
+ * by raw bytes, then name for stable output) and sum the totals. Pure - the measurement (reading the
539
+ * file + gzipping it) happens in the orchestrator; this is the deterministic, unit-testable core.
540
+ */
541
+ export function aggregateSizeReport(chunks: readonly ChunkSize[]): SizeReport {
542
+ const sorted = [...chunks].sort(
543
+ (a, b) => b.gzip - a.gzip || b.bytes - a.bytes || a.name.localeCompare(b.name),
544
+ )
545
+ let totalBytes = 0
546
+ let totalGzip = 0
547
+ for (const chunk of sorted) {
548
+ totalBytes += chunk.bytes
549
+ totalGzip += chunk.gzip
550
+ }
551
+ return { chunks: sorted, totalBytes, totalGzip }
552
+ }
553
+
554
+ /** Human-readable byte count: `B`/`KB`/`MB` with one decimal above 1 KB (e.g. `12.3 KB`). Pure. */
555
+ export function formatBytes(bytes: number): string {
556
+ if (bytes < 1024) return `${bytes} B`
557
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
558
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
559
+ }
560
+
561
+ /**
562
+ * Render a {@link SizeReport} as a terse aligned table (biggest first) with a totals row - the text
563
+ * `nifra build --report` prints. Pure (string in, string out) so the formatting is unit-testable.
564
+ */
565
+ export function renderSizeReport(report: SizeReport): string {
566
+ const rows = report.chunks.map((chunk) => ({
567
+ name: chunk.name,
568
+ raw: formatBytes(chunk.bytes),
569
+ gz: formatBytes(chunk.gzip),
570
+ }))
571
+ // Column widths from the header + every row + the totals label, so the table never clips a value.
572
+ const nameW = Math.max(5, ...rows.map((row) => row.name.length), "Total".length)
573
+ const rawW = Math.max(
574
+ 3,
575
+ ...rows.map((row) => row.raw.length),
576
+ formatBytes(report.totalBytes).length,
577
+ )
578
+ const gzW = Math.max(4, ...rows.map((row) => row.gz.length), formatBytes(report.totalGzip).length)
579
+ const padEnd = (value: string, width: number): string =>
580
+ value + " ".repeat(Math.max(0, width - value.length))
581
+ const padStart = (value: string, width: number): string =>
582
+ " ".repeat(Math.max(0, width - value.length)) + value
583
+ const line = (name: string, raw: string, gzip: string): string =>
584
+ ` ${padEnd(name, nameW)} ${padStart(raw, rawW)} ${padStart(gzip, gzW)}`
585
+ return [
586
+ line("Chunk", "Raw", "Gzip"),
587
+ line("-".repeat(nameW), "-".repeat(rawW), "-".repeat(gzW)),
588
+ ...rows.map((row) => line(row.name, row.raw, row.gz)),
589
+ line("Total", formatBytes(report.totalBytes), formatBytes(report.totalGzip)),
590
+ ].join("\n")
591
+ }
592
+
593
+ // ---------------------------------------------------------------------------------------------------
594
+ // Server-manifest drift detection (#7). `server-manifest.ts` is a committed generated file: it bakes
595
+ // the route list + the client-entry hash for a disk-less worker (`generateServerManifest`). If `routes/`
596
+ // changes but the manifest isn't regenerated, the worker serves a stale route table - a silent edge
597
+ // break. These pure helpers diff the COMMITTED manifest source against the freshly-discovered routes so
598
+ // `nifra check` (and `buildServer`) can fail with a named, actionable error before the drift ships.
599
+ // ---------------------------------------------------------------------------------------------------
600
+
601
+ /** A drift finding between a committed server-manifest and the live `routes/` tree. */
602
+ export interface ManifestDrift {
603
+ /** Route files present in `routes/` but ABSENT from the committed manifest (the manifest is stale -
604
+ * the new route won't be served by the worker). */
605
+ readonly missing: readonly string[]
606
+ /** Route files the committed manifest imports that no longer exist in `routes/` (a deleted/renamed
607
+ * route still wired into the worker - a build/runtime break). */
608
+ readonly extra: readonly string[]
609
+ }
610
+
611
+ // The route-relative file keys the generated manifest's route map declares. Both shapes are matched by
612
+ // their VALUE: eager ` "routes/x.tsx": m0,` and lazy ` "routes/x.tsx": () => import("./routes/x"),`.
613
+ // The KEY (not the import specifier) is the route identity `discoverRoutes` produces, extension and all;
614
+ // the import specifier is emitted extensionless (so a bare `tsc` compiles the manifest) and could not be
615
+ // mapped back to the exact `.tsx`/`.ts` key. The baked `routeStyles` JSON is a single line, so its keys
616
+ // are never at an indented line start and never match. `[ \t]+` (not `\s+`) so the `m` anchor stays on
617
+ // the same line and one route-map entry cannot span two lines of source.
618
+ const MANIFEST_ROUTE_ENTRY = /^[ \t]+(["'])([^"'\n]+)\1\s*:\s*(?:m\d+\b|\(\s*\)\s*=>\s*import\b)/gm
619
+ // The baked client-entry line: `export const clientEntry = "…"`.
620
+ const MANIFEST_CLIENT_ENTRY = /export\s+const\s+clientEntry\s*=\s*["']([^"']+)["']/
621
+
622
+ /**
623
+ * Extract the route-relative file list a committed server-manifest declares, as the same
624
+ * `routes/`-relative keys `discoverRoutes` produces (e.g. `docs/index.tsx`). Reads the route map's KEYS,
625
+ * which carry the file extension and no directory prefix - exactly discovery's shape - so the result is
626
+ * independent of the specifier prefix the manifest happened to import with. The `@nifrajs/web` import and
627
+ * the baked `clientEntry`/`styles`/`routeStyles` lines carry no route-map entry and are ignored.
628
+ *
629
+ * `_routesPrefix` is accepted for call-site compatibility but no longer needed: the keys are already
630
+ * prefix-free. Pure - operates on source text.
631
+ */
632
+ export function parseManifestRouteFiles(source: string, _routesPrefix?: string): string[] {
633
+ const files = new Set<string>()
634
+ for (const match of source.matchAll(MANIFEST_ROUTE_ENTRY)) {
635
+ if (match[2] !== undefined) files.add(match[2])
636
+ }
637
+ return [...files].sort()
638
+ }
639
+
640
+ /** The baked `clientEntry` URL in a committed server-manifest, or `undefined` if absent. Pure. */
641
+ export function parseManifestClientEntry(source: string): string | undefined {
642
+ return MANIFEST_CLIENT_ENTRY.exec(source)?.[1]
643
+ }
644
+
645
+ // The baked asset lines `generateServerManifest` emits: `export const styles = […]` then
646
+ // `export const routeStyles = {…}`, each a JSON literal running to the next `export const`. Extracted
647
+ // with `indexOf` slicing rather than a lazy `[\s\S]*?` regex: the slice is multi-line by nature (a
648
+ // formatter can wrap a long `routeStyles` map, and missing it would silently DROP every baked
649
+ // stylesheet on re-sync), and a lazy scan re-walks the file per unterminated opener - quadratic.
650
+ function bakedManifestLiteral(source: string, name: string): string | undefined {
651
+ const opener = `export const ${name} = `
652
+ const start = source.indexOf(opener)
653
+ if (start === -1) return undefined
654
+ const from = start + opener.length
655
+ const end = source.indexOf("\nexport const ", from)
656
+ return end === -1 ? undefined : source.slice(from, end)
657
+ }
658
+
659
+ /** Parse a baked JSON literal captured from a committed manifest, tolerating a formatter's TRAILING COMMAS
660
+ * (biome/prettier add one when wrapping a multi-line array/object; strict `JSON.parse` would reject it).
661
+ * Only a comma immediately before a closing `]`/`}` is stripped, so commas inside string values are safe. */
662
+ function parseManifestLiteral(raw: string): unknown {
663
+ return JSON.parse(raw.replace(/,(\s*[\]}])/g, "$1"))
664
+ }
665
+
666
+ /** The baked top-level `styles` array in a committed server-manifest (empty if absent/unparseable). Pure. */
667
+ export function parseManifestStyles(source: string): string[] {
668
+ const raw = bakedManifestLiteral(source, "styles")
669
+ if (raw === undefined) return []
670
+ try {
671
+ const value = parseManifestLiteral(raw)
672
+ return Array.isArray(value) ? (value as string[]) : []
673
+ } catch {
674
+ return []
675
+ }
676
+ }
677
+
678
+ /** The baked per-route `routeStyles` map in a committed server-manifest (empty if absent/unparseable). Pure. */
679
+ export function parseManifestRouteStyles(source: string): Record<string, string[]> {
680
+ const raw = bakedManifestLiteral(source, "routeStyles")
681
+ if (raw === undefined) return {}
682
+ try {
683
+ const value = parseManifestLiteral(raw)
684
+ return value !== null && typeof value === "object" && !Array.isArray(value)
685
+ ? (value as Record<string, string[]>)
686
+ : {}
687
+ } catch {
688
+ return {}
689
+ }
690
+ }
691
+
692
+ /**
693
+ * Diff the route files a committed server-manifest imports against the files freshly discovered in
694
+ * `routes/`. Returns the `missing` (in routes/, not in manifest - stale manifest) and `extra` (in
695
+ * manifest, gone from routes/ - dangling import) sets. Empty arrays ⇒ in sync. Pure - the caller
696
+ * supplies both file lists (the committed source is parsed via {@link parseManifestRouteFiles}; the
697
+ * fresh list comes from `discoverRoutes`). Lists need not be pre-sorted; the result is sorted.
698
+ */
699
+ export function diffManifestRoutes(
700
+ manifestFiles: readonly string[],
701
+ discoveredFiles: readonly string[],
702
+ ): ManifestDrift {
703
+ const inManifest = new Set(manifestFiles)
704
+ const inRoutes = new Set(discoveredFiles)
705
+ return {
706
+ missing: discoveredFiles.filter((file) => !inManifest.has(file)).sort(),
707
+ extra: manifestFiles.filter((file) => !inRoutes.has(file)).sort(),
708
+ }
709
+ }
710
+
711
+ /** True when a drift report is clean (no missing + no extra routes). */
712
+ export function isManifestInSync(drift: ManifestDrift): boolean {
713
+ return drift.missing.length === 0 && drift.extra.length === 0
714
+ }
715
+
716
+ /**
717
+ * Format a {@link ManifestDrift} as a named, actionable error message, or `undefined` when in sync.
718
+ * Names the exact missing/extra routes + the one fix (regenerate the manifest by re-running the build).
719
+ * `manifestPath` is shown for the dev to locate the stale file. Pure.
720
+ */
721
+ export function formatManifestDrift(
722
+ drift: ManifestDrift,
723
+ manifestPath = "server-manifest.ts",
724
+ ): string | undefined {
725
+ if (isManifestInSync(drift)) return undefined
726
+ const lines = [
727
+ `[nifra/web] server-manifest drift - \`${manifestPath}\` is out of sync with routes/.`,
728
+ ]
729
+ if (drift.missing.length > 0) {
730
+ lines.push(
731
+ ` Missing (in routes/, not in the manifest - these routes won't be served): ${drift.missing.join(", ")}`,
732
+ )
733
+ }
734
+ if (drift.extra.length > 0) {
735
+ lines.push(
736
+ ` Extra (imported by the manifest, gone from routes/ - a dangling import): ${drift.extra.join(", ")}`,
737
+ )
738
+ }
739
+ lines.push(" Fix: re-run the build to regenerate the server manifest, then commit it.")
740
+ return lines.join("\n")
741
+ }