@nifrajs/web 2.0.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/dist/build-vite.d.ts +68 -0
  2. package/dist/build-vite.d.ts.map +1 -0
  3. package/dist/build-vite.js +487 -0
  4. package/dist/build-vite.js.map +1 -0
  5. package/dist/build.d.ts +112 -25
  6. package/dist/build.d.ts.map +1 -1
  7. package/dist/build.js +283 -64
  8. package/dist/build.js.map +1 -1
  9. package/dist/bun-dev-entry.d.ts +83 -0
  10. package/dist/bun-dev-entry.d.ts.map +1 -0
  11. package/dist/bun-dev-entry.js +136 -0
  12. package/dist/bun-dev-entry.js.map +1 -0
  13. package/dist/client.d.ts.map +1 -1
  14. package/dist/client.js +10 -0
  15. package/dist/client.js.map +1 -1
  16. package/dist/deferred.d.ts +3 -2
  17. package/dist/deferred.d.ts.map +1 -1
  18. package/dist/deferred.js +11 -8
  19. package/dist/deferred.js.map +1 -1
  20. package/dist/dev-port.d.ts +36 -0
  21. package/dist/dev-port.d.ts.map +1 -0
  22. package/dist/dev-port.js +48 -0
  23. package/dist/dev-port.js.map +1 -0
  24. package/dist/dev.d.ts +61 -4
  25. package/dist/dev.d.ts.map +1 -1
  26. package/dist/dev.js +419 -60
  27. package/dist/dev.js.map +1 -1
  28. package/dist/draft.d.ts +37 -0
  29. package/dist/draft.d.ts.map +1 -1
  30. package/dist/draft.js +87 -4
  31. package/dist/draft.js.map +1 -1
  32. package/dist/fs.d.ts +14 -1
  33. package/dist/fs.d.ts.map +1 -1
  34. package/dist/fs.js +7 -1
  35. package/dist/fs.js.map +1 -1
  36. package/dist/index.d.ts +116 -5
  37. package/dist/index.d.ts.map +1 -1
  38. package/dist/index.js +565 -53
  39. package/dist/index.js.map +1 -1
  40. package/dist/internal/timing-safe-equal.d.ts +16 -0
  41. package/dist/internal/timing-safe-equal.d.ts.map +1 -0
  42. package/dist/internal/timing-safe-equal.js +26 -0
  43. package/dist/internal/timing-safe-equal.js.map +1 -0
  44. package/dist/isr.d.ts.map +1 -1
  45. package/dist/isr.js +1 -14
  46. package/dist/isr.js.map +1 -1
  47. package/dist/llms-txt.d.ts.map +1 -1
  48. package/dist/llms-txt.js +12 -6
  49. package/dist/llms-txt.js.map +1 -1
  50. package/dist/manifest.d.ts +51 -8
  51. package/dist/manifest.d.ts.map +1 -1
  52. package/dist/manifest.js +130 -14
  53. package/dist/manifest.js.map +1 -1
  54. package/dist/module-graph.d.ts +91 -0
  55. package/dist/module-graph.d.ts.map +1 -0
  56. package/dist/module-graph.js +92 -0
  57. package/dist/module-graph.js.map +1 -0
  58. package/dist/plugins/css-modules.d.ts.map +1 -1
  59. package/dist/plugins/css-modules.js +0 -0
  60. package/dist/plugins/css-modules.js.map +1 -1
  61. package/dist/plugins/vite-leak-guard.d.ts +45 -0
  62. package/dist/plugins/vite-leak-guard.d.ts.map +1 -0
  63. package/dist/plugins/vite-leak-guard.js +67 -0
  64. package/dist/plugins/vite-leak-guard.js.map +1 -0
  65. package/dist/public-dir.d.ts +36 -0
  66. package/dist/public-dir.d.ts.map +1 -0
  67. package/dist/public-dir.js +89 -0
  68. package/dist/public-dir.js.map +1 -0
  69. package/dist/route-manifest.d.ts +91 -0
  70. package/dist/route-manifest.d.ts.map +1 -0
  71. package/dist/route-manifest.js +128 -0
  72. package/dist/route-manifest.js.map +1 -0
  73. package/dist/router.d.ts +64 -1
  74. package/dist/router.d.ts.map +1 -1
  75. package/dist/router.js +114 -31
  76. package/dist/router.js.map +1 -1
  77. package/dist/vite.d.ts +10 -4
  78. package/dist/vite.d.ts.map +1 -1
  79. package/dist/vite.js +44 -12
  80. package/dist/vite.js.map +1 -1
  81. package/package.json +17 -2
  82. package/src/build-vite.ts +606 -0
  83. package/src/build.ts +432 -73
  84. package/src/bun-dev-entry.ts +184 -0
  85. package/src/client.ts +8 -0
  86. package/src/deferred.ts +13 -6
  87. package/src/dev-port.ts +63 -0
  88. package/src/dev.ts +467 -65
  89. package/src/draft.ts +116 -4
  90. package/src/fs.ts +22 -4
  91. package/src/index.ts +727 -57
  92. package/src/internal/timing-safe-equal.ts +24 -0
  93. package/src/isr.ts +1 -13
  94. package/src/llms-txt.ts +13 -5
  95. package/src/manifest.ts +188 -13
  96. package/src/module-graph.ts +146 -0
  97. package/src/plugins/css-modules.ts +0 -0
  98. package/src/plugins/vite-leak-guard.ts +110 -0
  99. package/src/public-dir.ts +108 -0
  100. package/src/route-manifest.ts +240 -0
  101. package/src/router.ts +198 -34
  102. package/src/vite.ts +62 -20
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Constant-time string equality, shared by every secret check in `@nifrajs/web`. Internal: no
3
+ * `exports` entry points here, so it never becomes public API.
4
+ *
5
+ * One owner per package rather than a copy per call site — this is the primitive whose whole value
6
+ * lies in a detail (no early exit) that a well-meaning "simplification" silently removes, and a bug
7
+ * in it is invisible in review and in tests, since a timing-unsafe compare returns the same answers.
8
+ */
9
+
10
+ /**
11
+ * Compare two strings without leaking, via timing, where they first differ. A length mismatch returns
12
+ * `false` up front: the length isn't the secret, and different-length buffers can't be compared in
13
+ * constant time anyway. Encodes to bytes first so multi-byte characters compare by content, not by
14
+ * UTF-16 code unit.
15
+ */
16
+ export function timingSafeEqual(a: string, b: string): boolean {
17
+ const enc = new TextEncoder()
18
+ const ba = enc.encode(a)
19
+ const bb = enc.encode(b)
20
+ if (ba.length !== bb.length) return false
21
+ let diff = 0
22
+ for (let i = 0; i < ba.length; i++) diff |= (ba[i] as number) ^ (bb[i] as number)
23
+ return diff === 0
24
+ }
package/src/isr.ts CHANGED
@@ -7,6 +7,7 @@
7
7
  * This module is the store primitive; the SWR wrapper (`withISR`) builds on it next.
