@nifrajs/web 0.1.0-alpha.1

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 (104) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +18 -0
  3. package/css-modules.d.ts +34 -0
  4. package/dist/build.d.ts +110 -0
  5. package/dist/build.d.ts.map +1 -0
  6. package/dist/build.js +238 -0
  7. package/dist/build.js.map +1 -0
  8. package/dist/client.d.ts +32 -0
  9. package/dist/client.d.ts.map +1 -0
  10. package/dist/client.js +204 -0
  11. package/dist/client.js.map +1 -0
  12. package/dist/deferred.d.ts +90 -0
  13. package/dist/deferred.d.ts.map +1 -0
  14. package/dist/deferred.js +310 -0
  15. package/dist/deferred.js.map +1 -0
  16. package/dist/dev-error.d.ts +17 -0
  17. package/dist/dev-error.d.ts.map +1 -0
  18. package/dist/dev-error.js +59 -0
  19. package/dist/dev-error.js.map +1 -0
  20. package/dist/dev.d.ts +25 -0
  21. package/dist/dev.d.ts.map +1 -0
  22. package/dist/dev.js +83 -0
  23. package/dist/dev.js.map +1 -0
  24. package/dist/draft.d.ts +58 -0
  25. package/dist/draft.d.ts.map +1 -0
  26. package/dist/draft.js +55 -0
  27. package/dist/draft.js.map +1 -0
  28. package/dist/fonts-google.d.ts +109 -0
  29. package/dist/fonts-google.d.ts.map +1 -0
  30. package/dist/fonts-google.js +291 -0
  31. package/dist/fonts-google.js.map +1 -0
  32. package/dist/fonts.d.ts +67 -0
  33. package/dist/fonts.d.ts.map +1 -0
  34. package/dist/fonts.js +103 -0
  35. package/dist/fonts.js.map +1 -0
  36. package/dist/fs.d.ts +10 -0
  37. package/dist/fs.d.ts.map +1 -0
  38. package/dist/fs.js +24 -0
  39. package/dist/fs.js.map +1 -0
  40. package/dist/index.d.ts +280 -0
  41. package/dist/index.d.ts.map +1 -0
  42. package/dist/index.js +784 -0
  43. package/dist/index.js.map +1 -0
  44. package/dist/islands.d.ts +37 -0
  45. package/dist/islands.d.ts.map +1 -0
  46. package/dist/islands.js +107 -0
  47. package/dist/islands.js.map +1 -0
  48. package/dist/isr.d.ts +154 -0
  49. package/dist/isr.d.ts.map +1 -0
  50. package/dist/isr.js +331 -0
  51. package/dist/isr.js.map +1 -0
  52. package/dist/manifest.d.ts +178 -0
  53. package/dist/manifest.d.ts.map +1 -0
  54. package/dist/manifest.js +213 -0
  55. package/dist/manifest.js.map +1 -0
  56. package/dist/plugins/css-modules.d.ts +40 -0
  57. package/dist/plugins/css-modules.d.ts.map +1 -0
  58. package/dist/plugins/css-modules.js +0 -0
  59. package/dist/plugins/css-modules.js.map +1 -0
  60. package/dist/plugins/kit.d.ts +44 -0
  61. package/dist/plugins/kit.d.ts.map +1 -0
  62. package/dist/plugins/kit.js +105 -0
  63. package/dist/plugins/kit.js.map +1 -0
  64. package/dist/plugins/scss.d.ts +27 -0
  65. package/dist/plugins/scss.d.ts.map +1 -0
  66. package/dist/plugins/scss.js +78 -0
  67. package/dist/plugins/scss.js.map +1 -0
  68. package/dist/prerender.d.ts +77 -0
  69. package/dist/prerender.d.ts.map +1 -0
  70. package/dist/prerender.js +139 -0
  71. package/dist/prerender.js.map +1 -0
  72. package/dist/query.d.ts +62 -0
  73. package/dist/query.d.ts.map +1 -0
  74. package/dist/query.js +184 -0
  75. package/dist/query.js.map +1 -0
  76. package/dist/router.d.ts +169 -0
  77. package/dist/router.d.ts.map +1 -0
  78. package/dist/router.js +531 -0
  79. package/dist/router.js.map +1 -0
  80. package/dist/vite.d.ts +49 -0
  81. package/dist/vite.d.ts.map +1 -0
  82. package/dist/vite.js +144 -0
  83. package/dist/vite.js.map +1 -0
  84. package/package.json +127 -0
  85. package/src/build.ts +375 -0
  86. package/src/client.ts +218 -0
  87. package/src/deferred.ts +339 -0
  88. package/src/dev-error.ts +62 -0
  89. package/src/dev.ts +108 -0
  90. package/src/draft.ts +82 -0
  91. package/src/fonts-google.ts +397 -0
  92. package/src/fonts.ts +147 -0
  93. package/src/fs.ts +26 -0
  94. package/src/index.ts +1202 -0
  95. package/src/islands.ts +121 -0
  96. package/src/isr.ts +461 -0
  97. package/src/manifest.ts +366 -0
  98. package/src/plugins/css-modules.ts +0 -0
  99. package/src/plugins/kit.ts +123 -0
  100. package/src/plugins/scss.ts +108 -0
  101. package/src/prerender.ts +209 -0
  102. package/src/query.ts +250 -0
  103. package/src/router.ts +676 -0
  104. package/src/vite.ts +212 -0
