@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
package/src/router.ts CHANGED
@@ -39,6 +39,72 @@ export const REDIRECT_HEADER = "x-nifra-redirect"
39
39
  */
40
40
  export const REVALIDATE_HEADER = "x-nifra-revalidate"
41
41
 
42
+ /**
43
+ * Response header carrying a **terminal status** a loader signalled with `notFound()` / `gone()` /
44
+ * `statusPage(n)` during a client-side navigation's data fetch.
45
+ *
46
+ * A soft-nav fetches data, not a document, so the server cannot answer by rendering the `_404` page -
47
+ * it has only JSON to return. Without this channel the two halves disagree: a crawler doing a hard
48
+ * navigation gets a correct 404 page while a user who clicked a link gets an error or a blank screen.
49
+ * The client reads it and renders the same boundary the server would have.
50
+ *
51
+ * Carried in a header rather than the body so it survives the NDJSON deferred-data path unchanged,
52
+ * exactly like {@link REDIRECT_HEADER}.
53
+ */
54
+ export const STATUS_HEADER = "x-nifra-status"
55
+
56
+ /**
57
+ * Layout indices a client navigation is asking the server NOT to re-run, comma separated.
58
+ *
59
+ * The client owns the decision because it holds both the old and new match plus each layout's scoped
60
+ * params. The server treats it as a hint and refuses to skip a `gate` regardless - a guard that runs
61
+ * only when the client says so is not a guard.
62
+ */
63
+ export const RETAIN_HEADER = "x-nifra-retain"
64
+
65
+ /**
66
+ * The path a client navigation is coming FROM, sent on the data-mode GET.
67
+ *
68
+ * Lets the SERVER decide which layout loaders to skip: it already knows each layout's scoped params
69
+ * (derived at build time), so given both paths it can tell whether a layout's own prefix changed.
70
+ * Doing it server-side means the client never needs the scope table, and the decision lives next to
71
+ * the data it is about.
72
+ *
73
+ * Sent only when the client actually holds layout data to retain, so a request without it is simply
74
+ * the full-chain case.
75
+ */
76
+ export const NAV_FROM_HEADER = "x-nifra-from"
77
+
78
+ /**
79
+ * The data-mode payload once a chain can carry layout data.
80
+ *
81
+ * Versioned because a prerendered `_data.json` is a static file on a CDN and outlives the deploy that
82
+ * wrote it: a browser running new client code can be handed an envelope written by the previous
83
+ * build. The reader therefore accepts BOTH the bare pre-envelope value and this shape, and decides by
84
+ * structure rather than by assuming the deploy was atomic.
85
+ */
86
+ export interface RouteDataEnvelope {
87
+ readonly v: 1
88
+ readonly data: unknown
89
+ readonly layoutData?: readonly unknown[]
90
+ /** Indices whose loader was SKIPPED because the layout's own params did not change. The client
91
+ * keeps its existing value at each of these, so an unchanged layout is neither refetched nor lost. */
92
+ readonly retained?: readonly number[]
93
+ /** Terminal status signalled by a loader. Added by the browser transport after reading
94
+ * `X-Nifra-Status`; old servers and static data files simply omit it. */
95
+ readonly status?: number
96
+ }
97
+
98
+ /** Recognise the envelope without mistaking a plain loader object that happens to have a `data` key. */
99
+ export function isRouteDataEnvelope(value: unknown): value is RouteDataEnvelope {
100
+ return (
101
+ typeof value === "object" &&
102
+ value !== null &&
103
+ (value as { v?: unknown }).v === 1 &&
104
+ "data" in value
105
+ )
106
+ }
107
+
42
108
  /** A URL matched against the manifest patterns: which route + its extracted params. */
