@nifrajs/web 0.1.0-alpha.1 → 0.1.0-alpha.3

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 (49) hide show
  1. package/README.md +8 -0
  2. package/dist/build.d.ts +252 -0
  3. package/dist/build.d.ts.map +1 -1
  4. package/dist/build.js +755 -6
  5. package/dist/build.js.map +1 -1
  6. package/dist/chunk-names.d.ts +4 -0
  7. package/dist/chunk-names.d.ts.map +1 -0
  8. package/dist/chunk-names.js +46 -0
  9. package/dist/chunk-names.js.map +1 -0
  10. package/dist/client.d.ts.map +1 -1
  11. package/dist/client.js +19 -2
  12. package/dist/client.js.map +1 -1
  13. package/dist/dev.d.ts +1 -1
  14. package/dist/dev.d.ts.map +1 -1
  15. package/dist/dev.js +2 -1
  16. package/dist/dev.js.map +1 -1
  17. package/dist/fonts-google.d.ts +2 -1
  18. package/dist/fonts-google.d.ts.map +1 -1
  19. package/dist/fonts-google.js.map +1 -1
  20. package/dist/fonts.d.ts +2 -1
  21. package/dist/fonts.d.ts.map +1 -1
  22. package/dist/fonts.js +0 -16
  23. package/dist/fonts.js.map +1 -1
  24. package/dist/index.d.ts +138 -6
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +268 -23
  27. package/dist/index.js.map +1 -1
  28. package/dist/manifest.d.ts +82 -5
  29. package/dist/manifest.d.ts.map +1 -1
  30. package/dist/manifest.js.map +1 -1
  31. package/dist/server-only.d.ts +21 -0
  32. package/dist/server-only.d.ts.map +1 -0
  33. package/dist/server-only.js +25 -0
  34. package/dist/server-only.js.map +1 -0
  35. package/dist/vite.d.ts +21 -1
  36. package/dist/vite.d.ts.map +1 -1
  37. package/dist/vite.js +68 -4
  38. package/dist/vite.js.map +1 -1
  39. package/package.json +7 -2
  40. package/src/build.ts +1066 -12
  41. package/src/chunk-names.ts +45 -0
  42. package/src/client.ts +19 -2
  43. package/src/dev.ts +3 -2
  44. package/src/fonts-google.ts +3 -2
  45. package/src/fonts.ts +2 -1
  46. package/src/index.ts +373 -32
  47. package/src/manifest.ts +84 -6
  48. package/src/server-only.ts +25 -0
  49. package/src/vite.ts +82 -5
package/src/build.ts CHANGED
@@ -8,8 +8,11 @@
8
8
  import { mkdirSync, writeFileSync } from "node:fs"
9
9
  import { dirname, relative, resolve as resolvePath } from "node:path"
10
10
  import type { BunPlugin } from "bun"
11
+ import { sanitizeOutputNames } from "./chunk-names.ts"
11
12
  import { discoverRoutes } from "./fs.ts"
12
13
  import { generateClientEntry, generateServerManifest } from "./index.ts"
14
+ // `buildTarget(static)` drives the SSG prerender engine directly (it's also re-exported below).
15
+ import { prerenderRoutes } from "./prerender.ts"
13
16
 
14
17
  // Build-time SSG: prerender opted-in static + dynamic routes to `index.html` (+ static `_data.json`),
15
18
  // run after `buildClient`.
@@ -45,6 +48,38 @@ export interface BuildClientOptions {
45
48
  readonly minify?: boolean
46
49
  /** URL prefix the assets are served under (default `"/assets/"`); also Bun's chunk `publicPath`. */
47
50
  readonly publicPath?: string
51
+ /**
52
+ * Prefix that opts an environment variable into the **client** bundle (Vite/Next convention; default
53
+ * `"PUBLIC_"`). Every var in the build environment whose name starts with this prefix is baked into
54
+ * the client `define` as `"process.env.NAME": JSON.stringify(value)`, so `process.env.PUBLIC_API_URL`
55
+ * compiles to its literal value in the browser. Vars WITHOUT the prefix are never exposed — the bare
56
+ * `process.env` define resolves them to `undefined`, so server secrets can't leak into the client
57
+ * bundle. Set to `""` to disable auto-exposure entirely (no var is baked in). `options.define` still
58
+ * wins over an auto-exposed var (it's layered last). Sourced from `Bun.env` (falls back to
59
+ * `process.env`) at build time. */
60
+ readonly publicEnvPrefix?: string
61
+ }
62
+
63
+ /**
64
+ * The `process.env.<NAME>` → `JSON.stringify(value)` define entries for every env var whose name
65
+ * carries `prefix` (the Vite/Next public-env convention). Exposing ONLY the prefixed vars is the
66
+ * security boundary: an unprefixed var (a secret) never gets a define, so the bare `process.env`
67
+ * define resolves it to `undefined` in the client bundle. An empty `prefix` exposes nothing (the
68
+ * opt-out). Pure + exported so the prefix/redaction contract is unit-testable without a real build.
69
+ */
70
+ export function publicEnvDefines(
71
+ prefix: string,
72
+ env: Readonly<Record<string, string | undefined>>,
73
+ ): Record<string, string> {
74
+ const defines: Record<string, string> = {}
75
+ if (prefix === "") return defines // opt-out: bake in nothing
76
+ for (const [name, value] of Object.entries(env)) {
77
+ // Skip `undefined` (a deleted/unset key can still enumerate) so we never bake in `"undefined"`.
78
+ if (name.startsWith(prefix) && value !== undefined) {
79
+ defines[`process.env.${name}`] = JSON.stringify(value)
80
+ }
81
+ }
82
+ return defines
48
83
  }
49
84
 
50
85
  /** The built asset map — the server reads `entry` for the client script + serves `assets`. */
@@ -71,15 +106,337 @@ export interface BuildManifest {
71
106
  readonly routeStyles?: Readonly<Record<string, readonly string[]>>
72
107
  }
73
108
 
