@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.
- package/LICENSE +21 -0
- package/README.md +18 -0
- package/css-modules.d.ts +34 -0
- package/dist/build.d.ts +110 -0
- package/dist/build.d.ts.map +1 -0
- package/dist/build.js +238 -0
- package/dist/build.js.map +1 -0
- package/dist/client.d.ts +32 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +204 -0
- package/dist/client.js.map +1 -0
- package/dist/deferred.d.ts +90 -0
- package/dist/deferred.d.ts.map +1 -0
- package/dist/deferred.js +310 -0
- package/dist/deferred.js.map +1 -0
- package/dist/dev-error.d.ts +17 -0
- package/dist/dev-error.d.ts.map +1 -0
- package/dist/dev-error.js +59 -0
- package/dist/dev-error.js.map +1 -0
- package/dist/dev.d.ts +25 -0
- package/dist/dev.d.ts.map +1 -0
- package/dist/dev.js +83 -0
- package/dist/dev.js.map +1 -0
- package/dist/draft.d.ts +58 -0
- package/dist/draft.d.ts.map +1 -0
- package/dist/draft.js +55 -0
- package/dist/draft.js.map +1 -0
- package/dist/fonts-google.d.ts +109 -0
- package/dist/fonts-google.d.ts.map +1 -0
- package/dist/fonts-google.js +291 -0
- package/dist/fonts-google.js.map +1 -0
- package/dist/fonts.d.ts +67 -0
- package/dist/fonts.d.ts.map +1 -0
- package/dist/fonts.js +103 -0
- package/dist/fonts.js.map +1 -0
- package/dist/fs.d.ts +10 -0
- package/dist/fs.d.ts.map +1 -0
- package/dist/fs.js +24 -0
- package/dist/fs.js.map +1 -0
- package/dist/index.d.ts +280 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +784 -0
- package/dist/index.js.map +1 -0
- package/dist/islands.d.ts +37 -0
- package/dist/islands.d.ts.map +1 -0
- package/dist/islands.js +107 -0
- package/dist/islands.js.map +1 -0
- package/dist/isr.d.ts +154 -0
- package/dist/isr.d.ts.map +1 -0
- package/dist/isr.js +331 -0
- package/dist/isr.js.map +1 -0
- package/dist/manifest.d.ts +178 -0
- package/dist/manifest.d.ts.map +1 -0
- package/dist/manifest.js +213 -0
- package/dist/manifest.js.map +1 -0
- package/dist/plugins/css-modules.d.ts +40 -0
- package/dist/plugins/css-modules.d.ts.map +1 -0
- package/dist/plugins/css-modules.js +0 -0
- package/dist/plugins/css-modules.js.map +1 -0
- package/dist/plugins/kit.d.ts +44 -0
- package/dist/plugins/kit.d.ts.map +1 -0
- package/dist/plugins/kit.js +105 -0
- package/dist/plugins/kit.js.map +1 -0
- package/dist/plugins/scss.d.ts +27 -0
- package/dist/plugins/scss.d.ts.map +1 -0
- package/dist/plugins/scss.js +78 -0
- package/dist/plugins/scss.js.map +1 -0
- package/dist/prerender.d.ts +77 -0
- package/dist/prerender.d.ts.map +1 -0
- package/dist/prerender.js +139 -0
- package/dist/prerender.js.map +1 -0
- package/dist/query.d.ts +62 -0
- package/dist/query.d.ts.map +1 -0
- package/dist/query.js +184 -0
- package/dist/query.js.map +1 -0
- package/dist/router.d.ts +169 -0
- package/dist/router.d.ts.map +1 -0
- package/dist/router.js +531 -0
- package/dist/router.js.map +1 -0
- package/dist/vite.d.ts +49 -0
- package/dist/vite.d.ts.map +1 -0
- package/dist/vite.js +144 -0
- package/dist/vite.js.map +1 -0
- package/package.json +127 -0
- package/src/build.ts +375 -0
- package/src/client.ts +218 -0
- package/src/deferred.ts +339 -0
- package/src/dev-error.ts +62 -0
- package/src/dev.ts +108 -0
- package/src/draft.ts +82 -0
- package/src/fonts-google.ts +397 -0
- package/src/fonts.ts +147 -0
- package/src/fs.ts +26 -0
- package/src/index.ts +1202 -0
- package/src/islands.ts +121 -0
- package/src/isr.ts +461 -0
- package/src/manifest.ts +366 -0
- package/src/plugins/css-modules.ts +0 -0
- package/src/plugins/kit.ts +123 -0
- package/src/plugins/scss.ts +108 -0
- package/src/prerender.ts +209 -0
- package/src/query.ts +250 -0
- package/src/router.ts +676 -0
- 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
|
+
}
|