package/src/router.ts ADDED
@@ -0,0 +1,676 @@
1
+ /**
2
+ * The agnostic client-side router core — pure logic, no `window`, no framework. A per-adapter
3
+ * Router binding subscribes via `subscribe`/`snapshot` and renders the matched chain; a
4
+ * browser-only `installHistory` (history + link interception) wires on top. Kept DOM-free so it
5
+ * unit-tests without a browser and is safe to import from the SSR core's main entry.
6
+ */
7
+ import { parseNdjsonData } from "./deferred.ts"
8
+
9
+ /**
10
+ * Request header that asks a nifra route's GET to return just the loader data as JSON (instead of
11
+ * the full HTML document). Set by client-side navigation; read by `createWebApp`'s GET handler.
12
+ */
13
+ export const DATA_HEADER = "x-nifra-data"
14
+
15
+ /**
16
+ * Global the server injects (`createWebApp({ prerenderedPaths })`) listing the SSG-prerendered paths.
17
+ * The client's default data fetch reads it: a soft-nav INTO a prerendered route fetches its static
18
+ * `<path>/_data.json` (a CDN file — no worker round-trip) instead of the dynamic header-GET.
19
+ */
20
+ export const PRERENDERED_GLOBAL = "__NIFRA_PRERENDERED__"
21
+
22
+ /**
23
+ * Response header a data-mode action POST uses to convey a redirect (`redirect(...)`) to the
24
+ * client — fetch would otherwise silently follow a 3xx to its HTML, losing the target. The
25
+ * client reads this and performs a client-side navigation instead.
26
+ */
27
+ export const REDIRECT_HEADER = "x-nifra-redirect"
28
+
29
+ /**
30
+ * Response header an action sets (via the `revalidate(paths, data)` helper) to tell the client which
31
+ * routes the mutation changed — a comma-separated list of paths. After the submit, the client marks
32
+ * those cached routes stale (refetching any that are mounted) so a mutation can refresh views beyond
33
+ * the active one. The client validates each path against the manifest matcher before acting on it.
34
+ */
35
+ export const REVALIDATE_HEADER = "x-nifra-revalidate"
36
+
37
+ /** A URL matched against the manifest patterns: which route + its extracted params. */
38
+ export interface RouteMatch {
39
+ readonly routeId: string
40
+ readonly params: Record<string, string>
41
+ }
42
+
43
+ /** An in-flight client submit — the action it targets + the `FormData` being sent. Set while the
44
+ * submit is pending, cleared when it settles. A component reads `submission.formData` to render an
45
+ * **optimistic** view (the expected result) before the server responds. */
46
+ export interface Submission {
47
+ readonly action: string
48
+ readonly formData: FormData
49
+ }
50
+
51
+ /** The router's observable state. A new object is published on every transition. */
52
+ export interface RouterState {
53
+ readonly routeId: string
54
+ readonly params: Record<string, string>
55
+ /** The current URL path (used to revalidate the active loader after an action). */
56
+ readonly path: string
57
+ readonly data: unknown
58
+ /** An action's data return after a client-side submit (cleared on navigation). */
59
+ readonly actionData?: unknown
60
+ /** True while a navigation or submit is in flight (drives loading UI). */
61
+ readonly pending: boolean
62
+ /** The in-flight submit (set during a `submit`, cleared when it settles) — for optimistic UI. */
63
+ readonly submission?: Submission
64
+ }
65
+
66
+ /** A route id paired with its nifra pattern (e.g. `":id"` segments) — the matcher input. */
67
+ export interface RoutePattern {
68
+ readonly routeId: string
69
+ readonly pattern: string
70
+ }
71
+
72
+ function compile(pattern: string): { regex: RegExp; keys: string[] } {
73
+ const keys: string[] = []
74
+ const src = pattern
75
+ .split("/")
76
+ .map((seg) => {
77
+ if (seg.startsWith(":")) {
78
+ keys.push(seg.slice(1))
79
+ return "([^/]+)"
80
+ }
81
+ if (seg.startsWith("*")) {
82
+ // Catch-all (`*slug`, or bare `*` → key "*"): capture the rest of the path, slashes included.
83
+ // Always the final segment (the manifest/core enforce it), so the greedy `.` is safe.
84
+ keys.push(seg.length === 1 ? "*" : seg.slice(1))
85
+ return "(.+)"
86
+ }
87
+ // Escape regex-special chars. `$`/`{`/`}` are ordered so no literal `${` appears (it can
88
+ // trip tooling that scans for template-literal interpolation, e.g. coverage sourcemaps).
89
+ return seg.replace(/[.*+?^()|[\]\\{}$]/g, "\\$&")
90
+ })
91
+ .join("/")
92
+ return { regex: new RegExp(`^${src}/?$`), keys }
93
+ }
94
+
95
+ /**
96
+ * Build a matcher from route patterns (built from the SAME manifest the server routes from, so
97
+ * client and server agree). Returns the first matching route + decoded params, or null. The
98
+ * query string is ignored for matching (it is not part of the route pattern).
99
+ */
100
+ export function createMatcher(
101
+ patterns: readonly RoutePattern[],
102
+ ): (path: string) => RouteMatch | null {
103
+ const compiled = patterns.map((p) => ({ routeId: p.routeId, ...compile(p.pattern) }))
104
+ return (path) => {
105
+ // Strip the query without allocating a `split("?")` array — matcher runs per match.
106
+ const q = path.indexOf("?")
107
+ const clean = q === -1 ? path : path.slice(0, q)
108
+ for (const c of compiled) {
109
+ const m = c.regex.exec(clean)
110
+ if (m === null) continue
111
+ const params: Record<string, string> = {}
112
+ // for-of (no per-match `forEach` closure); `i` indexes the capture groups (`m[0]` is the match).
113
+ let i = 0
114
+ for (const k of c.keys) {
115
+ params[k] = decodeURIComponent(m[i + 1] ?? "")
116
+ i++
117
+ }
118
+ return { routeId: c.routeId, params }
119
+ }
120
+ return null
121
+ }
122
+ }
123
+
124
+ /** How a router fetches a route's loader data on navigation. `signal` aborts a superseded fetch
125
+ * (and its deferred stream). */
126
+ export type FetchRouteData = (
127
+ path: string,
128
+ match: RouteMatch,
129
+ signal?: AbortSignal,
130
+ ) => Promise<unknown>
131
+
132
+ /** Per-submit options. `revalidate: false` opts out of the post-action loader re-fetch. */
133
+ export interface SubmitOptions {
134
+ /** Re-run the active route's loader after the action settles (default `true`). Set `false` to
135
+ * keep the current `data` and rely on the action's `actionData` alone. */
136
+ readonly revalidate?: boolean
137
+ }
138
+
139
+ /** A fetcher's observable state — independent of the main router. `pending` covers its in-flight
140
+ * load/submit; `data` is its last `load()` result; `actionData` its last `submit()` result;
141
+ * `submission` the in-flight submit (for optimistic UI). Client-only (never SSR'd). */
142
+ export interface FetcherState {
143
+ readonly pending: boolean
144
+ readonly data: unknown
145
+ readonly actionData?: unknown
146
+ readonly submission?: Submission
147
+ }
148
+
149
+ /**
150
+ * An independent load/submit state machine, retrieved by `router.fetcher(key)`. Runs **concurrently**
151
+ * with the main router and with other fetchers — each is single-flight against *itself* (its own
152
+ * monotonic token), so N row-level mutations / side-channel loads can be in flight at once without
153
+ * disturbing the active view. Loads/submits write the shared cache and honor `X-Nifra-Revalidate`.
154
+ */
155
+ export interface Fetcher {
156
+ /** Current state; stable reference between transitions. */
157
+ snapshot: () => FetcherState
158
+ /** Subscribe to this fetcher's transitions; returns an unsubscribe fn. */
159
+ subscribe: (listener: () => void) => () => void
160
+ /** Load a route path's loader data into this fetcher's own `data` (also writes the shared cache).
161
+ * A no-op for an unmatched path. */
162
+ load: (path: string) => Promise<void>
163
+ /** Submit an action into this fetcher's own state; honors `X-Nifra-Revalidate` by refreshing the
164
+ * active route + any mounted fetcher showing a changed path. Rejects on failure (caller falls back).
165
+ * (No `revalidate` opt-out — a fetcher has no active loader of its own to skip.) */
166
+ submit: (action: string, body: NonNullable<RequestInit["body"]>) => Promise<void>
167
+ }
168
+
169
+ /** The agnostic router store consumed by per-adapter Router bindings. */
170
+ export interface ClientRouter {
171
+ /** Current state; stable reference between transitions (so `useSyncExternalStore` can bail). */
172
+ snapshot: () => RouterState
173
+ /** Subscribe to transitions; returns an unsubscribe fn. */
174
+ subscribe: (listener: () => void) => () => void
175
+ /** Navigate to a path: match → fetch loader data → publish. No-op for an unmatched path. */
176
+ navigate: (path: string) => Promise<void>
177
+ /**
178
+ * Submit an action (POST `body` to `action` in data mode): a redirect becomes a client
179
+ * navigation; otherwise the data return is published as `actionData` and the active route's
180
+ * loader is revalidated so the mutation is reflected. Pass `{ revalidate: false }` to SKIP that
181
+ * revalidation — keep the current `data` and just publish the action's `actionData` (useful when
182
+ * the action already returned everything that changed, saving the extra round-trip). A redirect
183
+ * always loads its target regardless. Rejects on failure (caller falls back).
184
+ */
185
+ submit: (
186
+ action: string,
187
+ body: NonNullable<RequestInit["body"]>,
188
+ opts?: SubmitOptions,
189
+ ) => Promise<void>
190
+ /**
191
+ * Mark cached route data stale and refresh the active view. With `paths`, target exactly those
192
+ * (e.g. the routes a mutation changed); without, invalidate the whole cache. The active route
193
+ * refreshes immediately — refetched + republished — whenever it's in scope (an explicit list that
194
+ * includes it, or an invalidate-all); other stale entries refetch lazily when next read (a
195
+ * fetcher, or the next navigation/access). Rejects if the active refetch fails (like `navigate`).
196
+ * The keyed substrate for targeted revalidation (the `X-Nifra-Revalidate` header) and fetchers.
197
+ */
198
+ invalidate: (paths?: readonly string[]) => Promise<void>
199
+ /**
200
+ * Warm a path's chunk + loader data into a bounded one-shot cache without publishing state —
201
+ * a later `navigate` to it transitions with no network round-trip. Best-effort: failures and
202
+ * unmatched paths are no-ops. Wired to link hover/focus by `installHistory`.
203
+ */
204
+ prefetch: (path: string) => Promise<void>
205
+ /**
206
+ * Get (lazily creating) the stable {@link Fetcher} for `key` — an independent, concurrent
207
+ * load/submit state machine for row-level mutations or side-channel loads that must not disturb
208
+ * the active view. The same `key` always returns the same fetcher (so a binding can subscribe to a
209
+ * stable store). Keys are app-chosen and typically stable (e.g. a row id).
210
+ */
211
+ fetcher: (key: string) => Fetcher
212
+ /** All live fetchers — for a global busy view (e.g. a `useFetchers` binding). */
213
+ fetchers: () => readonly Fetcher[]
214
+ /** Subscribe to any-fetcher-changed (a transition on any fetcher, or a new one created) — backs a
215
+ * `useFetchers` binding; returns an unsubscribe fn. */
216
+ subscribeFetchers: (listener: () => void) => () => void
217
+ /** Match a path against the manifest patterns (exposed for history/link wiring). */
218
+ match: (path: string) => RouteMatch | null
219
+ }
220
+
221
+ export interface ClientRouterOptions {
222
+ readonly patterns: readonly RoutePattern[]
223
+ readonly initial: RouterState
224
+ /** Override the loader-data fetch (tests inject a stub; defaults to a same-origin JSON GET). */
225
+ readonly fetchData?: FetchRouteData
226
+ /** Ensure a route's code chunk is loaded before rendering (code-splitting). Awaited in parallel
227
+ * with the loader data, so `pending` covers both. Omit when the bundle isn't split. */
228
+ readonly loadModule?: (routeId: string) => Promise<void>
229
+ }
230
+
231
+ /** Options for a per-adapter `mountRouter` (the Router binding that hydrates + re-renders). */
232
+ export interface MountRouterOptions {
233
+ readonly router: ClientRouter
234
+ /** routeId → layout chain (outermost layout → page); built by `generateClientEntry`. */
235
+ readonly routes: Record<string, readonly unknown[]>
236
+ /** Hydration container (opaque — the adapter casts it to its DOM element type). */
237
+ readonly container: unknown
238
+ }
239
+
240
+ /** Read a nifra data response: a deferred loader/action streams NDJSON (parse line 1 + settle
241
+ * `<Await>` markers as resolution lines arrive); a non-deferred one returns a single JSON. Shared by
242
+ * navigation fetches and action submits — both transports are identical. */
243
+ const readResponseData = (res: Response, signal?: AbortSignal): Promise<unknown> =>
244
+ (res.headers.get("content-type") ?? "").includes("application/x-ndjson") && res.body !== null
245
+ ? parseNdjsonData(res.body, signal)
246
+ : res.json()
247
+
248
+ // The static `_data.json` URL for a prerendered path: `/` → `/_data.json`, `/users/7` →
249
+ // `/users/7/_data.json` (mirrors the build's `dataFileFor`).
250
+ const dataUrlFor = (pathname: string): string =>
251
+ pathname === "/" ? "/_data.json" : `${pathname.replace(/\/+$/, "")}/_data.json`
252
+
253
+ const defaultFetchData: FetchRouteData = async (path, _match, signal) => {
254
+ // SSG fast path: if this path was prerendered, its loader data is a static file — fetch that (no
255
+ // worker). Falls through to the dynamic header-GET on any miss (file absent, e.g. a deferred route,
256
+ // or a stale set), so it's always safe. Non-SSG apps have no global → the dynamic path, unchanged.
257
+ const prerendered = (globalThis as { [PRERENDERED_GLOBAL]?: unknown })[PRERENDERED_GLOBAL]
258
+ if (Array.isArray(prerendered)) {
259
+ const pathname = path.replace(/[?#].*$/, "")
260
+ if (prerendered.includes(pathname)) {
261
+ const staticRes = await fetch(dataUrlFor(pathname), { signal: signal ?? null })
262
+ if (staticRes.ok) return readResponseData(staticRes, signal)
263
+ }
264
+ }
265
+ const res = await fetch(path, { headers: { [DATA_HEADER]: "1" }, signal: signal ?? null })
266
+ if (!res.ok) throw new Error(`[nifra/web] navigation data fetch failed (${res.status}): ${path}`)
267
+ return readResponseData(res, signal)
268
+ }
269
+
270
+ /**
271
+ * Create the agnostic router store. `navigate` is guarded by a monotonic token so that when
272
+ * navigations overlap, only the latest result is applied (rapid clicks don't flash stale data).
273
+ * A failed fetch clears `pending` and rethrows so the caller can fall back to a full-page load.
274
+ */
275
+ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
276
+ const match = createMatcher(options.patterns)
277
+ const fetchData = options.fetchData ?? defaultFetchData
278
+ const loadModule = options.loadModule
279
+ let state = options.initial
280
+ const listeners = new Set<() => void>()
281
+ let token = 0
282
+ // Snapshot listeners to defend against accidental un/subscribe during notification. The adapters
283
+ // don't do this (un/subscribe runs in effect/cleanup ticks), so the overhead is negligible
284
+ // (5-20 listeners typically), and the defensiveness is valuable.
285
+ const emit = (): void => {
286
+ if (listeners.size === 0) return
287
+ for (const listener of [...listeners]) listener()
288
+ }
289
+ // Bounded one-shot prefetch cache (path → loader data) + an in-flight guard so hover spam
290
+ // doesn't double-fetch. Consumed (and dropped) by the next navigate to that path.
291
+ const MAX_PREFETCH = 10
292
+ const prefetched = new Map<string, unknown>()
293
+ const inflight = new Set<string>()
294
+ // Keyed data cache (path → latest loader data + freshness). Written on every published data
295
+ // (navigate/submit), read by `invalidate` (+ targeted revalidation and fetchers in later F16
296
+ // increments). Bounded — evict the oldest-inserted past the cap (route data is small; this just
297
+ // caps memory). `status` is the staleness ledger: `invalidate` flips entries to `stale`; readers
298
+ // refetch stale ones. Client-only — never serialized/hydrated.
299
+ const MAX_CACHE = 50
300
+ const cache = new Map<string, { data: unknown; status: "fresh" | "stale" }>()
301
+ const cachePut = (path: string, data: unknown): void => {
302
+ if (!cache.has(path) && cache.size >= MAX_CACHE) {
303
+ const oldest = cache.keys().next().value
304
+ if (oldest !== undefined) cache.delete(oldest)
305
+ }
306
+ cache.set(path, { data, status: "fresh" })
307
+ }
308
+ // Flip cached entries to stale (a no-op for paths not in the cache). Shared by `invalidate` and the
309
+ // `X-Nifra-Revalidate` handling in `submit`.
310
+ const markStale = (paths: readonly string[]): void => {
311
+ for (const p of paths) {
312
+ const entry = cache.get(p)
313
+ if (entry !== undefined) cache.set(p, { data: entry.data, status: "stale" })
314
+ }
315
+ }
316
+ // Parse the `X-Nifra-Revalidate` response header into validated paths. The header is response data
317
+ // (a trust boundary), so each path must match a real manifest pattern — unknown/garbage is dropped
318
+ // and never triggers a fetch.
319
+ const parseRevalidate = (header: string | null): string[] =>
320
+ header === null
321
+ ? []
322
+ : header
323
+ .split(",")
324
+ .map((s) => s.trim())
325
+ .filter((s) => s.length > 0 && match(s) !== null)
326
+ // Aborts the in-flight navigation's fetch + deferred stream when a newer navigation supersedes it
327
+ // (so a superseded route's NDJSON stream stops reading instead of draining in the background).
328
+ let navAbort: AbortController | undefined
329
+
330
+ // Refetch the active route + republish, single-flight via the shared `token`. Shared by `invalidate`
331
+ // and targeted revalidation. Rejects if the fetch fails (clearing its own `pending` first).
332
+ const refetchActive = async (): Promise<void> => {
333
+ const mine = ++token
334
+ navAbort?.abort()
335
+ const ac = new AbortController()
336
+ navAbort = ac
337
+ state = { ...state, pending: true }
338
+ emit()
339
+ try {
340
+ const data = await fetchData(
341
+ state.path,
342
+ { routeId: state.routeId, params: state.params },
343
+ ac.signal,
344
+ )
345
+ if (mine !== token) return // superseded — drop the stale result
346
+ cachePut(state.path, data)
347
+ state = { ...state, data, pending: false }
348
+ emit()
349
+ } catch (err) {
350
+ if (mine === token) {
351
+ state = { ...state, pending: false }
352
+ emit()
353
+ }
354
+ throw err
355
+ }
356
+ }
357
+
358
+ // --- Concurrent fetchers (F16.3) ---------------------------------------------------------------
359
+ // Lazily-created stable fetchers, keyed by an app-chosen string. `fetcherListeners` lets a
360
+ // `useFetchers` binding re-render when ANY fetcher changes or a new one is created.
361
+ type FetcherEntry = {
362
+ fetcher: Fetcher
363
+ refreshIfShowing: (paths: readonly string[]) => Promise<void>
364
+ }
365
+ const fetchers = new Map<string, FetcherEntry>()
366
+ const fetcherListeners = new Set<() => void>()
367
+ // Cached list of live fetchers, rebuilt (new array ref) on every fetcher change/creation. A stable
368
+ // ref between changes is what lets a `useFetchers` binding read it via `useSyncExternalStore`
369
+ // without looping; a fresh ref on each change is what makes it re-render.
370
+ let fetchersArr: readonly Fetcher[] = []
371
+ const emitFetchers = (): void => {
372
+ fetchersArr = [...fetchers.values()].map((e) => e.fetcher) // snapshot ref (intentional)
373
+ if (fetcherListeners.size === 0) return
374
+ for (const l of [...fetcherListeners]) l() // snapshot for defensiveness
375
+ }
376
+
377
+ // Best-effort refresh of every MOUNTED reader of `paths`: the active route (unless `skipActive`,
378
+ // when the caller already refetched it) + any fetcher that loaded one of these paths. Errors are
379
+ // swallowed — a failed targeted refresh must not fail the mutation that triggered it.
380
+ const refreshMounted = async (paths: readonly string[], skipActive: boolean): Promise<void> => {
381
+ const jobs: Promise<void>[] = []
382
+ if (!skipActive && paths.includes(state.path)) jobs.push(refetchActive().catch(() => {}))
383
+ for (const entry of fetchers.values()) jobs.push(entry.refreshIfShowing(paths))
384
+ await Promise.all(jobs)
385
+ }
386
+
387
+ const createFetcher = (): FetcherEntry => {
388
+ let fState: FetcherState = { pending: false, data: undefined }
389
+ let fToken = 0
390
+ let fAbort: AbortController | undefined
391
+ let loadedPath: string | undefined
392
+ const fListeners = new Set<() => void>()
393
+ const fEmit = (): void => {
394
+ if (fListeners.size > 0) for (const l of [...fListeners]) l() // snapshot for defensiveness
395
+ emitFetchers()
396
+ }
397
+ const runLoad = async (path: string): Promise<void> => {
398
+ const matched = match(path)
399
+ if (matched === null) return // unmatched path → no-op
400
+ const mine = ++fToken
401
+ fAbort?.abort()
402
+ const ac = new AbortController()
403
+ fAbort = ac
404
+ fState = { ...fState, pending: true }
405
+ fEmit()
406
+ try {
407
+ const [, data] = await Promise.all([
408
+ loadModule?.(matched.routeId),
409
+ fetchData(path, matched, ac.signal),
410
+ ])
411
+ if (mine !== fToken) return // a newer load/submit on THIS fetcher superseded us
412
+ // Record the loaded path only on SUCCESS — a thrown or superseded load must not
413
+ // leave `loadedPath` pointing at a path this fetcher never actually showed, or a later
414
+ // `X-Nifra-Revalidate` for it would spuriously refetch onto unexpected data.
415
+ loadedPath = path
416
+ cachePut(path, data)
417
+ fState = { ...fState, data, pending: false }
418
+ fEmit()
419
+ } catch (err) {
420
+ if (mine === fToken) {
421
+ fState = { ...fState, pending: false }
422
+ fEmit()
423
+ }
424
+ throw err
425
+ }
426
+ }
427
+ const fetcher: Fetcher = {
428
+ snapshot: () => fState,
429
+ subscribe: (l) => {
430
+ fListeners.add(l)
431
+ return () => {
432
+ fListeners.delete(l)
433
+ }
434
+ },
435
+ load: runLoad,
436
+ submit: async (action, body) => {
437
+ const mine = ++fToken
438
+ // Abort any prior in-flight load/submit on THIS fetcher (its fetch + NDJSON drain) — like
439
+ // `runLoad` does for a superseding load. The mutation POST is left to complete; the
440
+ // signal cancels the follow-up data drain if a newer op supersedes this one.
441
+ fAbort?.abort()
442
+ const ac = new AbortController()
443
+ fAbort = ac
444
+ // Expose the in-flight submission (FormData) for optimistic UI; a new submit supersedes any
445
+ // prior actionData (dropped here, set again on success).
446
+ fState = {
447
+ pending: true,
448
+ data: fState.data,
449
+ ...(body instanceof FormData ? { submission: { action, formData: body } } : {}),
450
+ }
451
+ fEmit()
452
+ try {
453
+ const res = await fetch(action, { method: "POST", body, headers: { [DATA_HEADER]: "1" } })
454
+ if (!res.ok)
455
+ throw new Error(`[nifra/web] fetcher action failed (${res.status}): ${action}`)
456
+ const actionData = res.status === 204 ? undefined : await readResponseData(res, ac.signal)
457
+ const changed = parseRevalidate(res.headers.get(REVALIDATE_HEADER))
458
+ if (mine !== fToken) return
459
+ // Publish the fetcher's actionData; clear `submission` (the optimistic window is over).
460
+ fState = { pending: false, data: fState.data, actionData }
461
+ fEmit()
462
+ // A fetcher has no loader of its own — its mutation's freshness flows through the cache:
463
+ // mark the changed routes stale + refresh every mounted reader (active route + fetchers).
464
+ if (changed.length > 0) {
465
+ markStale(changed)
466
+ await refreshMounted(changed, false)
467
+ }
468
+ } catch (err) {
469
+ if (mine === fToken) {
470
+ fState = { pending: false, data: fState.data }
471
+ fEmit()
472
+ }
473
+ throw err
474
+ }
475
+ },
476
+ }
477
+ return {
478
+ fetcher,
479
+ refreshIfShowing: async (paths) => {
480
+ // Best-effort: a failed targeted refresh of a fetcher must not reject the triggering flow.
481
+ if (loadedPath !== undefined && paths.includes(loadedPath))
482
+ await runLoad(loadedPath).catch(() => {})
483
+ },
484
+ }
485
+ }
486
+
487
+ return {
488
+ match,
489
+ snapshot: () => state,
490
+ subscribe: (listener) => {
491
+ listeners.add(listener)
492
+ return () => {
493
+ listeners.delete(listener)
494
+ }
495
+ },
496
+ navigate: async (path) => {
497
+ const matched = match(path)
498
+ if (matched === null) return
499
+ const mine = ++token
500
+ navAbort?.abort() // abandon any in-flight navigation's stream
501
+ const ac = new AbortController()
502
+ navAbort = ac
503
+ state = { ...state, pending: true }
504
+ emit()
505
+ try {
506
+ // Use prefetched data when present (one-shot — drop it); else fetch. The chunk is loaded
507
+ // either way (cached + instant after a prefetch), so pending covers both.
508
+ const hit = prefetched.has(path)
509
+ const dataPromise = hit
510
+ ? Promise.resolve(prefetched.get(path))
511
+ : fetchData(path, matched, ac.signal)
512
+ if (hit) prefetched.delete(path)
513
+ const [, data] = await Promise.all([loadModule?.(matched.routeId), dataPromise])
514
+ if (mine !== token) return // a newer navigation superseded this one — drop the stale result
515
+ cachePut(path, data) // keep the keyed cache coherent with what we publish
516
+ state = {
517
+ routeId: matched.routeId,
518
+ params: matched.params,
519
+ path,
520
+ data,
521
+ actionData: undefined, // a fresh navigation has no action result
522
+ pending: false,
523
+ }
524
+ emit()
525
+ } catch (err) {
526
+ // Clear our pending flag (only if still current) and rethrow — the caller decides how to
527
+ // recover (the history layer falls back to a full-page navigation).
528
+ if (mine === token) {
529
+ state = { ...state, pending: false }
530
+ emit()
531
+ }
532
+ throw err
533
+ }
534
+ },
535
+ submit: async (action, body, opts) => {
536
+ const mine = ++token
537
+ // A superseding navigation/submit aborts this submit's FOLLOW-UP reads (revalidation / redirect
538
+ // fetch + their NDJSON drains) — not the mutation POST itself, which should complete.
539
+ // Wire into `navAbort` like `navigate`/`refetchActive`, so a later nav cancels the in-flight read.
540
+ navAbort?.abort()
541
+ const ac = new AbortController()
542
+ navAbort = ac
543
+ // Expose the in-flight submission (when it's FormData) so components can render an optimistic
544
+ // view from it while pending; cleared when the submit settles below.
545
+ state = {
546
+ ...state,
547
+ pending: true,
548
+ ...(body instanceof FormData ? { submission: { action, formData: body } } : {}),
549
+ }
550
+ emit()
551
+ try {
552
+ const res = await fetch(action, { method: "POST", body, headers: { [DATA_HEADER]: "1" } })
553
+ if (!res.ok) throw new Error(`[nifra/web] action failed (${res.status}): ${action}`)
554
+ const redirectTo = res.headers.get(REDIRECT_HEADER)
555
+ if (redirectTo !== null) {
556
+ // The action redirected (Post/Redirect/Get) — treat it as a client navigation.
557
+ const target = match(redirectTo)
558
+ if (target === null)
559
+ throw new Error(`[nifra/web] action redirect off-route: ${redirectTo}`)
560
+ const [, data] = await Promise.all([
561
+ loadModule?.(target.routeId),
562
+ fetchData(redirectTo, target, ac.signal),
563
+ ])
564
+ if (mine !== token) return
565
+ cachePut(redirectTo, data)
566
+ state = {
567
+ routeId: target.routeId,
568
+ params: target.params,
569
+ path: redirectTo,
570
+ data,
571
+ actionData: undefined,
572
+ pending: false,
573
+ }
574
+ emit()
575
+ return
576
+ }
577
+ // The action's data — streamed NDJSON if it `defer()`'d slow parts (markers settle as lines
578
+ // arrive, for `<Await actionData>`), else one JSON. Abortable so a superseding nav cancels the
579
+ // drain.
580
+ const actionData = res.status === 204 ? undefined : await readResponseData(res, ac.signal)
581
+ // Routes the action declared changed (via the `revalidate()` helper → `X-Nifra-Revalidate`),
582
+ // validated against the manifest.
583
+ const changed = parseRevalidate(res.headers.get(REVALIDATE_HEADER))
584
+ // Revalidate the active route's loader so the mutation is reflected — unless the caller opted
585
+ // out (`revalidate: false`). A server-declared revalidate of the active path overrides the
586
+ // opt-out (the server says it changed, so stale data would be wrong). Default is to revalidate.
587
+ const skipActive = opts?.revalidate === false && !changed.includes(state.path)
588
+ const data = skipActive
589
+ ? state.data
590
+ : await fetchData(state.path, { routeId: state.routeId, params: state.params }, ac.signal)
591
+ if (mine !== token) return
592
+ cachePut(state.path, data) // the revalidated (or kept) data is now the cache's truth
593
+ // Mark the OTHER changed routes stale so the next access refetches.
594
+ markStale(changed.filter((p) => p !== state.path))
595
+ // Reconcile: publish the revalidated data + actionData; omit `submission` (the optimistic
596
+ // window is over — the real data now drives the view).
597
+ state = {
598
+ routeId: state.routeId,
599
+ params: state.params,
600
+ path: state.path,
601
+ data,
602
+ actionData,
603
+ pending: false,
604
+ }
605
+ emit()
606
+ // Refresh any mounted fetcher showing one of the changed routes (the active route was just
607
+ // revalidated inline above, so skip it here). Best-effort — never rejects the submit.
608
+ await refreshMounted(changed, true)
609
+ } catch (err) {
610
+ if (mine === token) {
611
+ // Revert: clear `submission` (the optimistic view vanishes) leaving `data` untouched, so the
612
+ // pre-submit data shows through.
613
+ state = {
614
+ routeId: state.routeId,
615
+ params: state.params,
616
+ path: state.path,
617
+ data: state.data,
618
+ actionData: state.actionData,
619
+ pending: false,
620
+ }
621
+ emit()
622
+ }
623
+ throw err
624
+ }
625
+ },
626
+ invalidate: async (paths) => {
627
+ // Mark targeted cache entries stale (all entries when no `paths`) — unmounted ones refetch
628
+ // lazily on next access.
629
+ const targets = paths ?? [...cache.keys()]
630
+ markStale(targets)
631
+ // Refresh mounted readers now: the active route (when in scope — an explicit list including it,
632
+ // or an invalidate-all) + any fetcher showing a targeted path. The active refetch rejects on
633
+ // failure (the caller asked to refresh); fetcher refreshes are best-effort (swallowed).
634
+ const jobs: Promise<void>[] = []
635
+ if (paths === undefined || paths.includes(state.path)) jobs.push(refetchActive())
636
+ for (const entry of fetchers.values()) jobs.push(entry.refreshIfShowing(targets))
637
+ await Promise.all(jobs)
638
+ },
639
+ prefetch: async (path) => {
640
+ if (prefetched.has(path) || inflight.has(path)) return
641
+ const matched = match(path)
642
+ if (matched === null) return
643
+ inflight.add(path)
644
+ try {
645
+ const [, data] = await Promise.all([
646
+ loadModule?.(matched.routeId),
647
+ fetchData(path, matched),
648
+ ])
649
+ if (prefetched.size >= MAX_PREFETCH) {
650
+ const oldest = prefetched.keys().next().value
651
+ if (oldest !== undefined) prefetched.delete(oldest)
652
+ }
653
+ prefetched.set(path, data)
654
+ } catch {
655
+ // Best-effort: a failed prefetch just means the eventual navigate fetches normally.
656
+ } finally {
657
+ inflight.delete(path)
658
+ }
659
+ },
660
+ fetcher: (key) => {
661
+ const existing = fetchers.get(key)
662
+ if (existing !== undefined) return existing.fetcher
663
+ const entry = createFetcher()
664
+ fetchers.set(key, entry)
665
+ emitFetchers() // a new fetcher appeared — wake any `useFetchers` subscriber
666
+ return entry.fetcher
667
+ },
668
+ fetchers: () => fetchersArr,
669
+ subscribeFetchers: (listener) => {
670
+ fetcherListeners.add(listener)
671
+ return () => {
672
+ fetcherListeners.delete(listener)
673
+ }
674
+ },
675
+ }
676
+ }