74
- /** The slice of Bun's build metafile nifra reads: per JS output, its source `entryPoint` + the
75
- * `cssBundle` Bun emitted for that entry. Used to map each route file → its stylesheet (collision-safe,
76
- * keyed by source path). Not yet in `@types/bun`; shape per the Bun bundler docs. */
109
+ /** One edge in the metafile's import graph: the resolved `path` + the `original` specifier as written
110
+ * (so `import "node:crypto"` records `original: "node:crypto"` even after Bun resolves it to a
111
+ * polyfill path). Other Bun fields (`kind`, `external`) are ignored here. */
112
+ interface BunMetafileImport {
113
+ readonly path?: string
114
+ readonly original?: string
115
+ }
116
+ /** The slice of Bun's build metafile nifra reads: per INPUT module, the specifiers it `imports`
117
+ * (for the `node:` guard); per JS OUTPUT, its source `entryPoint`, the `cssBundle` Bun emitted for
118
+ * that entry, and the `inputs` (graph keys) that landed in it (for the per-route CSS map + locating
119
+ * which chunk a flagged builtin reached). Not yet in `@types/bun`; shape per the Bun bundler docs. */
120
+ interface BunMetafileOutput {
121
+ readonly entryPoint?: string
122
+ readonly cssBundle?: string
123
+ readonly inputs?: Readonly<Record<string, unknown>>
124
+ }
77
125
  interface BunMetafile {
78
- readonly outputs: Readonly<
79
- Record<string, { readonly entryPoint?: string; readonly cssBundle?: string }>
80
- >
126
+ readonly inputs?: Readonly<Record<string, { readonly imports?: readonly BunMetafileImport[] }>>
127
+ readonly outputs: Readonly<Record<string, BunMetafileOutput>>
128
+ }
129
+
130
+ /** The `node:` specifier of an import edge, or undefined if it isn't a Node built-in. Reads the
131
+ * `original` (as-written) specifier first, falling back to the resolved `path` (an `external` node:
132
+ * import keeps `node:` in its path). */
133
+ const nodeBuiltinOf = (im: BunMetafileImport): string | undefined => {
134
+ if (im.original?.startsWith("node:")) return im.original
135
+ if (im.path?.startsWith("node:")) return im.path
136
+ return undefined
137
+ }
138
+
139
+ /** One `node:`-builtin-in-the-client finding: the offending builtin, the emitted chunk it landed in,
140
+ * and the shortest USER-module import chain that pulled it there (entry → … → builtin). */
141
+ export interface NodeBuiltinFinding {
142
+ readonly builtin: string
143
+ readonly chunk: string
144
+ /** The shortest import path from a user entry to the builtin, as a list of display labels:
145
+ * `[entryFile, ...as-written specifiers along the way, builtin]`, e.g.
146
+ * `["routes/article/[slug].tsx", "../data.ts", "../db/client.ts", "postgres", "node:tls"]`. The
147
+ * entry is its graph key (the route file); each hop is the import's *as-written* specifier; the tail
148
+ * is the builtin. Empty only if the builtin module isn't reachable from any traced input (it always
149
+ * is when flagged). */
150
+ readonly chain: readonly string[]
151
+ }
152
+
153
+ /**
154
+ * BFS the metafile import graph for the SHORTEST user-module path that pulls `builtin` into the
155
+ * bundle, returning it as display labels `[entryFile, …as-written specifiers…, builtin]`. The frontier
156
+ * starts at every `entryInput` (the route/user entrypoints) so the reported chain begins where the dev
157
+ * actually wrote `import` — the actionable root, not an arbitrary internal module. Traversal crosses
158
+ * only NON-`node:` edges (so Bun's polyfill chain never extends the path) and stops at the first edge
159
+ * whose target is the builtin. Returns `[builtin]` if no entry reaches it (defensive; a flagged builtin
160
+ * is reachable by construction). Pure — operates on the graph, never the emitted text.
161
+ */
162
+ function shortestBuiltinChain(
163
+ inputs: Readonly<Record<string, { readonly imports?: readonly BunMetafileImport[] }>>,
164
+ entryInputs: readonly string[],
165
+ builtin: string,
166
+ ): string[] {
167
+ // Each queue item carries the resolved-path node + the display chain to reach it (entry + the
168
+ // as-written specifier of every hop crossed so far). `seen` dedupes nodes so the BFS is linear and
169
+ // the first time we touch the builtin's importer is via a shortest path.
170
+ const seen = new Set<string>(entryInputs)
171
+ let frontier: Array<{ node: string; chain: string[] }> = entryInputs.map((node) => ({
172
+ node,
173
+ chain: [node],
174
+ }))
175
+ while (frontier.length > 0) {
176
+ const next: Array<{ node: string; chain: string[] }> = []
177
+ for (const { node, chain } of frontier) {
178
+ for (const im of inputs[node]?.imports ?? []) {
179
+ // The builtin reached via THIS edge → the chain ends here (append the builtin label). Match on
180
+ // the same `node:` detection the finding used (`original` first, then resolved `path`).
181
+ if (nodeBuiltinOf(im) === builtin) return [...chain, builtin]
182
+ const target = im.path
183
+ // Only follow edges into user (non-`node:`) modules that exist in the input graph and haven't
184
+ // been visited — so the polyfill subtree can't lengthen the path and there are no cycles.
185
+ if (
186
+ target === undefined ||
187
+ target.startsWith("node:") ||
188
+ inputs[target] === undefined ||
189
+ seen.has(target)
190
+ ) {
191
+ continue
192
+ }
193
+ seen.add(target)
194
+ // Display the hop by its as-written specifier (`../db/client.ts`, `postgres`), falling back to
195
+ // the resolved path — that's what the dev recognizes in their source, not the resolved path.
196
+ next.push({ node: target, chain: [...chain, im.original ?? target] })
197
+ }
198
+ }
199
+ frontier = next
200
+ }
201
+ return [builtin] // unreachable from any entry — degrade to just the builtin (shouldn't happen)
202
+ }
203
+
204
+ /**
205
+ * Scan a build's metafile for any `node:` builtin that a USER module pulled into a CLIENT output
206
+ * chunk, returning a sorted, deduped list of {@link NodeBuiltinFinding}s. Three graph facts combine so
207
+ * the report is precise AND actionable:
208
+ * 1. **What the user wrote** — only builtins imported by a NON-`node:` input count, so Bun's own
209
+ * polyfill chain (`node:crypto` → `node:buffer`/`node:stream`/…) doesn't bury the real cause.
210
+ * 2. **Where it landed** — the chunk is read from the per-output `inputs`, so the error names the
211
+ * emitted file to look at.
212
+ * 3. **How it got there** — the shortest import chain from a user entry to the builtin
213
+ * (`shortestBuiltinChain`), so the error points straight at the offending `import` line instead of
214
+ * leaving the dev to grep the dependency tree (the DX gap this closes).
215
+ * Graph-based (never the emitted text), so it survives minification and can't be fooled by a string
216
+ * literal that merely contains `"node:crypto"`. Pure + exported for unit testing. Empty ⇒ clean.
217
+ */
218
+ export function detectNodeBuiltinsInClient(
219
+ meta: BunMetafile | undefined,
220
+ ): ReadonlyArray<NodeBuiltinFinding> {
221
+ const inputs = meta?.inputs ?? {}
222
+ // (1) The builtins a user (non-polyfill) module imports directly — the ones the author controls.
223
+ const userImported = new Set<string>()
224
+ for (const [inputKey, input] of Object.entries(inputs)) {
225
+ if (inputKey.startsWith("node:")) continue // a polyfill importing another builtin — not the cause
226
+ for (const im of input.imports ?? []) {
227
+ const builtin = nodeBuiltinOf(im)
228
+ if (builtin !== undefined) userImported.add(builtin)
229
+ }
230
+ }
231
+ // The entry inputs (route/user entrypoints) — the chain BFS starts here so the reported path begins
232
+ // at the file the dev wrote an `import` in. Each output's `entryPoint` is one such input.
233
+ const entryInputs = [
234
+ ...new Set(
235
+ Object.values(meta?.outputs ?? {})
236
+ .map((o) => o.entryPoint)
237
+ .filter((e): e is string => e !== undefined && !e.startsWith("node:")),
238
+ ),
239
+ ]
240
+ // The chain per builtin is independent of the chunk, so compute it once per builtin (memoized).
241
+ const chainCache = new Map<string, readonly string[]>()
242
+ const chainFor = (builtin: string): readonly string[] => {
243
+ const cached = chainCache.get(builtin)
244
+ if (cached !== undefined) return cached
245
+ const chain = shortestBuiltinChain(inputs, entryInputs, builtin)
246
+ chainCache.set(builtin, chain)
247
+ return chain
248
+ }
249
+ // (2) Locate which emitted chunk each user-imported builtin reached, via the per-output `inputs`.
250
+ const findings = new Map<string, NodeBuiltinFinding>()
251
+ for (const [outPath, out] of Object.entries(meta?.outputs ?? {})) {
252
+ for (const inputKey of Object.keys(out.inputs ?? {})) {
253
+ if (userImported.has(inputKey)) {
254
+ const chunk = basename(outPath)
255
+ findings.set(`${inputKey}\0${chunk}`, {
256
+ builtin: inputKey,
257
+ chunk,
258
+ chain: chainFor(inputKey),
259
+ })
260
+ }
261
+ }
262
+ }
263
+ return [...findings.values()].sort((a, b) =>
264
+ a.builtin === b.builtin ? a.chunk.localeCompare(b.chunk) : a.builtin.localeCompare(b.builtin),
265
+ )
266
+ }
267
+
268
+ // ---------------------------------------------------------------------------------------------------
269
+ // `server-only` poison-import guard (§3.3/§5.1). The complement to the `.server` convention + the
270
+ // node-builtin guard: a module of PURE server logic with NO `node:` import (a secret-bearing constant,
271
+ // a server-only API call) that an author wants to FAIL LOUD if it reaches the client opts in with a
272
+ // side-effect `import "@nifrajs/web/server-only"` (Next's `import "server-only"`). On the SERVER build
273
+ // the marker is an empty no-op; the CLIENT build detects — via the SAME Bun metafile graph the
274
+ // node-builtin guard walks — any module that imports the marker AND lands in a client chunk, and fails
275
+ // the build with the import chain. Graph-based (never the emitted text), so it survives minification.
276
+ // ---------------------------------------------------------------------------------------------------
277
+
278
+ /** The marker specifier an author imports to opt a module into the client-leak guard. Matched on the
279
+ * import edge's *as-written* `original` first (the robust signal: it's exactly what the author typed,
280
+ * before Bun resolves it to `src/server-only.ts` / `dist/server-only.js`). */
281
+ export const SERVER_ONLY_MARKER = "@nifrajs/web/server-only"
282
+
283
+ /** True when an import edge is the `server-only` marker import. Reads the as-written `original` (the
284
+ * specifier the author typed); falls back to the resolved `path`'s basename so a pre-resolved edge
285
+ * (no `original`) — or a workspace-relative resolution — is still recognised. */
286
+ const isServerOnlyMarkerImport = (im: BunMetafileImport): boolean => {
287
+ if (im.original === SERVER_ONLY_MARKER) return true
288
+ // Defensive fallback: an edge that lost its `original` but resolved to the marker module file. The
289
+ // marker is the only `server-only.{ts,js}` under `@nifrajs/web`, so the basename is unambiguous.
290
+ const path = im.path
291
+ return path !== undefined && /(^|\/)server-only\.[cm]?[jt]s$/.test(path)
81
292
  }
82
293
 
