@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.
- package/dist/build-vite.d.ts +68 -0
- package/dist/build-vite.d.ts.map +1 -0
- package/dist/build-vite.js +487 -0
- package/dist/build-vite.js.map +1 -0
- package/dist/build.d.ts +112 -25
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +283 -64
- package/dist/build.js.map +1 -1
- package/dist/bun-dev-entry.d.ts +83 -0
- package/dist/bun-dev-entry.d.ts.map +1 -0
- package/dist/bun-dev-entry.js +136 -0
- package/dist/bun-dev-entry.js.map +1 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +10 -0
- package/dist/client.js.map +1 -1
- package/dist/deferred.d.ts +3 -2
- package/dist/deferred.d.ts.map +1 -1
- package/dist/deferred.js +11 -8
- package/dist/deferred.js.map +1 -1
- package/dist/dev-port.d.ts +36 -0
- package/dist/dev-port.d.ts.map +1 -0
- package/dist/dev-port.js +48 -0
- package/dist/dev-port.js.map +1 -0
- package/dist/dev.d.ts +61 -4
- package/dist/dev.d.ts.map +1 -1
- package/dist/dev.js +419 -60
- package/dist/dev.js.map +1 -1
- package/dist/draft.d.ts +37 -0
- package/dist/draft.d.ts.map +1 -1
- package/dist/draft.js +87 -4
- package/dist/draft.js.map +1 -1
- package/dist/fs.d.ts +14 -1
- package/dist/fs.d.ts.map +1 -1
- package/dist/fs.js +7 -1
- package/dist/fs.js.map +1 -1
- package/dist/index.d.ts +116 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +565 -53
- package/dist/index.js.map +1 -1
- package/dist/internal/timing-safe-equal.d.ts +16 -0
- package/dist/internal/timing-safe-equal.d.ts.map +1 -0
- package/dist/internal/timing-safe-equal.js +26 -0
- package/dist/internal/timing-safe-equal.js.map +1 -0
- package/dist/isr.d.ts.map +1 -1
- package/dist/isr.js +1 -14
- package/dist/isr.js.map +1 -1
- package/dist/llms-txt.d.ts.map +1 -1
- package/dist/llms-txt.js +12 -6
- package/dist/llms-txt.js.map +1 -1
- package/dist/manifest.d.ts +51 -8
- package/dist/manifest.d.ts.map +1 -1
- package/dist/manifest.js +130 -14
- package/dist/manifest.js.map +1 -1
- package/dist/module-graph.d.ts +91 -0
- package/dist/module-graph.d.ts.map +1 -0
- package/dist/module-graph.js +92 -0
- package/dist/module-graph.js.map +1 -0
- package/dist/plugins/css-modules.d.ts.map +1 -1
- package/dist/plugins/css-modules.js +0 -0
- package/dist/plugins/css-modules.js.map +1 -1
- package/dist/plugins/vite-leak-guard.d.ts +45 -0
- package/dist/plugins/vite-leak-guard.d.ts.map +1 -0
- package/dist/plugins/vite-leak-guard.js +67 -0
- package/dist/plugins/vite-leak-guard.js.map +1 -0
- package/dist/public-dir.d.ts +36 -0
- package/dist/public-dir.d.ts.map +1 -0
- package/dist/public-dir.js +89 -0
- package/dist/public-dir.js.map +1 -0
- package/dist/route-manifest.d.ts +91 -0
- package/dist/route-manifest.d.ts.map +1 -0
- package/dist/route-manifest.js +128 -0
- package/dist/route-manifest.js.map +1 -0
- package/dist/router.d.ts +64 -1
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +114 -31
- package/dist/router.js.map +1 -1
- package/dist/vite.d.ts +10 -4
- package/dist/vite.d.ts.map +1 -1
- package/dist/vite.js +44 -12
- package/dist/vite.js.map +1 -1
- package/package.json +17 -2
- package/src/build-vite.ts +606 -0
- package/src/build.ts +432 -73
- package/src/bun-dev-entry.ts +184 -0
- package/src/client.ts +8 -0
- package/src/deferred.ts +13 -6
- package/src/dev-port.ts +63 -0
- package/src/dev.ts +467 -65
- package/src/draft.ts +116 -4
- package/src/fs.ts +22 -4
- package/src/index.ts +727 -57
- package/src/internal/timing-safe-equal.ts +24 -0
- package/src/isr.ts +1 -13
- package/src/llms-txt.ts +13 -5
- package/src/manifest.ts +188 -13
- package/src/module-graph.ts +146 -0
- package/src/plugins/css-modules.ts +0 -0
- package/src/plugins/vite-leak-guard.ts +110 -0
- package/src/public-dir.ts +108 -0
- package/src/route-manifest.ts +240 -0
- package/src/router.ts +198 -34
- 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
|
|
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,
|
|
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<
|
|
284
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
|
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 [,
|
|
529
|
+
const [, loaded] = await Promise.all([
|
|
391
530
|
loadModule?.(matched.routeId),
|
|
392
|
-
|
|
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,
|
|
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
|
-
:
|
|
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 [,
|
|
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,
|
|
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 [,
|
|
691
|
+
const [, loaded] = await Promise.all([
|
|
544
692
|
loadModule?.(target.routeId),
|
|
545
|
-
|
|
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,
|
|
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
|
|
572
|
-
? state.data
|
|
573
|
-
: await
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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: {
|
|
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.
|
|
53
|
-
*
|
|
54
|
-
*
|
|
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: (
|
|
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
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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
|
-
|
|
361
|
+
const ssrLoad = (absolutePath: string): Promise<unknown> => vite.ssrLoadModule(absolutePath)
|
|
362
|
+
app = await options.createApp(entryUrl, ssrLoad)
|
|
345
363
|
|
|
346
|
-
//
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
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
|
-
|
|
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()
|