8
8
  */
9
9
  import { isDraftEnabled } from "./draft.ts"
10
+ import { timingSafeEqual } from "./internal/timing-safe-equal.ts"
10
11
 
11
12
  /** A cached SSR response — the bytes + metadata a {@link CacheStore} persists. */
12
13
  export interface CachedResponse {
@@ -406,19 +407,6 @@ export function withISR(
406
407
  const jsonError = (status: number, error: string): Response =>
407
408
  Response.json({ ok: false, error }, { status })
408
409
 
409
- /** Constant-time string equality (XOR-accumulate, no early exit). A length mismatch returns `false`
410
- * up front — the length isn't the secret, and different-length buffers can't be compared in constant
411
- * time anyway. Used to check the on-demand revalidation token without leaking it via timing. */
412
- function timingSafeEqual(a: string, b: string): boolean {
413
- const enc = new TextEncoder()
414
- const ba = enc.encode(a)
415
- const bb = enc.encode(b)
416
- if (ba.length !== bb.length) return false
417
- let diff = 0
418
- for (let i = 0; i < ba.length; i++) diff |= (ba[i] as number) ^ (bb[i] as number)
419
- return diff === 0
420
- }
421
-
422
410
  export interface RevalidateEndpointOptions {
423
411
  readonly store: CacheStore
424
412
  /** Shared secret; the request's token must match it (constant-time). */
package/src/llms-txt.ts CHANGED
@@ -91,11 +91,19 @@ export async function generateLlmsTxt(
91
91
  // 1. Project Guidelines
92
92
  let agentsMd = ""
93
93
  try {
94
- if (typeof Bun !== "undefined") {
95
- agentsMd = await Bun.file("AGENTS.md").text()
96
- } else {
97
- const fs = await import("node:fs/promises")
98
- agentsMd = await fs.readFile("AGENTS.md", "utf-8")
94
+ // Edge bundles define this flag and dead-code-eliminate the filesystem branch. Keeping the import
95
+ // behind a runtime-only `typeof Bun` check is insufficient: Rollup must still emit an unresolved
96
+ // `node:` dependency, which workerd cannot load.
97
+ const edgeRuntime = (
98
+ globalThis as typeof globalThis & { readonly __NIFRA_EDGE_RUNTIME__?: boolean }
99
+ ).__NIFRA_EDGE_RUNTIME__
100
+ if (edgeRuntime !== true) {
101
+ if (typeof Bun !== "undefined") {
102
+ agentsMd = await Bun.file("AGENTS.md").text()
103
+ } else {
104
+ const fs = await import("node:fs/promises")
105
+ agentsMd = await fs.readFile("AGENTS.md", "utf-8")
106
+ }
99
107
  }
100
108
  } catch {
101
109
  // ignore
package/src/manifest.ts CHANGED
@@ -89,6 +89,23 @@ export interface Meta {
89
89
  readonly link?: readonly LinkDescriptor[]
90
90
  /** Inert head `<script>`s (JSON-LD structured data, etc.). See {@link ScriptDescriptor}. */
91
91
  readonly script?: readonly ScriptDescriptor[]
92
+ /**
93
+ * The document language — `<html lang="...">`. Nearest-wins like `title`, so a localized route can
94
+ * override a layout's default. Defaults to `"en"` when no head in the chain sets it.
95
+ *
96
+ * This is the ONLY way to set it: the shell's `<html>` is framework-owned, so a multilingual app
97
+ * otherwise serves every URL as `lang="en"` — which tells a screen reader to pronounce every locale
98
+ * with an English voice, and suppresses the browser's translation offer.
99
+ */
100
+ readonly lang?: string
101
+ /**
102
+ * The document writing direction — `<html dir="...">`. Nearest-wins like `title`. Omitted from the
103
+ * shell when unset, which is HTML's `ltr` default, so an LTR app's output is byte-identical.
104
+ *
105
+ * Required for Arabic/Hebrew/Urdu/Persian: without it the browser lays the page out left-to-right
106
+ * and mis-orders trailing punctuation, so the text renders wrong rather than merely unstyled.
107
+ */
108
+ readonly dir?: "ltr" | "rtl" | "auto"
92
109
  }
93
110
 
94
111
  /**
@@ -195,6 +212,16 @@ export interface RouteEntry {
195
212
  readonly id: string
196
213
  readonly pattern: string
197
214
  readonly layoutIds: readonly string[]
215
+ /**
216
+ * Param names each layout in {@link layoutIds} owns, aligned by index.
217
+ *
218
+ * A layout wraps a URL prefix, so these are the params it may read and the ones whose change should
219
+ * re-run its loader - everything deeper belongs to routes beneath it. Derived per (route, layout)
220
+ * pair rather than per layout, because one layout can own different params on different expanded
221
+ * patterns: `[[lang]]/docs/_layout` owns nothing on `/docs/:slug` and `{lang}` on `/:lang/docs/:slug`.
222
+ * Optional only so hand-built test manifests may omit it.
223
+ */
224
+ readonly layoutParams?: ReadonlyArray<readonly string[]>
198
225
  /** `_error` boundary ids in this route's ancestor chain (outermost → innermost). The last is the
199
226
  * **nearest** boundary, rendered when the route's loader throws. Always set by `buildManifest`
200
227
  * (optional only so hand-built test manifests may omit it); absent/empty ⇒ no boundary (error 500s). */
@@ -211,6 +238,11 @@ export interface Manifest {
211
238
  * `buildManifest` (optional only so hand-built test manifests may omit it). */
212
239
  readonly errors?: Readonly<Record<string, LayoutEntry>>
213
240
  readonly notFound?: LayoutEntry
241
+ /** Per-status terminal pages from `_<status>.tsx` at the routes root (`_410`, `_451`, …), keyed by
242
+ * the status as a string. Rendered by a loader's `gone()` / `statusPage(n)`; a status with no page
243
+ * falls back to `_404`, and then to plain text. `_404` itself stays on {@link notFound} - it is
244
+ * reached by unmatched paths too, which these are not. */
245
+ readonly statusPages?: Readonly<Record<string, LayoutEntry>>
214
246
  }
215
247
 
216
248
  // `.svelte` and `.vue` routes are supported too: their `default` export is the component and
@@ -226,6 +258,10 @@ const OPTIONAL = /^\[\[([A-Za-z_][A-Za-z0-9_]*)\]\]$/
226
258
  // A route group: a `(name)` folder organizes routes (and can hold its own `_layout`) without
227
259
  // contributing a URL segment — mirrors Next/Remix. Requires content between the parens.
228
260
  const GROUP = /^\(.+\)$/
261
+ // A terminal status page: `_410.tsx`, `_451.tsx`. `_404` is matched before this and stays its own
262
+ // thing. Restricted to 3 digits so `_401k` or `_4` is treated as an ordinary underscore-prefixed
263
+ // file (ignored) rather than silently becoming a status page.
264
+ const STATUS_PAGE = /^_[1-5][0-9][0-9]$/
229
265
 
230
266
  const stripExt = (file: string): string => file.replace(ROUTE_EXT, "")
231
267
  const baseName = (file: string): string => file.slice(file.lastIndexOf("/") + 1)
@@ -242,14 +278,112 @@ const errorIdFor = (dir: string): string => (dir === "" ? "_error" : `${dir}/_er
242
278
  * with no optionals yields exactly one pattern. Throws on an invalid param or a catch-all that isn't
243
279
  * the last segment.
244
280
  */
281
+ /** Every `[…]` marker in a file segment, so a part-literal segment can be spliced into a pattern. */
282
+ const FILE_MARKER = /\[([^\]]*)\]/g
283
+ const FILE_PARAM_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/
284
+
285
+ /**
286
+ * Translate a part-literal-part-`[param]` file segment into its route pattern, or `undefined` when
287
+ * the segment is wholly literal or wholly a marker (both already handled by the caller).
288
+ *
289
+ * `[inKey].txt` becomes `:inKey.txt`. Needed because IndexNow-style verification files, per-locale
290
+ * sitemaps, and static-site URL parity (`post-[id].html`) all put a variable INSIDE a filename, and
291
+ * `stripExt` already reduces `[inKey].txt.tsx` to `[inKey].txt` - `robots.txt.tsx` proves literal
292
+ * dots survive into a segment, so this was only ever a parser gap.
293
+ *
294
+ * `[[optional]]` and `[...catchAll]` are **rejected** inside a mixed segment rather than given a
295
+ * meaning. There is no sensible absent form for `/[[locale]]-feed.xml` - `/-feed.xml` and dropping
296
+ * the segment are both surprising - and a catch-all captures the remaining path verbatim, which
297
+ * conflicts with a trailing literal. A clear rejection beats semantics nobody can predict.
298
+ */
299
+ function mixedFileSegment(seg: string, file: string): string | undefined {
300
+ // A segment that is ENTIRELY one of the existing whole-segment forms belongs to the caller, not
301
+ // here. Checked against the real patterns rather than by comparing the first marker to the
302
+ // segment: `[[lang]]` does not equal its own first `[…]` match, so a naive comparison sends every
303
+ // optional segment down the mixed path and rejects it.
304
+ if (PARAM.test(seg) || OPTIONAL.test(seg) || CATCH_ALL.test(seg)) return undefined
305
+ const markers = [...seg.matchAll(FILE_MARKER)]
306
+ if (markers.length === 0) return undefined
307
+
308
+ let pattern = ""
309
+ let cursor = 0
310
+ for (const marker of markers) {
311
+ const inner = marker[1] ?? ""
312
+ if (inner.startsWith("...")) {
313
+ throw new Error(
314
+ `[nifra/web] catch-all "[${inner}]" cannot be combined with literal text in "${file}" (segment "${seg}") — a catch-all captures the rest of the path, so a trailing literal can never match. Give it its own segment.`,
315
+ )
316
+ }
317
+ if (inner.startsWith("[")) {
318
+ throw new Error(
319
+ `[nifra/web] optional "[${inner}]]" cannot be combined with literal text in "${file}" (segment "${seg}") — there is no sensible form for the segment when it is absent. Use a required [name], or give the optional its own segment.`,
320
+ )
321
+ }
322
+ if (!FILE_PARAM_NAME.test(inner)) {
323
+ throw new Error(
324
+ `[nifra/web] invalid route param in "${file}": "[${inner}]" must be [name], [[name]], [...name], or a (group) folder`,
325
+ )
326
+ }
327
+ pattern += `${seg.slice(cursor, marker.index)}:${inner}`
328
+ cursor = (marker.index ?? 0) + marker[0].length
329
+ }
330
+ return pattern + seg.slice(cursor)
331
+ }
332
+
245
333
  export function filePathToPatterns(file: string): string[] {
246
- // Each combo is a list of URL segments built so far; an optional segment doubles the set.
247
- let combos: string[][] = [[]]
334
+ return filePathToRoutes(file).map((route) => route.pattern)
335
+ }
336
+
337
+ /** One URL pattern a route file expands to, plus the scope information layouts need. */
338
+ export interface FileRoutePattern {
339
+ readonly pattern: string
340
+ /**
341
+ * URL segments contributed by the first *k* path parts of the file, indexed by *k*.
342
+ *
343
+ * This is what makes a layout's scope derivable without turning layouts into router nodes. A layout
344
+ * lives at a directory; the segments that directory contributes are exactly the prefix of the URL it
345
+ * wraps, so `depths[partsIn(layoutDir)]` is how many leading pattern segments it owns - and the
346
+ * params inside that prefix are the ones it may read.
347
+ *
348
+ * Per EXPANDED pattern, not per file, because the two diverge: `[[lang]]/docs/_layout` owns nothing
349
+ * on `/docs/:slug` and `{lang}` on `/:lang/docs/:slug`. A route group contributes no segment, so
350
+ * `(marketing)/_layout` owns nothing at all.
351
+ */
352
+ readonly depths: readonly number[]
353
+ }
354
+
355
+ /** Expand a route file into its URL patterns, each carrying per-directory segment counts. */
356
+ export function filePathToRoutes(file: string): FileRoutePattern[] {
357
+ // Each combo is the URL segments built so far, plus the running per-path-part segment count.
358
+ let combos: Array<{ segs: string[]; depths: number[] }> = [{ segs: [], depths: [0] }]
248
359
  const segments = stripExt(file).split("/")
360
+ // Record the running segment count after each path part, on every combo. Called once per part so
361
+ // `depths` stays index-aligned with the file's path parts even for parts that emit nothing.
362
+ const mark = (): void => {
363
+ for (const combo of combos) combo.depths.push(combo.segs.length)
364
+ }
365
+ const push = (segment: string): void => {
366
+ for (const combo of combos) combo.segs.push(segment)
367
+ }
249
368
  for (let i = 0; i < segments.length; i++) {
250
369
  const seg = segments[i]!
251
- if (seg === "index") continue
252
- if (GROUP.test(seg)) continue // route group — no URL segment (its `_layout` still applies, keyed by dir)
370
+ if (seg === "index") {
371
+ mark()
372
+ continue
373
+ }
374
+ if (GROUP.test(seg)) {
375
+ // Route group — no URL segment (its `_layout` still applies, keyed by dir).
376
+ mark()
377
+ continue
378
+ }
379
+ // A segment that is part literal, part `[param]` — `[inKey].txt`, `post-[id].html`. Checked
380
+ // before the whole-segment forms below, which only handle a marker spanning the entire segment.
381
+ const mixed = mixedFileSegment(seg, file)
382
+ if (mixed !== undefined) {
383
+ push(mixed)
384
+ mark()
385
+ continue
386
+ }
253
387
  if (seg.startsWith("[")) {
254
388
  const catchAll = CATCH_ALL.exec(seg)
255
389
  if (catchAll !== null) {
@@ -261,13 +395,18 @@ export function filePathToPatterns(file: string): string[] {
261
395
  `[nifra/web] catch-all "${seg}" must be the last segment in "${file}" (found "${after.join("/")}" after it)`,
262
396
  )
263
397
  }
264
- combos = combos.map((c) => [...c, `*${catchAll[1]}`])
398
+ push(`*${catchAll[1]}`)
399
+ mark()
265
400
  continue
266
401
  }
267
402
  const optional = OPTIONAL.exec(seg)
268
403
  if (optional !== null) {
269
404
  // Optional segment: keep each existing combo (absent) AND a copy with `:name` appended (present).
270
- combos = combos.flatMap((c) => [c, [...c, `:${optional[1]}`]])
405
+ combos = combos.flatMap((c) => [
406
+ { segs: c.segs, depths: c.depths },
407
+ { segs: [...c.segs, `:${optional[1]}`], depths: [...c.depths] },
408
+ ])
409
+ mark()
271
410
  continue
272
411
  }
273
412
  const match = PARAM.exec(seg)
@@ -276,12 +415,13 @@ export function filePathToPatterns(file: string): string[] {
276
415
  `[nifra/web] invalid route param in "${file}": "${seg}" must be [name], [[name]], [...name], or a (group) folder`,
277
416
  )
278
417
  }
279
- combos = combos.map((c) => [...c, `:${match[1]}`])
418
+ push(`:${match[1]}`)
280
419
  } else {
281
- combos = combos.map((c) => [...c, seg])
420
+ push(seg)
282
421
  }
422
+ mark()
283
423
  }
284
- return combos.map((c) => `/${c.join("/")}`)
424
+ return combos.map((c) => ({ pattern: `/${c.segs.join("/")}`, depths: c.depths }))
285
425
  }
286
426
 
287
427
  /**
@@ -295,6 +435,26 @@ export function filePathToPattern(file: string): string {
295
435
  }
296
436
 
297
437
  /** The dir chain from root → the file's own dir, e.g. "a/b/p.tsx" → ["", "a", "a/b"]. */
438
+ /**
439
+ * Param names in the first `count` segments of a pattern - the params a layout at that depth owns.
440
+ *
441
+ * This is the whole scoping mechanism: a layout wraps a URL prefix, so the params inside that prefix
442
+ * are the ones it may read, and the ones whose change should re-run its loader. Everything deeper
443
+ * belongs to routes below it and must not invalidate it.
444
+ */
445
+ function paramsInPrefix(pattern: string, count: number): string[] {
446
+ if (count === 0) return []
447
+ const names: string[] = []
448
+ const segments = pattern === "/" ? [] : pattern.slice(1).split("/")
449
+ for (const segment of segments.slice(0, count)) {
450
+ // Mirrors the router's own grammar: `:name`, `*name`, and a mixed segment's embedded `:name`s.
451
+ for (const match of segment.matchAll(/[:*]([A-Za-z_][A-Za-z0-9_]*)/g)) {
452
+ names.push(match[1] as string)
453
+ }
454
+ }
455
+ return names
456
+ }
457
+
298
458
  const ancestorDirs = (file: string): string[] => {
299
459
  const dirs = [""]
300
460
  const dir = dirOf(file)
@@ -322,6 +482,7 @@ export function buildManifest(
322
482
  const errorDirs = new Set<string>()
323
483
  const errors: Record<string, LayoutEntry> = {}
324
484
  let notFound: LayoutEntry | undefined
485
+ const statusPages: Record<string, LayoutEntry> = {}
325
486
  const routeFiles: string[] = []
326
487
 
327
488
  for (const file of files) {
@@ -336,6 +497,11 @@ export function buildManifest(
336
497
  errors[errorIdFor(dir)] = { file, load: importer(file) }
337
498
  } else if (stem === "_404") {
338
499
  notFound = { file, load: importer(file) }
500
+ } else if (STATUS_PAGE.test(stem) && dirOf(file) === "") {
501
+ // `_410.tsx`, `_451.tsx`, … at the routes root. Root-only: unlike `_error`, these are not
502
+ // resolved per segment - a terminal status is a property of the outcome, not of where in the
503
+ // tree the route lives, and a per-segment variant would need a precedence rule nobody asked for.
504
+ statusPages[stem.slice(1)] = { file, load: importer(file) }
339
505
  } else if (!stem.startsWith("_")) {
340
506
  routeFiles.push(file)
341
507
  }
@@ -345,13 +511,14 @@ export function buildManifest(
345
511
  const routes: RouteEntry[] = []
346
512
  for (const file of routeFiles) {
347
513
  const dirs = ancestorDirs(file)
348
- const layoutIds = dirs.filter((dir) => layoutDirs.has(dir)).map(layoutIdFor)
514
+ const layoutDirsForFile = dirs.filter((dir) => layoutDirs.has(dir))
515
+ const layoutIds = layoutDirsForFile.map(layoutIdFor)
349
516
  const errorIds = dirs.filter((dir) => errorDirs.has(dir)).map(errorIdFor)
350
517
  const id = stripExt(file)
351
518
  const load = importer(file) // one lazy loader per file, shared by its (possibly expanded) patterns
352
519
  // An optional `[[x]]` segment expands a file into multiple patterns, all pointing at the same
353
520
  // module (same id/load/layout chain). Distinct patterns ⇒ no match ambiguity (different lengths).
354
- for (const pattern of filePathToPatterns(file)) {
521
+ for (const { pattern, depths } of filePathToRoutes(file)) {
355
522
  const existing = byPattern.get(pattern)
356
523
  if (existing !== undefined) {
357
524
  throw new Error(
@@ -359,11 +526,19 @@ export function buildManifest(
359
526
  )
360
527
  }
361
528
  byPattern.set(pattern, file)
362
- routes.push({ id, pattern, layoutIds, errorIds, file, load })
529
+ const layoutParams = layoutDirsForFile.map((dir) =>
530
+ paramsInPrefix(pattern, depths[dir === "" ? 0 : dir.split("/").length] ?? 0),
531
+ )
532
+ routes.push({ id, pattern, layoutIds, layoutParams, errorIds, file, load })
363
533
  }
364
534
  }
365
535
 
366
- const base: Manifest = { routes, layouts, errors }
536
+ const base: Manifest = {
537
+ routes,
538
+ layouts,
539
+ errors,
540
+ ...(Object.keys(statusPages).length > 0 ? { statusPages } : {}),
541
+ }
367
542
  return notFound === undefined ? base : { ...base, notFound }
368
543
  }
369
544
 
@@ -0,0 +1,146 @@
1
+ /**
2
+ * A bundler-neutral view of a client build, for the guards that must run on every pipeline.
3
+ *
4
+ * nifra's two client-leak guards - server-only code reaching the browser, and `node:` builtins in
5
+ * client code - are the reason a pipeline choice is safe. They are security guards, not lints: one
6
+ * stops secrets and database access shipping to a browser. Today both read Bun's metafile directly,
7
+ * so a second production pipeline would either arrive without them or need them hastily ported, and
8
+ * "mostly ported" is the wrong outcome for a guard of that kind.
9
+ *
10
+ * This is the seam that prevents that. It is introduced while Bun is still the ONLY producer, so the
11
+ * adapter can be verified against the existing behaviour rather than written under pressure beside a
12
+ * new bundler. Adding Rollup later becomes one more `from…` function rather than a second copy of the
13
+ * detection logic.
14
+ *
15
+ * Deliberately minimal - exactly what the guards read, nothing more:
16
+ * - which modules exist and what each imports (to walk the import chain to a sink)
17
+ * - which output chunk each module landed in (to name the chunk a leak reached)
18
+ */
19
+
20
+ /** One import edge, as the bundler recorded it. */
21
+ export interface GraphImport {
22
+ /** The resolved path, when the bundler resolved it. */
23
+ readonly path?: string
24
+ /** The specifier as written in source - the only form that survives an unresolved import. */
25
+ readonly original?: string
26
+ }
27
+
28
+ export interface GraphModule {
29
+ readonly imports: readonly GraphImport[]
30
+ }
31
+
32
+ export interface GraphChunk {
33
+ /** Source module this chunk is the entry for, if it is one. */
34
+ readonly entryPoint?: string
35
+ /** Module ids that landed in this chunk. */
36
+ readonly modules: readonly string[]
37
+ }
38
+
39
+ /** What a client build looks like to the guards, whichever bundler produced it. */
40
+ export interface ClientModuleGraph {
41
+ /** Module id → its imports. Ids are bundler-native (Bun graph keys, Rollup module ids). */
42
+ readonly modules: Readonly<Record<string, GraphModule>>
43
+ /** Output path → what it contains. */
44
+ readonly chunks: Readonly<Record<string, GraphChunk>>
45
+ }
46
+
47
+ /** The slice of Bun's metafile this seam consumes. Not yet in `@types/bun`; shape per the docs. */
48
+ export interface BunMetafileLike {
49
+ readonly inputs?: Readonly<
50
+ Record<string, { readonly imports?: ReadonlyArray<{ path?: string; original?: string }> }>
51
+ >
52
+ readonly outputs?: Readonly<
53
+ Record<
54
+ string,
55
+ { readonly entryPoint?: string; readonly inputs?: Readonly<Record<string, unknown>> }
56
+ >
57
+ >
58
+ }
59
+
60
+ /**
61
+ * Adapt a `Bun.build` metafile to the neutral graph.
62
+ *
63
+ * A total function: an absent or partial metafile yields an empty graph rather than throwing, because
64
+ * a guard that crashes on an unexpected build shape fails the build for the wrong reason. An empty
65
+ * graph reports no findings, which matches the existing behaviour when the metafile is missing.
66
+ */
67
+ export function fromBunMetafile(meta: BunMetafileLike | undefined): ClientModuleGraph {
68
+ const modules: Record<string, GraphModule> = {}
69
+ for (const [id, input] of Object.entries(meta?.inputs ?? {})) {
70
+ modules[id] = { imports: input.imports ?? [] }
71
+ }
72
+ const chunks: Record<string, GraphChunk> = {}
73
+ for (const [path, output] of Object.entries(meta?.outputs ?? {})) {
74
+ chunks[path] = {
75
+ ...(output.entryPoint !== undefined ? { entryPoint: output.entryPoint } : {}),
76
+ modules: Object.keys(output.inputs ?? {}),
77
+ }
78
+ }
79
+ return { modules, chunks }
80
+ }
81
+
82
+ /**
83
+ * The slice of a Rollup/Vite output bundle this seam consumes. `OutputBundle` is
84
+ * `Record<fileName, OutputChunk | OutputAsset>`; only chunks carry a module graph. Typed structurally
85
+ * (no `rollup`/`vite` type dependency) — the fields are stable Rollup output API.
86
+ */
87
+ export interface RollupChunkLike {
88
+ /** `"chunk"` for JS output, `"asset"` for CSS/static — assets have no module graph. */
89
+ readonly type?: string
90
+ /** The entry module id this chunk was built for, if it is an entry. Rollup's `facadeModuleId`. */
91
+ readonly facadeModuleId?: string | null
92
+ /** Every module id that landed in this chunk. Rollup's `moduleIds`. */
93
+ readonly moduleIds?: readonly string[]
94
+ }
95
+ export type RollupBundleLike = Readonly<Record<string, RollupChunkLike>>
96
+
97
+ /**
98
+ * Adapt a Rollup/Vite output bundle to the neutral graph — the second producer the seam was built for.
99
+ *
100
+ * The bundle records which modules landed in which chunk (`moduleIds`) but NOT each module's import
101
+ * edges, so those come from `importsOf`, which the caller backs with `this.getModuleInfo(id).importedIds`
102
+ * inside a plugin hook (a test backs it with a plain map). Edges carry only the RESOLVED id — Rollup does
103
+ * not keep the as-written specifier per edge — so `path` is set and `original` is left undefined. Both
104
+ * guards read the resolved `path` as their fallback (a `node:` prefix, the `server-only` basename), so
105
+ * detection is unaffected; only the human-readable chain shows resolved paths instead of as-written ones.
106
+ *
107
+ * Total, like {@link fromBunMetafile}: an empty bundle yields an empty graph (no findings), never a throw
108
+ * that would fail a build for the wrong reason.
109
+ */
110
+ export function fromRollupBundle(
111
+ bundle: RollupBundleLike,
112
+ importsOf: (moduleId: string) => readonly string[],
113
+ ): ClientModuleGraph {
114
+ const modules: Record<string, GraphModule> = {}
115
+ const chunks: Record<string, GraphChunk> = {}
116
+ for (const [fileName, output] of Object.entries(bundle)) {
117
+ // Assets (CSS, copied files) have no module graph; `type` is absent only on hand-built test inputs,
118
+ // which are always chunks, so treat "not explicitly an asset" as a chunk.
119
+ if (output.type === "asset") continue
120
+ const moduleIds = output.moduleIds ?? []
121
+ // Start from the real modules; `node:` builtins get appended below.
122
+ const chunkModules: string[] = [...moduleIds]
123
+ for (const id of moduleIds) {
124
+ const imports = importsOf(id).map((path) => ({ path }))
125
+ if (modules[id] === undefined) modules[id] = { imports }
126
+ // The guards locate a leak by finding the builtin IN a chunk's module list - Bun BUNDLES the `node:`
127
+ // polyfill, so the builtin appears there as an input. Rollup EXTERNALIZES `node:`, so it never lands
128
+ // in `moduleIds` and the guard would see nothing. Synthesize the Bun shape: register each imported
129
+ // builtin as a member of THIS chunk (where the leak is) and as a leaf module, so the neutral graph
130
+ // is identical whichever bundler produced it. Only `node:` needs this; a server-only-marked module
131
+ // is a real user module and is already a chunk member.
132
+ for (const im of imports) {
133
+ if (!im.path.startsWith("node:")) continue
134
+ if (!chunkModules.includes(im.path)) chunkModules.push(im.path)
135
+ if (modules[im.path] === undefined) modules[im.path] = { imports: [] }
136
+ }
137
+ }
138
+ chunks[fileName] = {
139
+ // `facadeModuleId` is null for a shared/common chunk (no single entry) — omit it then, matching
140
+ // how a non-entry Bun output omits `entryPoint`, so the guards' entry set stays the real entries.
141
+ ...(output.facadeModuleId ? { entryPoint: output.facadeModuleId } : {}),
142
+ modules: chunkModules,
143
+ }
144
+ }
145
+ return { modules, chunks }
146
+ }
Binary file
@@ -0,0 +1,110 @@
1
+ /**
2
+ * `@nifrajs/web/plugins/vite-leak-guard` — nifra's two client-leak guards, for a Vite/Rollup production
3
+ * build.
4
+ *
5
+ * ## Why this exists
6
+ *
7
+ * nifra's production default is Bun, and that is not changing: a Vite/Rollup production build inherits
8
+ * Rollup's build profile and gives up the Bun-native advantage nifra competes on. But some apps depend on
9
+ * a Vite-only transform that has no Bun equivalent, and for those a Vite production build is the escape
10
+ * hatch. The moment that hatch exists, the two client-leak guards have to come with it - they are security
11
+ * guards, not lints: one stops secrets and database access from shipping to a browser. A second production
12
+ * pipeline arriving WITHOUT them, or with a hastily re-implemented "mostly ported" copy, is the failure the
13
+ * neutral module-graph seam was built to prevent.
14
+ *
15
+ * So this is not a second implementation. The detection logic (`detectNodeBuiltinsInClient`,
16
+ * `detectServerOnlyInClient`) and the failure MESSAGES (`formatNodeBuiltinLeak`, `formatServerOnlyLeak`)
17
+ * are the exact same functions the Bun build calls. This plugin only adapts Rollup's bundle shape into the
18
+ * neutral `ClientModuleGraph` (`fromRollupBundle`) and runs them. A leak reads identically whichever
19
+ * bundler produced it, and there is one place to change if a guard changes.
20
+ *
21
+ * ## How
22
+ *
23
+ * In `generateBundle` the whole client graph is available: each output chunk lists its `moduleIds`, and
24
+ * `this.getModuleInfo(id)` gives each module's resolved import edges. That is exactly what
25
+ * `fromRollupBundle` needs. Findings fail the build through Rollup's `this.error`, so the message lands in
26
+ * the build output the same way a Bun-build throw does.
27
+ *
28
+ * Add it to the `plugins` of a Vite production build (the LAST plugin, so it sees the final graph):
29
+ *
30
+ * // vite.config.ts (production client build)
31
+ * import { viteLeakGuard } from "@nifrajs/web/plugins/vite-leak-guard"
32
+ * export default { build: { rollupOptions: { plugins: [viteLeakGuard()] } } }
33
+ */
34
+ import {
35
+ detectNodeBuiltinsInClient,
36
+ detectServerOnlyInClient,
37
+ formatNodeBuiltinLeak,
38
+ formatServerOnlyLeak,
39
+ } from "../build.ts"
40
+ import { fromRollupBundle, type RollupBundleLike } from "../module-graph.ts"
41
+
42
+ /**
43
+ * The Rollup plugin-context slice this uses: `getModuleInfo` for a module's resolved imports, and `error`
44
+ * to fail the build. Typed structurally so the file needs no `rollup`/`vite` type dependency.
45
+ */
46
+ interface RollupPluginContext {
47
+ getModuleInfo(id: string): {
48
+ readonly importedIds?: readonly string[]
49
+ readonly dynamicallyImportedIds?: readonly string[]
50
+ } | null
51
+ /**
52
+ * Takes an `Error`, never a string.
53
+ *
54
+ * Handed a string, rolldown synthesizes its own error type and calls `Error.captureStackTrace` on a
55
+ * plain object - which Bun rejects with "First argument must be an Error object", and THAT becomes the
56
+ * build failure. The guard still fires, but its message is replaced by an internal one naming nothing,
57
+ * so a real client leak reports as a stack-trace complaint. Passing an Error skips that construction
58
+ * entirely and the message survives.
59
+ */
60
+ error(error: Error): never
61
+ }
62
+
63
+ /** The minimal Rollup plugin shape this returns — `generateBundle` bound to the plugin context. */
64
+ export interface LeakGuardPlugin {
65
+ readonly name: string
66
+ /**
67
+ * The finding that failed the build, or `undefined` when the bundle was clean.
68
+ *
69
+ * Recorded as well as thrown because the throw does not reliably survive. A plugin error is handed to
70
+ * the bundler, which builds its own error to report it, and on some Bun + rolldown combinations that
71
+ * construction itself fails (`Error.captureStackTrace` rejecting a non-Error) - so what surfaces is an
72
+ * internal complaint naming neither the leaked module nor its import chain. A caller that owns the
73
+ * build can read this afterwards and raise the real message from its own code, where nothing can
74
+ * rewrite it. A leak is the one failure that must stay legible: the whole point is naming what
75
+ * reached the browser.
76
+ */
77
+ leak?: string
78
+ generateBundle(this: RollupPluginContext, options: unknown, bundle: RollupBundleLike): void
79
+ }
80
+
81
+ /**
82
+ * A Vite/Rollup plugin that fails the build when server-only code or a `node:` builtin reaches the client
83
+ * bundle - the same two guards, and the same error messages, as nifra's Bun production build.
84
+ */
85
+ export function viteLeakGuard(): LeakGuardPlugin {
86
+ const plugin: LeakGuardPlugin = {
87
+ name: "nifra:leak-guard",
88
+ generateBundle(_options, bundle) {
89
+ // Resolved import edges per module, straight from Rollup's graph. Dynamic imports count too: a
90
+ // `node:`/server-only module reached only via `import()` still ships to the browser.
91
+ const importsOf = (id: string): readonly string[] => {
92
+ const info = this.getModuleInfo(id)
93
+ if (info === null) return []
94
+ return [...(info.importedIds ?? []), ...(info.dynamicallyImportedIds ?? [])]
95
+ }
96
+ const graph = fromRollupBundle(bundle, importsOf)
97
+ // Node-builtin guard first, then server-only - the same order as the Bun build, so the first error a
98
+ // dev sees is the same across pipelines when a module trips both.
99
+ const leak =
100
+ formatNodeBuiltinLeak(detectNodeBuiltinsInClient(graph)) ??
101
+ formatServerOnlyLeak(detectServerOnlyInClient(graph))
102
+ if (leak === undefined) return
103
+ // Record before throwing: the throw is what fails the build for a standalone user of this plugin,
104
+ // and the record is what lets a caller that owns the build re-raise the real message (see `leak`).
105
+ plugin.leak = leak
106
+ this.error(new Error(leak))
107
+ },
108
+ }
109
+ return plugin
110
+ }