294
+ /** One `server-only`-module-in-the-client finding: the offending module (the as-written marker-import
295
+ * chain's tail before the marker), the emitted chunk it landed in, and the shortest USER-module import
296
+ * chain that pulled it there (entry → … → the server-only module). */
297
+ export interface ServerOnlyFinding {
298
+ /** The emitted client chunk the server-only module landed in (basename). */
299
+ readonly chunk: string
300
+ /** The shortest import path from a user entry to the server-only module, as display labels:
301
+ * `[entryFile, ...as-written specifiers…, "<module> (marked server-only)"]`. Mirrors
302
+ * {@link NodeBuiltinFinding.chain}; the tail names the marked module so the message reads
303
+ * `routes/x.tsx → ../secrets.ts (marked server-only)`. */
304
+ readonly chain: readonly string[]
305
+ }
306
+
307
+ /**
308
+ * BFS the metafile import graph for the SHORTEST user-module path that reaches a module which imports
309
+ * the `server-only` marker, returning it as display labels `[entryFile, …as-written specifiers…,
310
+ * "<module> (marked server-only)"]`. Mirrors {@link shortestBuiltinChain}: the frontier starts at
311
+ * every `entryInput` so the chain begins at the file the dev wrote an `import` in; traversal crosses
312
+ * only NON-`node:` user edges (no cycles via `seen`); it stops at the first node whose import set
313
+ * contains the marker (the marked module — the actionable tail), labelling that node by the as-written
314
+ * specifier the previous hop used to reach it. Pure — operates on the graph, never the emitted text.
315
+ */
316
+ function shortestServerOnlyChain(
317
+ inputs: Readonly<Record<string, { readonly imports?: readonly BunMetafileImport[] }>>,
318
+ entryInputs: readonly string[],
319
+ markedModule: string,
320
+ resolveTarget: (im: BunMetafileImport) => string | undefined,
321
+ ): string[] {
322
+ // The label for the marked module's tail: its as-written specifier (filled when we cross the edge
323
+ // that reaches it) suffixed with `(marked server-only)`; the entry case uses the entry key itself.
324
+ const tail = (label: string): string => `${label} (marked server-only)`
325
+ // An entry that is ITSELF the marked module — the chain is just that one node.
326
+ if (entryInputs.includes(markedModule)) return [tail(markedModule)]
327
+ const seen = new Set<string>(entryInputs)
328
+ let frontier: Array<{ node: string; chain: string[] }> = entryInputs.map((node) => ({
329
+ node,
330
+ chain: [node],
331
+ }))
332
+ while (frontier.length > 0) {
333
+ const next: Array<{ node: string; chain: string[] }> = []
334
+ for (const { node, chain } of frontier) {
335
+ for (const im of inputs[node]?.imports ?? []) {
336
+ // Resolve the edge's `path` to the matching INPUT-GRAPH KEY — in a real build the edge `path`
337
+ // is absolute while the input keys are cwd-relative, so a raw equality/lookup would miss every
338
+ // multi-hop user edge (and the chain would degrade to just the tail). The resolver maps both.
339
+ const target = resolveTarget(im)
340
+ if (target === undefined || target.startsWith("node:")) continue
341
+ // The marked module reached via THIS edge → the chain ends here (label it as the marked tail
342
+ // using the as-written specifier the author wrote, falling back to the resolved key).
343
+ if (target === markedModule) return [...chain, tail(im.original ?? target)]
344
+ if (seen.has(target)) continue
345
+ seen.add(target)
346
+ next.push({ node: target, chain: [...chain, im.original ?? target] })
347
+ }
348
+ }
349
+ frontier = next
350
+ }
351
+ return [tail(markedModule)] // unreachable from any entry (defensive; a flagged module is reachable)
352
+ }
353
+
354
+ /**
355
+ * Build a resolver from an import EDGE to its INPUT-GRAPH KEY. Bun's metafile records edge `path`s as
356
+ * ABSOLUTE paths but keys `inputs` by CWD-RELATIVE paths, so a raw `inputs[im.path]` lookup misses
357
+ * every user edge in a real build. The resolver: (1) an exact key match (the synthetic-metafile / unit
358
+ * case); else (2) the input key the absolute path ends with (`…/secrets.ts` → `packages/web/.../secrets.ts`).
359
+ * The longest-suffix match is taken so a shorter key can't shadow a more specific one. Pure.
360
+ */
361
+ function inputKeyResolver(
362
+ inputs: Readonly<Record<string, unknown>>,
363
+ ): (im: BunMetafileImport) => string | undefined {
364
+ const keys = Object.keys(inputs)
365
+ return (im) => {
366
+ const path = im.path
367
+ if (path === undefined) return undefined
368
+ if (inputs[path] !== undefined) return path // exact (synthetic keys, or already-relative paths)
369
+ let best: string | undefined
370
+ for (const key of keys) {
371
+ // `/<key>` so a suffix match aligns on a path boundary (never a partial segment), and the longest
372
+ // such key wins (the most specific file).
373
+ if (path.endsWith(`/${key}`) && (best === undefined || key.length > best.length)) best = key
374
+ }
375
+ return best
376
+ }
377
+ }
378
+
379
+ /**
380
+ * Scan a build's metafile for any module that opts into the `server-only` marker (a side-effect
381
+ * `import "@nifrajs/web/server-only"`) yet landed in a CLIENT output chunk, returning a sorted, deduped
382
+ * list of {@link ServerOnlyFinding}s. Mirrors {@link detectNodeBuiltinsInClient}: it reads the SAME
383
+ * graph facts — which inputs import the marker (the "marked" modules), which chunk each landed in (the
384
+ * per-output `inputs`), and the shortest import chain from a user entry to it. The marker module ITSELF
385
+ * (which imports nothing) is excluded — only the modules that *opt in* are flagged. Pure + exported for
386
+ * unit testing. Empty ⇒ clean.
387
+ */
388
+ export function detectServerOnlyInClient(
389
+ meta: BunMetafile | undefined,
390
+ ): ReadonlyArray<ServerOnlyFinding> {
391
+ const inputs = meta?.inputs ?? {}
392
+ // (1) The modules that import the marker — the ones the author opted into the guard. The marker
393
+ // module itself is skipped: it's the import TARGET, not an opt-in (it imports nothing of its own).
394
+ const marked = new Set<string>()
395
+ for (const [inputKey, input] of Object.entries(inputs)) {
396
+ if (isServerOnlyMarkerModule(inputKey)) continue
397
+ if ((input.imports ?? []).some(isServerOnlyMarkerImport)) marked.add(inputKey)
398
+ }
399
+ if (marked.size === 0) return []
400
+ // The entry inputs — the chain BFS starts here, so the reported path begins at the file the dev
401
+ // wrote an `import` in. Same derivation as the node-builtin guard.
402
+ const entryInputs = [
403
+ ...new Set(
404
+ Object.values(meta?.outputs ?? {})
405
+ .map((o) => o.entryPoint)
406
+ .filter((e): e is string => e !== undefined && !e.startsWith("node:")),
407
+ ),
408
+ ]
409
+ const resolveTarget = inputKeyResolver(inputs)
410
+ const chainCache = new Map<string, readonly string[]>()
411
+ const chainFor = (markedModule: string): readonly string[] => {
412
+ const cached = chainCache.get(markedModule)
413
+ if (cached !== undefined) return cached
414
+ const chain = shortestServerOnlyChain(inputs, entryInputs, markedModule, resolveTarget)
415
+ chainCache.set(markedModule, chain)
416
+ return chain
417
+ }
418
+ // (2) Locate which emitted chunk each marked module reached, via the per-output `inputs`.
419
+ const findings = new Map<string, ServerOnlyFinding>()
420
+ for (const [outPath, out] of Object.entries(meta?.outputs ?? {})) {
421
+ for (const inputKey of Object.keys(out.inputs ?? {})) {
422
+ if (marked.has(inputKey)) {
423
+ const chunk = basename(outPath)
424
+ findings.set(`${inputKey}\0${chunk}`, { chunk, chain: chainFor(inputKey) })
425
+ }
426
+ }
427
+ }
428
+ return [...findings.values()].sort((a, b) => {
429
+ const am = a.chain[a.chain.length - 1] ?? ""
430
+ const bm = b.chain[b.chain.length - 1] ?? ""
431
+ return am === bm ? a.chunk.localeCompare(b.chunk) : am.localeCompare(bm)
432
+ })
433
+ }
434
+
435
+ /** True when an INPUT graph key is the marker module file itself (`…/server-only.{ts,js}` under web).
436
+ * Excluded from the "marked" set — the marker is the import target, not an opt-in module. */
437
+ const isServerOnlyMarkerModule = (inputKey: string): boolean =>
438
+ /(^|\/)server-only\.[cm]?[jt]s$/.test(inputKey)
439
+
83
440
  const basename = (path: string): string => path.slice(path.lastIndexOf("/") + 1)
84
441
  /** `[name]` Bun derives for an entrypoint: basename without extension (`users/[id].tsx` → `[id]`). */