43
109
  export interface RouteMatch {
44
110
  readonly routeId: string
@@ -60,6 +126,9 @@ export interface RouterState {
60
126
  /** The current URL path (used to revalidate the active loader after an action). */
61
127
  readonly path: string
62
128
  readonly data: unknown
129
+ /** Per-layout loader data, aligned with the matched chain's leading layout prefix. Absent when no
130
+ * layout in that chain has a loader. */
131
+ readonly layoutData?: readonly unknown[] | undefined
63
132
  /** An action's data return after a client-side submit (cleared on navigation). */
64
133
  readonly actionData?: unknown
65
134
  /** True while a navigation or submit is in flight (drives loading UI). */
@@ -110,6 +179,10 @@ export type FetchRouteData = (
110
179
  path: string,
111
180
  match: RouteMatch,
112
181
  signal?: AbortSignal,
182
+ navigation?: {
183
+ readonly from: string
184
+ readonly retain: readonly number[]
185
+ },
113
186
  ) => Promise<unknown>
114
187
 
115
188
  /** Per-submit options. `revalidate: false` opts out of the post-action loader re-fetch. */
@@ -209,6 +282,9 @@ export interface ClientRouterOptions {
209
282
  /** Ensure a route's code chunk is loaded before rendering (code-splitting). Awaited in parallel
210
283
  * with the loader data, so `pending` covers both. Omit when the bundle isn't split. */
211
284
  readonly loadModule?: (routeId: string) => Promise<void>
285
+ /** Terminal status → client route id. Generated entries populate this from `_404` and
286
+ * `_<status>` files so soft navigation renders the same boundary as a hard request. */
287
+ readonly statusRoutes?: Readonly<Record<number, string>>
212
288
  }
213
289
 
214
290
  /** Options for a per-adapter `mountRouter` (the Router binding that hydrates + re-renders). */
@@ -233,7 +309,7 @@ const readResponseData = (res: Response, signal?: AbortSignal): Promise<unknown>
233
309
  const dataUrlFor = (pathname: string): string =>
234
310
  pathname === "/" ? "/_data.json" : `${pathname.replace(/\/+$/, "")}/_data.json`
235
311
 
236
- const defaultFetchData: FetchRouteData = async (path, _match, signal) => {
312
+ const defaultFetchData: FetchRouteData = async (path, _match, signal, navigation) => {
237
313
  // SSG fast path: if this path was prerendered, its loader data is a static file — fetch that (no
238
314
  // worker). Falls through to the dynamic header-GET on any miss (file absent, e.g. a deferred route,
239
315
  // or a stale set), so it's always safe. Non-SSG apps have no global → the dynamic path, unchanged.
@@ -245,7 +321,16 @@ const defaultFetchData: FetchRouteData = async (path, _match, signal) => {
245
321
  if (staticRes.ok) return readResponseData(staticRes, signal)
246
322
  }
247
323
  }
248
- const res = await fetch(path, { headers: { [DATA_HEADER]: "1" }, signal: signal ?? null })
324
+ const headers = new Headers({ [DATA_HEADER]: "1" })
325
+ if (navigation !== undefined && navigation.retain.length > 0) {
326
+ headers.set(RETAIN_HEADER, navigation.retain.join(","))
327
+ headers.set(NAV_FROM_HEADER, navigation.from)
328
+ }
329
+ const res = await fetch(path, { headers, signal: signal ?? null })
330
+ const status = Number(res.headers.get(STATUS_HEADER))
331
+ if (Number.isInteger(status) && status >= 400 && status <= 599) {
332
+ return { v: 1, data: null, status } satisfies RouteDataEnvelope
333
+ }
249
334
  if (!res.ok) throw new Error(`[nifra/web] navigation data fetch failed (${res.status}): ${path}`)
250
335
  return readResponseData(res, signal)
251
336
  }
@@ -258,6 +343,55 @@ const defaultFetchData: FetchRouteData = async (path, _match, signal) => {
258
343
  export function createClientRouter(options: ClientRouterOptions): ClientRouter {
259
344
  const match = createMatcher(options.patterns)
260
345
  const fetchData = options.fetchData ?? defaultFetchData
346
+ /**
347
+ * Fetch a route's data and normalise it.
348
+ *
349
+ * The payload is either the versioned envelope (a chain with layout loaders) or the bare loader
350
+ * value (everything else, and every response written before layout loaders existed). Deciding by
351
+ * structure rather than by version-of-the-deploy is what lets a prerendered `_data.json` written by
352
+ * an older build be read by newer client code.
353
+ */
354
+ type LoadedRouteData = {
355
+ readonly data: unknown
356
+ readonly layoutData?: readonly unknown[] | undefined
357
+ readonly terminalRouteId?: string | undefined
358
+ }
359
+ const loadRouteData = async (
360
+ path: string,
361
+ match: { routeId: string; params: Record<string, string> },
362
+ signal?: AbortSignal,
363
+ retainFrom?: { readonly path: string; readonly layoutData: readonly unknown[] },
364
+ ): Promise<LoadedRouteData> => {
365
+ const retain = retainFrom?.layoutData.map((_, index) => index) ?? []
366
+ const payload = await fetchData(
367
+ path,
368
+ match,
369
+ signal,
370
+ retainFrom === undefined ? undefined : { from: retainFrom.path, retain },
371
+ )
372
+ if (!isRouteDataEnvelope(payload)) return { data: payload }
373
+ if (payload.status !== undefined) {
374
+ const terminalRouteId = options.statusRoutes?.[payload.status] ?? options.statusRoutes?.[404]
375
+ if (terminalRouteId === undefined) {
376
+ throw new Error(
377
+ `[nifra/web] no client status boundary is available for terminal status ${payload.status}`,
378
+ )
379
+ }
380
+ return { data: null, terminalRouteId }
381
+ }
382
+ if (retainFrom === undefined || payload.retained === undefined) {
383
+ return { data: payload.data, layoutData: payload.layoutData }
384
+ }
385
+ const retained = new Set(
386
+ payload.retained.filter(
387
+ (index) => Number.isInteger(index) && index >= 0 && index < retainFrom.layoutData.length,
388
+ ),
389
+ )
390
+ const layoutData = payload.layoutData?.map((value, index) =>
391
+ retained.has(index) ? retainFrom.layoutData[index] : value,
392
+ )
393
+ return { data: payload.data, layoutData }
394
+ }
261
395
  const loadModule = options.loadModule
262
396
  let state = options.initial
263
397
  const listeners = new Set<() => void>()
@@ -272,7 +406,7 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
272
406
  // Bounded one-shot prefetch cache (path → loader data) + an in-flight guard so hover spam
273
407
  // doesn't double-fetch. Consumed (and dropped) by the next navigate to that path.
274
408
  const MAX_PREFETCH = 10
275
- const prefetched = new Map<string, unknown>()
409
+ const prefetched = new Map<string, LoadedRouteData>()
276
410
  const inflight = new Set<string>()
277
411
  // Keyed data cache (path → latest loader data + freshness). Written on every published data
278
412
  // (navigate/submit), read by `invalidate` (+ targeted revalidation and fetchers in later F16
@@ -280,13 +414,18 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
280
414
  // caps memory). `status` is the staleness ledger: `invalidate` flips entries to `stale`; readers
281
415
  // refetch stale ones. Client-only — never serialized/hydrated.
282
416
  const MAX_CACHE = 50
283
- const cache = new Map<string, { data: unknown; status: "fresh" | "stale" }>()
284
- const cachePut = (path: string, data: unknown): void => {
417
+ const cache = new Map<
418
+ string,
419
+ { data: unknown; layoutData?: readonly unknown[] | undefined; status: "fresh" | "stale" }
420
+ >()
421
+ const cachePut = (path: string, loaded: LoadedRouteData): void => {
285
422
  if (!cache.has(path) && cache.size >= MAX_CACHE) {
286
423
  const oldest = cache.keys().next().value
287
424
  if (oldest !== undefined) cache.delete(oldest)
288
425
  }
289
- cache.set(path, { data, status: "fresh" })
426
+ // The PAIR, not just the value: navigating back to a cached route must restore its layouts' data
427
+ // too, or they would render empty on a hit and populated on a miss.
428
+ cache.set(path, { data: loaded.data, layoutData: loaded.layoutData, status: "fresh" })
290
429
  }
291
430
  // Flip cached entries to stale (a no-op for paths not in the cache). Shared by `invalidate` and the
292
431
  // `X-Nifra-Revalidate` handling in `submit`.
@@ -320,14 +459,14 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
320
459
  state = { ...state, pending: true }
321
460
  emit()
322
461
  try {
323
- const data = await fetchData(
462
+ const loaded = await loadRouteData(
324
463
  state.path,
325
464
  { routeId: state.routeId, params: state.params },
326
465
  ac.signal,
327
466
  )
328
467
  if (mine !== token) return // superseded — drop the stale result
329
- cachePut(state.path, data)
330
- state = { ...state, data, pending: false }
468
+ cachePut(state.path, loaded)
469
+ state = { ...state, data: loaded.data, layoutData: loaded.layoutData, pending: false }
331
470
  emit()
332
471
  } catch (err) {
333
472
  if (mine === token) {
@@ -387,17 +526,17 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
387
526
  fState = { ...fState, pending: true }
388
527
  fEmit()
389
528
  try {
390
- const [, data] = await Promise.all([
529
+ const [, loaded] = await Promise.all([
391
530
  loadModule?.(matched.routeId),
392
- fetchData(path, matched, ac.signal),
531
+ loadRouteData(path, matched, ac.signal),
393
532
  ])
394
533
  if (mine !== fToken) return // a newer load/submit on THIS fetcher superseded us
395
534
  // Record the loaded path only on SUCCESS — a thrown or superseded load must not
396
535
  // leave `loadedPath` pointing at a path this fetcher never actually showed, or a later
397
536
  // `X-Nifra-Revalidate` for it would spuriously refetch onto unexpected data.
398
537
  loadedPath = path
399
- cachePut(path, data)
400
- fState = { ...fState, data, pending: false }
538
+ cachePut(path, loaded)
539
+ fState = { ...fState, data: loaded.data, pending: false }
401
540
  fEmit()
402
541
  } catch (err) {
403
542
  if (mine === fToken) {
@@ -490,17 +629,26 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
490
629
  // either way (cached + instant after a prefetch), so pending covers both.
491
630
  const hit = prefetched.has(path)
492
631
  const dataPromise = hit
493
- ? Promise.resolve(prefetched.get(path))
494
- : fetchData(path, matched, ac.signal)
632
+ ? Promise.resolve(prefetched.get(path) as LoadedRouteData)
633
+ : loadRouteData(
634
+ path,
635
+ matched,
636
+ ac.signal,
637
+ state.layoutData === undefined
638
+ ? undefined
639
+ : { path: state.path, layoutData: state.layoutData },
640
+ )
495
641
  if (hit) prefetched.delete(path)
496
- const [, data] = await Promise.all([loadModule?.(matched.routeId), dataPromise])
642
+ const [, loaded] = await Promise.all([loadModule?.(matched.routeId), dataPromise])
643
+ if (loaded.terminalRouteId !== undefined) await loadModule?.(loaded.terminalRouteId)
497
644
  if (mine !== token) return // a newer navigation superseded this one — drop the stale result
498
- cachePut(path, data) // keep the keyed cache coherent with what we publish
645
+ cachePut(path, loaded) // keep the keyed cache coherent with what we publish
499
646
  state = {
500
- routeId: matched.routeId,
501
- params: matched.params,
647
+ routeId: loaded.terminalRouteId ?? matched.routeId,
648
+ params: loaded.terminalRouteId === undefined ? matched.params : {},
502
649
  path,
503
- data,
650
+ data: loaded.data,
651
+ layoutData: loaded.layoutData,
504
652
  actionData: undefined, // a fresh navigation has no action result
505
653
  pending: false,
506
654
  }
@@ -540,17 +688,26 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
540
688
  const target = match(redirectTo)
541
689
  if (target === null)
542
690
  throw new Error(`[nifra/web] action redirect off-route: ${redirectTo}`)
543
- const [, data] = await Promise.all([
691
+ const [, loaded] = await Promise.all([
544
692
  loadModule?.(target.routeId),
545
- fetchData(redirectTo, target, ac.signal),
693
+ loadRouteData(
694
+ redirectTo,
695
+ target,
696
+ ac.signal,
697
+ state.layoutData === undefined
698
+ ? undefined
699
+ : { path: state.path, layoutData: state.layoutData },
700
+ ),
546
701
  ])
702
+ if (loaded.terminalRouteId !== undefined) await loadModule?.(loaded.terminalRouteId)
547
703
  if (mine !== token) return
548
- cachePut(redirectTo, data)
704
+ cachePut(redirectTo, loaded)
549
705
  state = {
550
- routeId: target.routeId,
551
- params: target.params,
706
+ routeId: loaded.terminalRouteId ?? target.routeId,
707
+ params: loaded.terminalRouteId === undefined ? target.params : {},
552
708
  path: redirectTo,
553
- data,
709
+ data: loaded.data,
710
+ layoutData: loaded.layoutData,
554
711
  actionData: undefined,
555
712
  pending: false,
556
713
  }
@@ -568,11 +725,15 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
568
725
  // out (`revalidate: false`). A server-declared revalidate of the active path overrides the
569
726
  // opt-out (the server says it changed, so stale data would be wrong). Default is to revalidate.
570
727
  const skipActive = opts?.revalidate === false && !changed.includes(state.path)
571
- const data = skipActive
572
- ? state.data
573
- : await fetchData(state.path, { routeId: state.routeId, params: state.params }, ac.signal)
728
+ const loaded = skipActive
729
+ ? { data: state.data, layoutData: state.layoutData }
730
+ : await loadRouteData(
731
+ state.path,
732
+ { routeId: state.routeId, params: state.params },
733
+ ac.signal,
734
+ )
574
735
  if (mine !== token) return
575
- cachePut(state.path, data) // the revalidated (or kept) data is now the cache's truth
736
+ cachePut(state.path, loaded) // the revalidated (or kept) data is now the cache's truth
576
737
  // Mark the OTHER changed routes stale so the next access refetches.
577
738
  markStale(changed.filter((p) => p !== state.path))
578
739
  // Reconcile: publish the revalidated data + actionData; omit `submission` (the optimistic
@@ -581,7 +742,8 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
581
742
  routeId: state.routeId,
582
743
  params: state.params,
583
744
  path: state.path,
584
- data,
745
+ data: loaded.data,
746
+ layoutData: loaded.layoutData,
585
747
  actionData,
586
748
  pending: false,
587
749
  }
@@ -625,15 +787,17 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
625
787
  if (matched === null) return
626
788
  inflight.add(path)
627
789
  try {
628
- const [, data] = await Promise.all([
790
+ // Unwrapped here, so a prefetched entry is the same shape `navigate` builds state from —
791
+ // otherwise a prefetch hit would publish an envelope where a miss publishes loader data.
792
+ const [, loaded] = await Promise.all([
629
793
  loadModule?.(matched.routeId),
630
- fetchData(path, matched),
794
+ loadRouteData(path, matched),
631
795
  ])
632
796
  if (prefetched.size >= MAX_PREFETCH) {
633
797
  const oldest = prefetched.keys().next().value
634
798
  if (oldest !== undefined) prefetched.delete(oldest)
635
799
  }
636
- prefetched.set(path, data)
800
+ prefetched.set(path, loaded)
637
801
  } catch {
638
802
  // Best-effort: a failed prefetch just means the eventual navigate fetches normally.
639
803
  } finally {
package/src/vite.ts CHANGED
@@ -18,6 +18,7 @@ import {
18
18
  } from "node:http"
19
19
  import { relative, resolve as resolvePath } from "node:path"
20
20
  import { renderDevErrorOverlay } from "./dev-error.ts"
21
+ import { listenOrExplain } from "./dev-port.ts"
21
22
  import { discoverRoutes } from "./fs.ts"
22
23
  import { DEFAULT_DEV_PORT, generateClientEntry } from "./index.ts"
23
24
 
@@ -28,10 +29,14 @@ interface FetchApp {
28
29
 
29
30
  // Structural slice of the Vite dev server this module drives (avoids a hard type dep on `vite`).
30
31
  interface ViteLike {
32
+ /** Load a module through VITE's graph - the seam that makes the Vite pipeline own SSR too. */
33
+ ssrLoadModule(url: string): Promise<Record<string, unknown>>
31
34
  readonly middlewares: (req: IncomingMessage, res: ServerResponse, next: () => void) => void
32
35
  transformIndexHtml(url: string, html: string): Promise<string>
33
36
  ssrFixStacktrace(err: Error): void
34
- readonly watcher: { on(event: "change", cb: (path: string) => void): void }
37
+ readonly watcher: {
38
+ on(event: "change" | "add" | "unlink", cb: (path: string) => void): void
39
+ }
35
40
  close(): Promise<void>
36
41
  }
37
42
  interface ViteModule {
@@ -49,11 +54,18 @@ export interface ViteDevServerOptions {
49
54
  /** Client runtime module providing `mountRouter` (e.g. `"@nifrajs/web-react/client"`). */
50
55
  readonly clientModule: string
51
56
  /**
52
- * Build the nifra app for the given dev client-entry URL. `importQuery` changes when a route file
53
- * changes — thread it into `discoverRoutes(routesDir, { importQuery })` so a hard reload SSRs edited
54
- * modules (HMR handles the live client update; this keeps server-render fresh).
57
+ * Build the nifra app for the given dev client-entry URL.
58
+ *
59
+ * `load` resolves a route module through **Vite's** graph (`ssrLoadModule`). Pass it to
60
+ * `discoverRoutes(routesDir, { load })` so SSR and the client are resolved by the SAME toolchain.
61
+ * Without it SSR resolves through Bun while the client resolves through Vite - two resolvers, one
62
+ * process - which is what makes `resolve.dedupe` fail to reach SSR and produces the dual-React
63
+ * crash. Vite re-evaluates on change, so no `importQuery` cache-buster is needed alongside it.
55
64
  */
56
- readonly createApp: (clientEntry: string, importQuery: string) => FetchApp | Promise<FetchApp>
65
+ readonly createApp: (
66
+ clientEntry: string,
67
+ load: (absolutePath: string) => Promise<unknown>,
68
+ ) => FetchApp | Promise<FetchApp>
57
69
  /** Vite plugins — inject your framework's official plugin, e.g. `[react()]`. */
58
70
  readonly plugins?: readonly unknown[]
59
71
  /**
@@ -76,6 +88,8 @@ export interface ViteDevServerOptions {
76
88
  * `true` (or the env var `CHOKIDAR_USEPOLLING=1`) to poll instead. Default: off (native events).
77
89
  */
78
90
  readonly poll?: boolean
91
+ /** Vite public directory. Defaults to `<root>/public`; `false` disables it. */
92
+ readonly publicDir?: string | false
79
93
  }
80
94
 
81
95
  export interface ViteDevServer {
@@ -259,12 +273,15 @@ export async function createViteDevServer(options: ViteDevServerOptions): Promis
259
273
  const port = options.port ?? DEFAULT_DEV_PORT
260
274
 
261
275
  // Codegen the client entry with Vite-servable, root-relative specifiers (e.g. `/routes/index.tsx`).
262
- const manifest = discoverRoutes(routesDir)
263
276
  const toUrl = (file: string): string => `/${relative(root, `${routesDir}/${file}`)}`
264
- writeFileSync(
265
- resolvePath(root, DEV_ENTRY),
266
- generateClientEntry(manifest, { clientModule: options.clientModule, resolve: toUrl }),
267
- )
277
+ const writeClientEntry = (): void => {
278
+ const manifest = discoverRoutes(routesDir)
279
+ writeFileSync(
280
+ resolvePath(root, DEV_ENTRY),
281
+ generateClientEntry(manifest, { clientModule: options.clientModule, resolve: toUrl }),
282
+ )
283
+ }
284
+ writeClientEntry()
268
285
  const entryUrl = `/${DEV_ENTRY}`
269
286
 
270
287
  // Create our HTTP server FIRST, then hand it to Vite as the HMR WebSocket host (`hmr.server`). In
@@ -273,7 +290,6 @@ export async function createViteDevServer(options: ViteDevServerOptions): Promis
273
290
  // port means HMR rides the same origin as the app, robust across restarts. The handler closes over
274
291
  // `vite`, which is assigned just below before the server starts listening.
275
292
  let vite!: ViteLike
276
- let version = 0
277
293
  let app: FetchApp
278
294
 
279
295
  const server: NodeHttpServer = createHttpServer((req, res) => {
@@ -321,6 +337,7 @@ export async function createViteDevServer(options: ViteDevServerOptions): Promis
321
337
  )
322
338
  vite = await createServer({
323
339
  root,
340
+ ...(options.publicDir !== undefined ? { publicDir: options.publicDir } : {}),
324
341
  appType: "custom",
325
342
  server: {
326
343
  middlewareMode: true,
@@ -341,21 +358,46 @@ export async function createViteDevServer(options: ViteDevServerOptions): Promis
341
358
  ...(options.define ? { define: options.define } : {}),
342
359
  })
343
360
 
344
- app = await options.createApp(entryUrl, `v=${version}`)
361
+ const ssrLoad = (absolutePath: string): Promise<unknown> => vite.ssrLoadModule(absolutePath)
362
+ app = await options.createApp(entryUrl, ssrLoad)
345
363
 
346
- // Keep server-render fresh on edits (Vite already HMRs the client live; this is for a hard reload).
347
- vite.watcher.on("change", () => {
348
- version += 1
349
- Promise.resolve(options.createApp(entryUrl, `v=${version}`))
364
+ // Re-create the app on change so a hard reload picks up a route ADD/REMOVE (the manifest comes
365
+ // from a directory scan, which `ssrLoadModule` cannot invalidate). Module CONTENT no longer needs a
366
+ // version counter: Vite owns SSR resolution now and re-evaluates changed modules itself, which is
367
+ // the cache-busting the old `importQuery` was emulating against Bun's import cache.
368
+ let refreshVersion = 0
369
+ const refreshApp = (): void => {
370
+ const version = ++refreshVersion
371
+ Promise.resolve(options.createApp(entryUrl, ssrLoad))
350
372
  .then((next) => {
351
- app = next
373
+ if (version === refreshVersion) app = next
352
374
  })
353
375
  .catch((err) => console.error("[nifra/web/vite] app re-create failed:", err))
354
- })
376
+ }
377
+ vite.watcher.on("change", refreshApp)
378
+ for (const event of ["add", "unlink"] as const) {
379
+ vite.watcher.on(event, (path) => {
380
+ if (path.startsWith(`${routesDir}/`) || path.startsWith(`${routesDir}\\`)) writeClientEntry()
381
+ refreshApp()
382
+ })
383
+ }
355
384
 
356
- await new Promise<void>((done) => server.listen(port, done))
385
+ try {
386
+ await listenOrExplain(server, port)
387
+ } catch (err) {
388
+ // Nothing is listening, but Vite is fully up by now - watchers, the dep optimizer, its own sockets -
389
+ // and every one of those keeps the event loop alive. Left open, the process prints the diagnosis and
390
+ // then HANGS on it, which is worse than the raw crash this guard replaced: the user sees a dev server
391
+ // that appears to be starting. Tear Vite down so the failure is terminal.
392
+ await vite.close().catch(() => {})
393
+ throw err
394
+ }
395
+ // Report the port actually BOUND, not the one requested. They differ for `port: 0`, which is how you
396
+ // ask the OS for a free one - the correct thing to do in a test or when running several apps at once.
397
+ // Echoing the request back would return a literal 0, which connects to nothing.
398
+ const address = server.address()
357
399
  return {
358
- port,
400
+ port: typeof address === "object" && address !== null ? address.port : port,
359
401
  stop: async () => {
360
402
  server.close()
361
403
  await vite.close()