85
442
  const entryName = (file: string): string => {
@@ -88,6 +445,181 @@ const entryName = (file: string): string => {
88
445
  return dot === -1 ? base : base.slice(0, dot)
89
446
  }
90
447
 
448
+ // ---------------------------------------------------------------------------------------------------
449
+ // Bundle-size report (`nifra build --report`). The raw byte + gzip size of each emitted chunk lets a
450
+ // dev catch a bundle regression at build time. Pure aggregation/formatting helpers (no fs, no Bun
451
+ // build) so they're unit-testable in isolation; the orchestrator below wires them to real outputs.
452
+ // ---------------------------------------------------------------------------------------------------
453
+
454
+ /** One emitted chunk's measured size, in raw bytes + gzipped bytes (over-the-wire weight). */
455
+ export interface ChunkSize {
456
+ /** The emitted file's basename (e.g. `index-abc123.js`). */
457
+ readonly name: string
458
+ /** Raw byte length of the file. */
459
+ readonly bytes: number
460
+ /** Gzipped byte length (what the client actually downloads, modulo brotli). */
461
+ readonly gzip: number
462
+ }
463
+
464
+ /** A whole build's size report — every chunk (largest first) + the totals. */
465
+ export interface SizeReport {
466
+ /** Per-chunk sizes, sorted biggest gzip first (the regression you want to see at the top). */
467
+ readonly chunks: readonly ChunkSize[]
468
+ /** Sum of every chunk's raw bytes. */
469
+ readonly totalBytes: number
470
+ /** Sum of every chunk's gzip bytes. */
471
+ readonly totalGzip: number
472
+ }
473
+
474
+ /**
475
+ * Aggregate a list of measured chunks into a {@link SizeReport}: sort biggest-gzip-first (ties broken
476
+ * by raw bytes, then name for stable output) and sum the totals. Pure — the measurement (reading the
477
+ * file + `Bun.gzipSync`) happens in the orchestrator; this is the deterministic, unit-testable core.
478
+ */
479
+ export function aggregateSizeReport(chunks: readonly ChunkSize[]): SizeReport {
480
+ const sorted = [...chunks].sort(
481
+ (a, b) => b.gzip - a.gzip || b.bytes - a.bytes || a.name.localeCompare(b.name),
482
+ )
483
+ let totalBytes = 0
484
+ let totalGzip = 0
485
+ for (const c of sorted) {
486
+ totalBytes += c.bytes
487
+ totalGzip += c.gzip
488
+ }
489
+ return { chunks: sorted, totalBytes, totalGzip }
490
+ }
491
+
492
+ /** Human-readable byte count: `B`/`KB`/`MB` with one decimal above 1 KB (e.g. `12.3 KB`). Pure. */
493
+ export function formatBytes(bytes: number): string {
494
+ if (bytes < 1024) return `${bytes} B`
495
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
496
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
497
+ }
498
+
499
+ /**
500
+ * Render a {@link SizeReport} as a terse aligned table (biggest first) with a totals row — the text
501
+ * `nifra build --report` prints. Pure (string in, string out) so the formatting is unit-testable.
502
+ */
503
+ export function renderSizeReport(report: SizeReport): string {
504
+ const rows = report.chunks.map((c) => ({
505
+ name: c.name,
506
+ raw: formatBytes(c.bytes),
507
+ gz: formatBytes(c.gzip),
508
+ }))
509
+ // Column widths from the header + every row + the totals label, so the table never clips a value.
510
+ const nameW = Math.max(5, ...rows.map((r) => r.name.length), "Total".length)
511
+ const rawW = Math.max(3, ...rows.map((r) => r.raw.length), formatBytes(report.totalBytes).length)
512
+ const gzW = Math.max(4, ...rows.map((r) => r.gz.length), formatBytes(report.totalGzip).length)
513
+ const padEnd = (s: string, w: number): string => s + " ".repeat(Math.max(0, w - s.length))
514
+ const padStart = (s: string, w: number): string => " ".repeat(Math.max(0, w - s.length)) + s
515
+ const line = (name: string, raw: string, gz: string): string =>
516
+ ` ${padEnd(name, nameW)} ${padStart(raw, rawW)} ${padStart(gz, gzW)}`
517
+ const out = [
518
+ line("Chunk", "Raw", "Gzip"),
519
+ line("-".repeat(nameW), "-".repeat(rawW), "-".repeat(gzW)),
520
+ ...rows.map((r) => line(r.name, r.raw, r.gz)),
521
+ line("Total", formatBytes(report.totalBytes), formatBytes(report.totalGzip)),
522
+ ]
523
+ return out.join("\n")
524
+ }
525
+
526
+ // ---------------------------------------------------------------------------------------------------
527
+ // Server-manifest drift detection (#7). `server-manifest.ts` is a committed generated file: it bakes
528
+ // the route list + the client-entry hash for a disk-less worker (`generateServerManifest`). If `routes/`
529
+ // changes but the manifest isn't regenerated, the worker serves a stale route table — a silent edge
530
+ // break. These pure helpers diff the COMMITTED manifest source against the freshly-discovered routes so
531
+ // `nifra check` (and `buildServer`) can fail with a named, actionable error before the drift ships.
532
+ // ---------------------------------------------------------------------------------------------------
533
+
534
+ /** A drift finding between a committed server-manifest and the live `routes/` tree. */
535
+ export interface ManifestDrift {
536
+ /** Route files present in `routes/` but ABSENT from the committed manifest (the manifest is stale —
537
+ * the new route won't be served by the worker). */
538
+ readonly missing: readonly string[]
539
+ /** Route files the committed manifest imports that no longer exist in `routes/` (a deleted/renamed
540
+ * route still wired into the worker — a build/runtime break). */
541
+ readonly extra: readonly string[]
542
+ }
543
+
544
+ // The route-relative specifiers the generated manifest imports. Both shapes `generateServerManifest`
545
+ // emits are matched: eager `import * as m0 from "./routes/x.tsx"` and lazy `() => import("./routes/x")`.
546
+ // Captures the path inside the quotes; the caller strips the routes-dir prefix to compare with discovery.
547
+ const MANIFEST_IMPORT = /(?:import\s+\*\s+as\s+\w+\s+from|import)\s*\(?\s*["']([^"']+)["']\)?/g
548
+ // The baked client-entry line: `export const clientEntry = "…"`.
549
+ const MANIFEST_CLIENT_ENTRY = /export\s+const\s+clientEntry\s*=\s*["']([^"']+)["']/
550
+
551
+ /**
552
+ * Extract the route-relative file list the committed server-manifest imports, normalized to the same
553
+ * `routes/`-relative keys `discoverRoutes` produces (e.g. `docs/index.tsx`). `routesPrefix` is the
554
+ * specifier prefix the manifest used for the routes dir (default `./routes/`, what `buildServer`'s
555
+ * default `resolve` emits). Only import specifiers under that prefix are route files; the
556
+ * `@nifrajs/web` import (and any other bare specifier) is ignored. Pure — operates on source text.
557
+ */
558
+ export function parseManifestRouteFiles(source: string, routesPrefix = "./routes/"): string[] {
559
+ const files = new Set<string>()
560
+ for (const match of source.matchAll(MANIFEST_IMPORT)) {
561
+ const spec = match[1]
562
+ if (spec === undefined || !spec.startsWith(routesPrefix)) continue
563
+ files.add(spec.slice(routesPrefix.length))
564
+ }
565
+ return [...files].sort()
566
+ }
567
+
568
+ /** The baked `clientEntry` URL in a committed server-manifest, or `undefined` if absent. Pure. */
569
+ export function parseManifestClientEntry(source: string): string | undefined {
570
+ return MANIFEST_CLIENT_ENTRY.exec(source)?.[1]
571
+ }
572
+
573
+ /**
574
+ * Diff the route files a committed server-manifest imports against the files freshly discovered in
575
+ * `routes/`. Returns the `missing` (in routes/, not in manifest — stale manifest) and `extra` (in
576
+ * manifest, gone from routes/ — dangling import) sets. Empty arrays ⇒ in sync. Pure — the caller
577
+ * supplies both file lists (the committed source is parsed via {@link parseManifestRouteFiles}; the
578
+ * fresh list comes from `discoverRoutes`). Lists need not be pre-sorted; the result is sorted.
579
+ */
580
+ export function diffManifestRoutes(
581
+ manifestFiles: readonly string[],
582
+ discoveredFiles: readonly string[],
583
+ ): ManifestDrift {
584
+ const inManifest = new Set(manifestFiles)
585
+ const inRoutes = new Set(discoveredFiles)
586
+ const missing = discoveredFiles.filter((f) => !inManifest.has(f)).sort()
587
+ const extra = manifestFiles.filter((f) => !inRoutes.has(f)).sort()
588
+ return { missing, extra }
589
+ }
590
+
591
+ /** True when a drift report is clean (no missing + no extra routes). */
592
+ export function isManifestInSync(drift: ManifestDrift): boolean {
593
+ return drift.missing.length === 0 && drift.extra.length === 0
594
+ }
595
+
596
+ /**
597
+ * Format a {@link ManifestDrift} as a named, actionable error message, or `undefined` when in sync.
598
+ * Names the exact missing/extra routes + the one fix (regenerate the manifest by re-running the build).
599
+ * `manifestPath` is shown for the dev to locate the stale file. Pure.
600
+ */
601
+ export function formatManifestDrift(
602
+ drift: ManifestDrift,
603
+ manifestPath = "server-manifest.ts",
604
+ ): string | undefined {
605
+ if (isManifestInSync(drift)) return undefined
606
+ const lines: string[] = [
607
+ `[nifra/web] server-manifest drift — \`${manifestPath}\` is out of sync with routes/.`,
608
+ ]
609
+ if (drift.missing.length > 0) {
610
+ lines.push(
611
+ ` Missing (in routes/, not in the manifest — these routes won't be served): ${drift.missing.join(", ")}`,
612
+ )
613
+ }
614
+ if (drift.extra.length > 0) {
615
+ lines.push(
616
+ ` Extra (imported by the manifest, gone from routes/ — a dangling import): ${drift.extra.join(", ")}`,
617
+ )
618
+ }
619
+ lines.push(" Fix: re-run the build to regenerate the server manifest, then commit it.")
620
+ return lines.join("\n")
621
+ }
622
+
91
623
  /**
92
624
  * Build the client bundle for a file-routed app. Writes the hashed assets + `manifest.json` to
93
625
  * `outDir` and returns the manifest. Throws (with the bundler logs) on build failure — never
@@ -102,8 +634,20 @@ export async function buildClient(options: BuildClientOptions): Promise<BuildMan
102
634
  const routeManifest = discoverRoutes(routesDir)
103
635
  // The bootstrap's filename is `_velo`-namespaced (not `entry.ts`) so its `[name]` can't collide with
104
636
  // a user route named `entry.tsx` when the CSS mapping below excludes the bootstrap's aggregate CSS.
637
+ const mode = options.minify === false ? "development" : "production"
638
+ // PUBLIC_*-prefixed env → client define (Vite/Next convention). Sourced from the build env (`Bun.env`,
639
+ // falling back to `process.env`). Only prefixed vars are baked in; unprefixed secrets stay undefined
640
+ // in the client via the bare `process.env` → `({})` define below. Caller's `options.define` wins (it's
641
+ // layered after these in the `define` object). `Bun` may be absent under non-Bun typecheck — guard it.
642
+ const buildEnv = (typeof Bun !== "undefined" ? Bun.env : undefined) ?? process.env
643
+ const publicDefines = publicEnvDefines(options.publicEnvPrefix ?? "PUBLIC_", buildEnv)
105
644
  const entryFile = `${outDir}/_nifra-entry.ts`
106
- writeFileSync(entryFile, generateClientEntry(routeManifest, { clientModule, resolve }))
645
+ // A client bundle has no Node `process`; provide a minimal one so a stray bare `process` reference in
646
+ // an app module doesn't crash hydration (`process.env.*` reads are handled at compile time by `define`).
647
+ writeFileSync(
648
+ entryFile,
649
+ `globalThis.process ??= { env: {} };\n${generateClientEntry(routeManifest, { clientModule, resolve })}`,
650
+ )
107
651
 
108
652
  // Every unique route/layout/`_404` file (sorted, stable), as ADDITIONAL entrypoints — Bun emits a
109
653
  // named chunk per file that the bootstrap's lazy `import()` dedupes to (verified), so the manifest
@@ -135,9 +679,24 @@ export async function buildClient(options: BuildClientOptions): Promise<BuildMan
135
679
  // below — both the aggregate and per-route — via the metafile, for `<link>` injection.
136
680
  ...buildExtras,
137
681
  minify: options.minify ?? true,
138
- plugins: [...(options.plugins ?? [])],
682
+ plugins: [
683
+ reactDedupePlugin(routesDir),
684
+ preactDedupePlugin(routesDir),
685
+ serverOnlyEmptyPlugin(),
686
+ ...(options.plugins ?? []),
687
+ ],
139
688
  ...(options.conditions ? { conditions: [...options.conditions] } : {}),
140
- ...(options.define ? { define: { ...options.define } } : {}),
689
+ // Replace `process.env.*` at compile time so an app module reading config off `process.env` doesn't
690
+ // hit a `process is not defined` crash in the browser. Bun does longest-match: NODE_ENV resolves to
691
+ // the build mode (React's prod/dev branch); each PUBLIC_* var resolves to its baked VALUE; every
692
+ // other `process.env.X` becomes undefined (the bare `process.env` → `({})` fallback — so secrets
693
+ // never leak). Callers can override any of these via `options.define` (layered last).
694
+ define: {
695
+ "process.env": "({})",
696
+ "process.env.NODE_ENV": JSON.stringify(mode),
697
+ ...publicDefines,
698
+ ...options.define,
699
+ },
141
700
  })
142
701
  if (!result.success) {
143
702
  throw new Error(
@@ -145,7 +704,54 @@ export async function buildClient(options: BuildClientOptions): Promise<BuildMan
145
704
  )
146
705
  }
147
706
 
148
- const toUrl = (path: string): string => `${publicPath}${basename(path)}`
707
+ // #4: a `node:` builtin (e.g. `node:crypto`) pulled into a CLIENT chunk builds fine (Bun substitutes
708
+ // a browser polyfill) but breaks/leaks at runtime. Fail the build with a named, actionable error
709
+ // instead — caught at build time, not by a confused user in the browser. Graph-based (the metafile's
710
+ // per-output `inputs`), so it can't false-positive on a `"node:..."` string literal and survives
711
+ // minification. Only the client build runs this; the server build's `node:` imports are legitimate.
712
+ const clientMeta = (result as unknown as { metafile?: BunMetafile }).metafile
713
+ const nodeBuiltins = detectNodeBuiltinsInClient(clientMeta)
714
+ if (nodeBuiltins.length > 0) {
715
+ // Name the offending builtin AND the import chain that pulled it in (entry → … → builtin), so the
716
+ // dev sees the exact path from a route file to the leak instead of just the emitted chunk name.
717
+ // The chain always starts at a user entry; fall back to the chunk name if it somehow came back empty.
718
+ const lines = nodeBuiltins.map((f) =>
719
+ f.chain.length > 1
720
+ ? ` - ${f.builtin} reached the client bundle via ${f.chain.join(" → ")} (chunk: ${f.chunk})`
721
+ : ` - ${f.builtin} reached the client bundle via ${f.chunk}`,
722
+ )
723
+ throw new Error(
724
+ `[nifra/web] Node built-in(s) in the client bundle — move them behind a server-only path ` +
725
+ `(a loader/action runs on the server; import the \`node:\` module there, not at a route's ` +
726
+ `top level):\n${lines.join("\n")}`,
727
+ )
728
+ }
729
+
730
+ // §3.3/§5.1: a module that opted into the `server-only` marker (`import "@nifrajs/web/server-only"`)
731
+ // yet reached a CLIENT chunk — fail the build with the same chain-bearing, "reached the client bundle
732
+ // via" message shape as the node-builtin guard above. This catches pure-server logic (a secret, a
733
+ // server-only API call) that carries no `node:` import and isn't named `*.server`, so neither of the
734
+ // other two guards fires. Graph-based (the same metafile), so it survives minification.
735
+ const serverOnly = detectServerOnlyInClient(clientMeta)
736
+ if (serverOnly.length > 0) {
737
+ const lines = serverOnly.map((f) =>
738
+ f.chain.length > 1
739
+ ? ` - server-only module reached the client bundle via ${f.chain.join(" → ")} (chunk: ${f.chunk})`
740
+ : ` - server-only module reached the client bundle via ${f.chunk}`,
741
+ )
742
+ throw new Error(
743
+ `[nifra/web] server-only module(s) in the client bundle — a module marked ` +
744
+ `\`import "${SERVER_ONLY_MARKER}"\` reached the browser. Move it behind a server-only path ` +
745
+ `(reach it via a loader/action, or rename it \`*.server.ts\`), so its server logic never ships ` +
746
+ `to the client:\n${lines.join("\n")}`,
747
+ )
748
+ }
749
+
750
+ // Rename any chunk whose basename isn't URL-safe (dynamic-route files become `[slug]-hash.js`) and
751
+ // rewrite the references — otherwise the lazy import 404s and the route silently never hydrates.
752
+ const renamed = sanitizeOutputNames(result.outputs)
753
+ const toUrl = (path: string): string =>
754
+ `${publicPath}${renamed.get(basename(path)) ?? basename(path)}`
149
755
  // Entry-point outputs come back in entrypoint order: [bootstrap, ...routeFiles]. Map each route file
150
756
  // to its chunk URL by that order (guarded against drift), then a route's chunks = its layout chain +
151
757
  // own file.
@@ -195,10 +801,9 @@ export async function buildClient(options: BuildClientOptions): Promise<BuildMan
195
801
  // same-basename collisions (`index.tsx` + `blog/index.tsx`) that a filename match can't. A page then
196
802
  // links only its layout chain + own CSS (deduped); an empty array means the page needs no CSS at all.
197
803
  // Absent (→ aggregate fallback) only if Bun emits no metafile/cssBundle — never silently incomplete.
198
- const meta = (result as unknown as { metafile?: BunMetafile }).metafile
199
804
  const cwd = process.cwd()
200
805
  const cssByEntry = new Map<string, string>()
201
- for (const out of Object.values(meta?.outputs ?? {})) {
806
+ for (const out of Object.values(clientMeta?.outputs ?? {})) {
202
807
  if (out.entryPoint !== undefined && out.cssBundle !== undefined) {
203
808
  cssByEntry.set(resolvePath(cwd, out.entryPoint), toUrl(out.cssBundle))
204
809
  }
@@ -297,6 +902,88 @@ const reactDomEdgePlugin = (from: string): BunPlugin => ({
297
902
  },
298
903
  })
299
904
 
905
+ /**
906
+ * Dedupe React to a single copy. A `file:`-linked package can ship its OWN `react` under its own
907
+ * node_modules, so the bundle ends up with two React cores — each with its own hook dispatcher — and SSR
908
+ * throws the cryptic `null is not an object (evaluating '…H.useState')` (the second renderer's dispatcher
909
+ * is null). This was the #1 time-sink in app builds. Pinning `react` + its JSX runtimes to ONE resolved
910
+ * copy (the app's, from `from`) guarantees a single dispatcher; `react-dom`, which imports `react`, then
911
+ * shares it — so the class can't occur rather than needing a named error. No-op when React isn't used
912
+ * (an unresolvable spec is skipped; the resolver only fires on an exact match). React core is
913
+ * condition-agnostic, so pinning it doesn't disturb the edge/browser/server conditions that select
914
+ * react-dom's build.
915
+ */
916
+ const REACT_DEDUPE_SPECS = ["react", "react/jsx-runtime", "react/jsx-dev-runtime"] as const
917
+ export const reactDedupePlugin = (from: string): BunPlugin => ({
918
+ name: "nifra-react-dedupe",
919
+ setup(build) {
920
+ for (const spec of REACT_DEDUPE_SPECS) {
921
+ let resolved: string
922
+ try {
923
+ resolved = Bun.resolveSync(spec, from)
924
+ } catch {
925
+ continue // React (or this subpath) isn't resolvable here — nothing to dedupe
926
+ }
927
+ const escaped = spec.replace(/[/\\^$*+?.()|[\]{}]/g, "\\$&")
928
+ build.onResolve({ filter: new RegExp(`^${escaped}$`) }, () => ({ path: resolved }))
929
+ }
930
+ },
931
+ })
932
+
933
+ /**
934
+ * Dedupe Preact to a single copy — the Preact analogue of `reactDedupePlugin`, closing the same class of
935
+ * bug for the Preact framework (which had NO build-time dedup before). A `file:`-linked package can ship
936
+ * its OWN `preact`, so the bundle ends up with two Preact cores; since `preact-render-to-string` mutates
937
+ * `preact`'s shared `options` global and `preact/hooks` writes the SAME global, two copies → two `options`
938
+ * → SSR throws `undefined is not an object (… __H)` (the vnode's hook state was set up on the other copy).
939
+ * Pinning `preact` + its hooks/compat/jsx subpaths to ONE resolved copy (the app's, from `from`) makes the
940
+ * renderer and the components share one core. No-op when Preact isn't used (an unresolvable spec is
941
+ * skipped). Preact core is condition-agnostic, so pinning it doesn't disturb any condition selection — and
942
+ * unlike react-dom there is no edge-vs-server build to preserve, so pinning the subpaths is safe.
943
+ */
944
+ const PREACT_DEDUPE_SPECS = [
945
+ "preact",
946
+ "preact/hooks",
947
+ "preact/compat",
948
+ "preact/jsx-runtime",
949
+ "preact/jsx-dev-runtime",
950
+ ] as const
951
+ export const preactDedupePlugin = (from: string): BunPlugin => ({
952
+ name: "nifra-preact-dedupe",
953
+ setup(build) {
954
+ for (const spec of PREACT_DEDUPE_SPECS) {
955
+ let resolved: string
956
+ try {
957
+ resolved = Bun.resolveSync(spec, from)
958
+ } catch {
959
+ continue // Preact (or this subpath) isn't resolvable here — nothing to dedupe
960
+ }
961
+ const escaped = spec.replace(/[/\\^$*+?.()|[\]{}]/g, "\\$&")
962
+ build.onResolve({ filter: new RegExp(`^${escaped}$`) }, () => ({ path: resolved }))
963
+ }
964
+ },
965
+ })
966
+
967
+ /**
968
+ * Remix-style `.server` convention for the CLIENT build. A module named `*.server.ts(x)` (`db.server.ts`,
969
+ * `auth.server.ts`, …) is server-only — empty it in the browser bundle so its (possibly `node:` / native /
970
+ * Capacitor) import subtree never reaches the client. The body is CJS-with-a-Proxy so any named OR default
971
+ * import resolves to `undefined` rather than a "missing export" bundle error (verified), and the real
972
+ * import subtree is gone. The complement to the node-builtin guard: when a server-only import is co-located
973
+ * in a route file (so it can't be tree-shaken out and the guard fails loud), moving it into a `*.server`
974
+ * module is the fix. CLIENT-only — buildServer keeps the real module, which runs server-side.
975
+ */
976
+ const SERVER_ONLY_MODULE = /\.server(\.[cm]?[jt]sx?)?$/
977
+ export const serverOnlyEmptyPlugin = (): BunPlugin => ({
978
+ name: "nifra-server-only-empty",
979
+ setup(build) {
980
+ build.onLoad({ filter: SERVER_ONLY_MODULE }, () => ({
981
+ contents: "module.exports = new Proxy({}, { get: () => undefined })",
982
+ loader: "js",
983
+ }))
984
+ },
985
+ })
986
+
300
987
  /**
301
988
  * `solid-js/web` selects its **server** runtime (`renderToStream`) via the `worker` condition, but
302
989
  * `Bun.build` 1.3.14 **segfaults** when the `worker` condition is active (https://bun.report). And
@@ -359,6 +1046,8 @@ export async function buildServer(options: BuildServerOptions): Promise<ServerBu
359
1046
  splitting: lazy,
360
1047
  plugins: [
361
1048
  ...(edge ? [reactDomEdgePlugin(entryDir), solidWebServerPlugin(entryDir)] : []),
1049
+ reactDedupePlugin(entryDir),
1050
+ preactDedupePlugin(entryDir),
362
1051
  ...(options.plugins ?? []),
363
1052
  ],
364
1053
  })
@@ -373,3 +1062,368 @@ export async function buildServer(options: BuildServerOptions): Promise<ServerBu
373
1062
  }
374
1063
  return { worker: entryOutput.path, outputs: result.outputs.map((o) => o.path) }
375
1064
  }
1065
+
1066
+ // ===================================================================================================
1067
+ // `nifra build --target` — package the engine above into one command that emits a full deploy dir.
1068
+ //
1069
+ // An app already declares everything the build needs through nifra's conventions: `adapter` +
1070
+ // `clientModule` (nifra.config.ts / framework.ts), an optional `backend` (backend.ts), and `routes/`.
1071
+ // The ONLY thing apps used to hand-write per target was the server entry (`_worker.ts`, `server-bun.ts`,
1072
+ // …) — so we GENERATE it here (per target) instead of asking each app to ship five near-identical files.
1073
+ // ===================================================================================================
1074
+
1075
+ /** A deploy target `nifra build --target <t>` can emit. `static` is pure SSG (no server). */
1076
+ export const BUILD_TARGETS = ["bun", "node", "deno", "cf-pages", "vercel", "static"] as const
1077
+ export type BuildTarget = (typeof BUILD_TARGETS)[number]
1078
+
1079
+ /** A type guard narrowing an arbitrary string to a {@link BuildTarget}. */
1080
+ export function isBuildTarget(value: string): value is BuildTarget {
1081
+ return (BUILD_TARGETS as readonly string[]).includes(value)
1082
+ }
1083
+
1084
+ /**
1085
+ * Codegen the per-target **server entry** module (source text) for `buildServer` to bundle. It imports
1086
+ * the app's `adapter` (from `framework.ts`), the optional `backend` (from `backend.ts`), and the
1087
+ * generated `{ manifest, clientEntry }` (from `./server-manifest`), builds `createWebApp`, then wires
1088
+ * the right host:
1089
+ * - `cf-pages` / `vercel`: `export default` the fetch handler (the platform serves /assets/* itself).
1090
+ * - `deno`: same fetch-handler default, plus `Deno.serve` self-host when run directly.
1091
+ * - `bun` / `node`: a self-hosting server that ALSO serves the client bundle from disk (those
1092
+ * runtimes have a filesystem; the static `/assets/*` sit next to the entry).
1093
+ * `adapterImport`/`backendImport` are the specifiers the entry uses (relative to where it's written) —
1094
+ * `buildServer` writes the entry next to `serverEntry`, so they're resolved from there. Pure (string in,
1095
+ * string out) so the generation is unit-testable without a real build.
1096
+ */
1097
+ export function generateServerEntry(options: {
1098
+ readonly target: BuildTarget
1099
+ /** Import specifier for the module exporting `adapter` (e.g. `"../framework.ts"`). */
1100
+ readonly adapterImport: string
1101
+ /** Import specifier for the module exporting `backend`, or `undefined` for a frontend-only app. */
1102
+ readonly backendImport?: string
1103
+ /** Document `<title>` passed to `createWebApp`. */
1104
+ readonly title?: string
1105
+ }): string {
1106
+ const { target, adapterImport, backendImport, title = "nifra" } = options
1107
+ if (target === "static") {
1108
+ throw new Error("[nifra/web] generateServerEntry: `static` has no server entry (SSG only)")
1109
+ }
1110
+ const lines: string[] = ['import { createWebApp } from "@nifrajs/web"']
1111
+ if (backendImport !== undefined) lines.push('import { inProcessClient } from "@nifrajs/client"')
1112
+ lines.push(`import { adapter } from ${JSON.stringify(adapterImport)}`)
1113
+ if (backendImport !== undefined) {
1114
+ lines.push(`import { backend } from ${JSON.stringify(backendImport)}`)
1115
+ }
1116
+ lines.push('import { clientEntry, manifest } from "./server-manifest"')
1117
+ // cf-pages/vercel/deno need the fetch-handler shape; bun/node call app.fetch directly.
1118
+ const usesToFetch = target === "cf-pages" || target === "vercel" || target === "deno"
1119
+ if (usesToFetch) lines.push('import { toFetchHandler } from "@nifrajs/core"')
1120
+ if (target === "node") lines.push('import { serve } from "@nifrajs/node"')
1121
+ lines.push(
1122
+ "",
1123
+ "const app = createWebApp({",
1124
+ " adapter,",
1125
+ " manifest,",
1126
+ " clientEntry,",
1127
+ ...(backendImport !== undefined ? [" api: inProcessClient(backend),"] : []),
1128
+ ` title: ${JSON.stringify(title)},`,
1129
+ "})",
1130
+ "",
1131
+ )
1132
+
1133
+ if (target === "cf-pages") {
1134
+ // Cloudflare Pages advanced mode: `_routes.json` serves /assets/* from the CDN; everything else
1135
+ // falls through to this fetch handler (SSR). The default export is the handler object.
1136
+ lines.push("export default toFetchHandler(app)")
1137
+ return `${lines.join("\n")}\n`
1138
+ }
1139
+ if (target === "vercel") {
1140
+ lines.push(
1141
+ "// Vercel Edge Function — Vercel serves /assets/* from its CDN; this only SSRs page routes.",
1142
+ 'export const config = { runtime: "edge" }',
1143
+ "export default (req: Request): Response | Promise<Response> => app.fetch(req)",
1144
+ )
1145
+ return `${lines.join("\n")}\n`
1146
+ }
1147
+
1148
+ // bun / node / deno self-host AND serve the client bundle from disk (it sits next to this entry).
1149
+ lines.push(
1150
+ "// The client bundle lives next to this entry; serve /assets/* from disk, SSR everything else.",
1151
+ 'const ASSETS = new URL("./assets/", import.meta.url)',
1152
+ 'const TYPES = { js: "text/javascript", css: "text/css", map: "application/json" }',
1153
+ )
1154
+ if (target === "bun") {
1155
+ lines.push(
1156
+ "const server = Bun.serve({",
1157
+ " port: Number(Bun.env.PORT ?? 3000),",
1158
+ " async fetch(req) {",
1159
+ " const { pathname } = new URL(req.url)",
1160
+ ' if (pathname.startsWith("/assets/")) {',
1161
+ ' const name = pathname.slice("/assets/".length)',
1162
+ ' if (!/^[A-Za-z0-9._-]+$/.test(name)) return new Response("bad request", { status: 400 })',
1163
+ " const file = Bun.file(new URL(name, ASSETS))",
1164
+ ' if (!(await file.exists())) return new Response("not found", { status: 404 })',
1165
+ ' const ext = name.slice(name.lastIndexOf(".") + 1)',
1166
+ ' return new Response(file, { headers: { "content-type": TYPES[ext] ?? "application/octet-stream" } })',
1167
+ " }",
1168
+ " return app.fetch(req)",
1169
+ " },",
1170
+ "})",
1171
+ // The `${...}` here is literal OUTPUT (a template in the GENERATED file), not a template in this
1172
+ // source — split so biome's noTemplateCurlyInString doesn't flag it; the emitted line is unchanged.
1173
+ `console.log(\`nifra (Bun) → http://localhost:$${"{server.port}"}\`)`,
1174
+ )
1175
+ return `${lines.join("\n")}\n`
1176
+ }
1177
+ if (target === "node") {
1178
+ lines.push(
1179
+ 'import { readFile } from "node:fs/promises"',
1180
+ "await serve(",
1181
+ " {",
1182
+ " async fetch(req) {",
1183
+ " const { pathname } = new URL(req.url)",
1184
+ ' if (pathname.startsWith("/assets/")) {',
1185
+ ' const name = pathname.slice("/assets/".length)',
1186
+ ' if (!/^[A-Za-z0-9._-]+$/.test(name)) return new Response("bad request", { status: 400 })',
1187
+ " try {",
1188
+ " const body = await readFile(new URL(name, ASSETS))",
1189
+ ' const ext = name.slice(name.lastIndexOf(".") + 1)',
1190
+ ' return new Response(body, { headers: { "content-type": TYPES[ext] ?? "application/octet-stream" } })',
1191
+ " } catch {",
1192
+ ' return new Response("not found", { status: 404 })',
1193
+ " }",
1194
+ " }",
1195
+ " return app.fetch(req)",
1196
+ " },",
1197
+ " },",
1198
+ " { port: Number(process.env.PORT ?? 3000) },",
1199
+ ")",
1200
+ )
1201
+ return `${lines.join("\n")}\n`
1202
+ }
1203
+ // deno
1204
+ lines.push(
1205
+ "const handler = toFetchHandler(app)",
1206
+ "// @ts-ignore — Deno global is present on the Deno runtime this output targets.",
1207
+ 'Deno.serve({ port: Number(Deno.env.get("PORT") ?? "3000") }, async (req) => {',
1208
+ " const { pathname } = new URL(req.url)",
1209
+ ' if (pathname.startsWith("/assets/")) {',
1210
+ ' const name = pathname.slice("/assets/".length)',
1211
+ ' if (!/^[A-Za-z0-9._-]+$/.test(name)) return new Response("bad request", { status: 400 })',
1212
+ " try {",
1213
+ " // @ts-ignore — Deno.readFile is present on the Deno runtime.",
1214
+ " const body = await Deno.readFile(new URL(name, ASSETS))",
1215
+ ' const ext = name.slice(name.lastIndexOf(".") + 1)',
1216
+ ' return new Response(body, { headers: { "content-type": TYPES[ext] ?? "application/octet-stream" } })',
1217
+ " } catch {",
1218
+ ' return new Response("not found", { status: 404 })',
1219
+ " }",
1220
+ " }",
1221
+ " return handler.fetch(req)",
1222
+ "})",
1223
+ )
1224
+ return `${lines.join("\n")}\n`
1225
+ }
1226
+
1227
+ /** The Bun.build `target` each deploy target compiles its server with (mirrors `buildServer`'s docs). */
1228
+ const SERVER_BUILD_TARGET: Record<Exclude<BuildTarget, "static">, "browser" | "node" | "bun"> = {
1229
+ "cf-pages": "browser", // edge conditions (workerd/edge-light)
1230
+ vercel: "browser", // Vercel Edge runtime
1231
+ deno: "browser", // Deno's Web-standard runtime runs the edge bundle
1232
+ node: "node", // node:* external; react-dom → its Node SSR build
1233
+ bun: "bun", // Bun's flagship runtime
1234
+ }
1235
+
1236
+ /** Measure each emitted output file's raw + gzip size. Reads the file off disk and gzips it with
1237
+ * `Bun.gzipSync` (the over-the-wire weight). Async only because it reads files; the aggregation is the
1238
+ * pure {@link aggregateSizeReport}. */
1239
+ async function measureOutputs(paths: readonly string[]): Promise<ChunkSize[]> {
1240
+ const chunks: ChunkSize[] = []
1241
+ for (const path of paths) {
1242
+ const bytes = await Bun.file(path).bytes()
1243
+ chunks.push({
1244
+ name: basename(path),
1245
+ bytes: bytes.byteLength,
1246
+ gzip: Bun.gzipSync(bytes).byteLength,
1247
+ })
1248
+ }
1249
+ return chunks
1250
+ }
1251
+
1252
+ export interface BuildTargetOptions {
1253
+ /** The `routes/` directory to discover (absolute path). */
1254
+ readonly routesDir: string
1255
+ /** Output directory for the assembled deploy dir (absolute path). Cleared and recreated. */
1256
+ readonly outDir: string
1257
+ /** A scratch directory for intermediate codegen (the generated server entry + manifest) and the
1258
+ * server bundle, cleaned up after. Absolute path. */
1259
+ readonly workDir: string
1260
+ /** The adapter's client runtime module (exports `mountRouter`), e.g. `"@nifrajs/web-react/client"`. */
1261
+ readonly clientModule: string
1262
+ /** Import specifier (resolvable from `workDir`) of the module exporting `adapter`. */
1263
+ readonly adapterImport: string
1264
+ /** Import specifier (resolvable from `workDir`) of the module exporting `backend`, or `undefined`. */
1265
+ readonly backendImport?: string
1266
+ /** A pre-built app instance for `static` prerendering (a `createWebApp` result). Required for
1267
+ * `target: "static"` (SSG drives `app.fetch`); ignored otherwise. */
1268
+ readonly prerenderApp?: PrerenderAppLike
1269
+ /** Client-build plugins (e.g. the MDX/Vue/Solid Bun plugins). */
1270
+ readonly clientPlugins?: readonly BunPlugin[]
1271
+ /** Server-build plugins (e.g. the SSR variants). */
1272
+ readonly serverPlugins?: readonly BunPlugin[]
1273
+ /** Extra Bun.build resolve conditions for the CLIENT build. */
1274
+ readonly conditions?: readonly string[]
1275
+ /** Compile-time `define` replacements layered onto both builds. */
1276
+ readonly define?: Readonly<Record<string, string>>
1277
+ /** Document `<title>` for the generated server entry. */
1278
+ readonly title?: string
1279
+ }
1280
+
1281
+ /** Minimal app surface `buildTarget`'s static path needs — a fetch handler (a built `createWebApp`). */
1282
+ export interface PrerenderAppLike {
1283
+ fetch(req: Request): Response | Promise<Response>
1284
+ }
1285
+
1286
+ /** The result of a target build — the deploy dir + the client manifest + an optional size report. */
1287
+ export interface BuildTargetResult {
1288
+ /** The deploy target that was built. */
1289
+ readonly target: BuildTarget
1290
+ /** The assembled output directory. */
1291
+ readonly outDir: string
1292
+ /** The client build's manifest (entry URL, assets, per-route chunks/styles). */
1293
+ readonly client: BuildManifest
1294
+ /** A human-readable note on how to run/deploy the output. */
1295
+ readonly run: string
1296
+ /** Per-chunk size report over the emitted client (+ server) outputs. Always computed; the CLI prints
1297
+ * it only with `--report`. */
1298
+ readonly size: SizeReport
1299
+ }
1300
+
1301
+ /**
1302
+ * Build a full deploy directory for `target` from a file-routed nifra app. Emits the client bundle to
1303
+ * `<outDir>/assets/*`, then per target:
1304
+ * - `static`: prerenders opted-in routes (`prerenderRoutes`) to `<outDir>/<path>/index.html` (+
1305
+ * `_data.json`); needs `prerenderApp`. No server.
1306
+ * - `cf-pages`: a `_worker.js` (edge bundle) + a `_routes.json` excluding /assets/* from the worker.
1307
+ * - `vercel`: a `.vercel/output`-shaped function isn't emitted here — `vercel` emits the bundled edge
1308
+ * entry as `<outDir>/index.js` (the CLI's docs point at `vercel`'s Build Output wrapper). [see note]
1309
+ * - `deno`/`node`/`bun`: the self-hosting server bundle (`server.js`) next to the assets.
1310
+ * The server entry is GENERATED (`generateServerEntry`) and bundled (`buildServer`); the app supplies
1311
+ * only adapter/backend/routes. Returns the manifest + a size report. Throws on any build failure.
1312
+ *
1313
+ * Note: the heavier platform wrappers (`.vercel/output` v3 layout, wrangler ISR `find_additional_modules`)
1314
+ * remain app-owned scripts; this command targets the common single-bundle deploys. See the CLI docs.
1315
+ */
1316
+ export async function buildTarget(
1317
+ target: BuildTarget,
1318
+ options: BuildTargetOptions,
1319
+ ): Promise<BuildTargetResult> {
1320
+ const { routesDir, outDir, workDir } = options
1321
+ const { rmSync } = await import("node:fs")
1322
+ rmSync(outDir, { recursive: true, force: true })
1323
+ rmSync(workDir, { recursive: true, force: true })
1324
+ const assetsDir = `${outDir}/assets`
1325
+ mkdirSync(assetsDir, { recursive: true })
1326
+ mkdirSync(workDir, { recursive: true })
1327
+
1328
+ // (1) Client bundle → <outDir>/assets/* (every target ships the same hashed client bundle).
1329
+ const client = await buildClient({
1330
+ routesDir,
1331
+ outDir: assetsDir,
1332
+ clientModule: options.clientModule,
1333
+ ...(options.clientPlugins ? { plugins: options.clientPlugins } : {}),
1334
+ ...(options.conditions ? { conditions: options.conditions } : {}),
1335
+ define: { "process.env.NODE_ENV": '"production"', ...(options.define ?? {}) },
1336
+ })
1337
+
1338
+ if (target === "static") {
1339
+ if (options.prerenderApp === undefined) {
1340
+ throw new Error(
1341
+ "[nifra/web] buildTarget(static) requires `prerenderApp` (a built createWebApp)",
1342
+ )
1343
+ }
1344
+ const manifest = discoverRoutes(routesDir)
1345
+ const result = await prerenderRoutes({
1346
+ app: options.prerenderApp,
1347
+ routes: manifest.routes,
1348
+ outDir,
1349
+ })
1350
+ if (result.prerendered.length === 0) {
1351
+ // A static build that renders nothing is almost always a misconfig (no `prerender = true` / no
1352
+ // getStaticPaths) — fail loudly rather than ship an empty dir the dev thinks is their site.
1353
+ throw new Error(
1354
+ "[nifra/web] buildTarget(static): no routes were prerendered — opt routes in with " +
1355
+ "`export const prerender = true` (static) or `getStaticPaths` (dynamic).",
1356
+ )
1357
+ }
1358
+ rmSync(workDir, { recursive: true, force: true })
1359
+ const size = aggregateSizeReport(
1360
+ await measureOutputs(client.assets.map((u) => assetUrlToPath(u, assetsDir))),
1361
+ )
1362
+ return {
1363
+ target,
1364
+ outDir,
1365
+ client,
1366
+ run: `static site → ${outDir} (serve the directory with any static host)`,
1367
+ size,
1368
+ }
1369
+ }
1370
+
1371
+ // (2) Generate + bundle the server entry. It's written into workDir; the generated server-manifest
1372
+ // lands next to it (buildServer writes it there). The adapter/backend specifiers are resolved from
1373
+ // workDir, so the caller passes paths relative to it (or absolute).
1374
+ const serverEntryPath = `${workDir}/server-entry.ts`
1375
+ writeFileSync(
1376
+ serverEntryPath,
1377
+ generateServerEntry({
1378
+ target,
1379
+ adapterImport: options.adapterImport,
1380
+ ...(options.backendImport !== undefined ? { backendImport: options.backendImport } : {}),
1381
+ ...(options.title !== undefined ? { title: options.title } : {}),
1382
+ }),
1383
+ )
1384
+ const serverTarget = SERVER_BUILD_TARGET[target]
1385
+ const { worker } = await buildServer({
1386
+ routesDir,
1387
+ serverEntry: serverEntryPath,
1388
+ outDir: `${workDir}/server`,
1389
+ clientEntry: client.entry,
1390
+ target: serverTarget,
1391
+ ...(options.serverPlugins ? { plugins: options.serverPlugins } : {}),
1392
+ define: { "process.env.NODE_ENV": '"production"', ...(options.define ?? {}) },
1393
+ })
1394
+
1395
+ // (3) Assemble the deploy dir for the target.
1396
+ const { cpSync } = await import("node:fs")
1397
+ let run: string
1398
+ if (target === "cf-pages") {
1399
+ cpSync(worker, `${outDir}/_worker.js`)
1400
+ writeFileSync(
1401
+ `${outDir}/_routes.json`,
1402
+ `${JSON.stringify({ version: 1, include: ["/*"], exclude: ["/assets/*"] }, null, 2)}\n`,
1403
+ )
1404
+ run = `Cloudflare Pages → ${outDir} (deploy: wrangler pages deploy ${basename(outDir)})`
1405
+ } else if (target === "vercel") {
1406
+ cpSync(worker, `${outDir}/index.js`)
1407
+ run = `Vercel edge function → ${outDir}/index.js (wrap with your vercel.json or Build Output API)`
1408
+ } else {
1409
+ // bun / node / deno: the runnable server bundle next to its /assets.
1410
+ cpSync(worker, `${outDir}/server.js`)
1411
+ const cmd =
1412
+ target === "node"
1413
+ ? `node ${basename(outDir)}/server.js`
1414
+ : `${target} ${basename(outDir)}/server.js`
1415
+ run = `${target} server → ${outDir} (run: ${cmd})`
1416
+ }
1417
+ rmSync(workDir, { recursive: true, force: true })
1418
+
1419
+ // Size report over the client assets + the server bundle (its parse cost matters on the edge).
1420
+ const clientPaths = client.assets.map((u) => assetUrlToPath(u, assetsDir))
1421
+ const serverPath = `${outDir}/${target === "cf-pages" ? "_worker.js" : target === "vercel" ? "index.js" : "server.js"}`
1422
+ const size = aggregateSizeReport(await measureOutputs([...clientPaths, serverPath]))
1423
+ return { target, outDir, client, run, size }
1424
+ }
1425
+
1426
+ /** Map a client asset URL (`/assets/x-hash.js`) back to its on-disk path under `assetsDir`. The
1427
+ * `publicPath` prefix is always `/assets/` for these builds, so strip it and rejoin. */
1428
+ const assetUrlToPath = (url: string, assetsDir: string): string =>
1429
+ `${assetsDir}/${url.slice(url.lastIndexOf("/") + 1)}`