@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
@@ -0,0 +1,209 @@
1
+ /**
2
+ * Build-time SSG driver. Prerendering is framework-agnostic by construction: a nifra SSR page is
3
+ * "just `app.fetch(GET)` returning a streamed `Response`", so prerendering = drive that same handler
4
+ * at build with a synthetic request, drain the body to bytes, and write the HTML to disk. No adapter
5
+ * (React/Solid/Vue/Preact/Svelte) is touched — this sits entirely above the render seam.
6
+ *
7
+ * Handles **static** routes (opt in via `export const prerender = true`) and **dynamic** `:param`
8
+ * routes (enumerate concrete params via `export const getStaticPaths`). Catch-all/wildcard (`*`)
9
+ * routes are not supported.
10
+ */
11
+ import { mkdirSync, writeFileSync } from "node:fs"
12
+ import { dirname, resolve, sep } from "node:path"
13
+ import { fillRoutePattern, type RouteEntry } from "./manifest.ts"
14
+ import { DATA_HEADER } from "./router.ts"
15
+
16
+ /** Minimal app surface the driver needs — just a fetch handler (a built `createWebApp`). */
17
+ export interface PrerenderApp {
18
+ fetch(req: Request): Response | Promise<Response>
19
+ }
20
+
21
+ export interface PrerenderOptions {
22
+ /** The built web app (a `createWebApp` result). A synthetic `GET <origin><pattern>` is issued per
23
+ * prerenderable route; its drained response body is written to disk. */
24
+ readonly app: PrerenderApp
25
+ /** The route manifest's `routes`. Only **static** routes whose module exports `prerender === true`
26
+ * are emitted; everything else is recorded in {@link PrerenderResult.skipped} with a reason. */
27
+ readonly routes: readonly RouteEntry[]
28
+ /** Absolute output directory. `<pattern>/index.html` is written under it (`/` → `index.html`). */
29
+ readonly outDir: string
30
+ /** Origin for the synthetic request URL. Default `http://localhost`. Only the path is meaningful;
31
+ * loaders see this as `request.url`, so set it if a loader builds absolute URLs from the origin. */
32
+ readonly origin?: string
33
+ }
34
+
35
+ export interface PrerenderEntry {
36
+ /** The route pattern that was prerendered (e.g. `/`, `/about`). */
37
+ readonly path: string
38
+ /** The written file, relative to `outDir` (e.g. `index.html`, `about/index.html`). */
39
+ readonly file: string
40
+ /** Byte length of the written HTML. */
41
+ readonly bytes: number
42
+ /** The loader-data file written alongside (`<path>/_data.json`) so a client soft-nav INTO this
43
+ * route skips the worker. Absent when the data-mode response is NDJSON (a deferred loader). */
44
+ readonly dataFile?: string
45
+ }
46
+
47
+ export interface PrerenderResult {
48
+ readonly prerendered: readonly PrerenderEntry[]
49
+ readonly skipped: readonly { readonly path: string; readonly reason: string }[]
50
+ /** Per dynamic route pattern, its `getStaticPaths` `fallback` (`"ssr"` default) — so the deploy
51
+ * layer knows whether unlisted paths should hit the worker (`"ssr"`) or 404 (`"404"`). */
52
+ readonly fallbacks: Readonly<Record<string, "ssr" | "404">>
53
+ }
54
+
55
+ /** Map a route path to its output file: `/` → `index.html`, `/a/b` → `a/b/index.html`. */
56
+ export function htmlFileFor(pattern: string): string {
57
+ const trimmed = pattern.replace(/^\/+/, "").replace(/\/+$/, "")
58
+ return trimmed === "" ? "index.html" : `${trimmed}/index.html`
59
+ }
60
+
61
+ /** The static loader-data file next to a route's `index.html`: `/` → `_data.json`, `/a/b` →
62
+ * `a/b/_data.json`. The client fetches it on soft-nav into a prerendered route (no worker). */
63
+ export function dataFileFor(pattern: string): string {
64
+ return htmlFileFor(pattern).replace(/index\.html$/, "_data.json")
65
+ }
66
+
67
+ const outputPath = (root: string, file: string): string | undefined => {
68
+ const abs = resolve(root, file)
69
+ return abs !== root && abs.startsWith(`${root}${sep}`) ? abs : undefined
70
+ }
71
+
72
+ /**
73
+ * Render every opted-in static route to a static `index.html` under `outDir`. Run AFTER `buildClient`
74
+ * (so the app references the hashed client entry). Returns a report of what was emitted vs skipped —
75
+ * the caller can use `prerendered` to wire a hybrid deploy (e.g. exclude those paths from the SSR
76
+ * worker so the CDN serves the static file). Never throws on a per-route miss: a non-OK response or a
77
+ * non-opted/dynamic route is recorded in `skipped` and the build continues.
78
+ */
79
+ export async function prerenderRoutes(options: PrerenderOptions): Promise<PrerenderResult> {
80
+ const origin = options.origin ?? "http://localhost"
81
+ const prerendered: PrerenderEntry[] = []
82
+ const skipped: { path: string; reason: string }[] = []
83
+ const fallbacks: Record<string, "ssr" | "404"> = {}
84
+ const outRoot = resolve(options.outDir)
85
+
86
+ // Render one concrete path → drain the streamed document to bytes → write its index.html. Shared by
87
+ // static (one path) and dynamic (one path per getStaticPaths entry) routes; never throws.
88
+ const renderPath = async (path: string): Promise<void> => {
89
+ const res = await options.app.fetch(new Request(`${origin}${path}`))
90
+ if (!res.ok) {
91
+ // A failed render is a per-route skip, not a build failure — surfaced for the caller to log.
92
+ skipped.push({ path, reason: `render returned HTTP ${res.status}` })
93
+ return
94
+ }
95
+ const html = await res.text()
96
+ const file = htmlFileFor(path)
97
+ const abs = outputPath(outRoot, file)
98
+ if (abs === undefined) {
99
+ skipped.push({ path, reason: "unsafe output path" })
100
+ return
101
+ }
102
+ mkdirSync(dirname(abs), { recursive: true })
103
+ writeFileSync(abs, html)
104
+
105
+ // Also emit the loader data as a static `_data.json` next to it, so a client soft-nav into this
106
+ // route fetches a file instead of hitting the worker. Only when the data-mode response is plain
107
+ // JSON — a deferred loader answers with NDJSON, which the static fast-path doesn't cover (the
108
+ // client falls back to the worker for those).
109
+ let dataFile: string | undefined
110
+ const dataRes = await options.app.fetch(
111
+ new Request(`${origin}${path}`, { headers: { [DATA_HEADER]: "1" } }),
112
+ )
113
+ if (
114
+ dataRes.ok &&
115
+ !(dataRes.headers.get("content-type") ?? "").includes("application/x-ndjson")
116
+ ) {
117
+ dataFile = dataFileFor(path)
118
+ const dataAbs = outputPath(outRoot, dataFile)
119
+ if (dataAbs === undefined) {
120
+ await dataRes.body?.cancel()
121
+ dataFile = undefined
122
+ } else {
123
+ writeFileSync(dataAbs, await dataRes.text())
124
+ }
125
+ } else {
126
+ await dataRes.body?.cancel() // not emitting (non-OK or NDJSON) — release the stream
127
+ }
128
+
129
+ prerendered.push({ path, file, bytes: html.length, ...(dataFile ? { dataFile } : {}) })
130
+ }
131
+
132
+ for (const route of options.routes) {
133
+ if (route.pattern.includes("*")) {
134
+ skipped.push({ path: route.pattern, reason: "catch-all/wildcard route — not supported" })
135
+ continue
136
+ }
137
+ const mod = await route.load()
138
+
139
+ if (route.pattern.includes(":")) {
140
+ // Dynamic route → enumerate concrete paths via `getStaticPaths`.
141
+ if (mod.getStaticPaths === undefined) {
142
+ skipped.push({ path: route.pattern, reason: "dynamic route without getStaticPaths" })
143
+ continue
144
+ }
145
+ const { paths, fallback = "ssr" } = await mod.getStaticPaths()
146
+ fallbacks[route.pattern] = fallback
147
+ for (const { params } of paths) {
148
+ const { path, missing } = fillRoutePattern(route.pattern, params)
149
+ if (missing.length > 0) {
150
+ skipped.push({
151
+ path: route.pattern,
152
+ reason: `getStaticPaths missing param(s): ${missing.join(", ")}`,
153
+ })
154
+ continue
155
+ }
156
+ await renderPath(path)
157
+ }
158
+ continue
159
+ }
160
+
161
+ // Static route → opt-in flag.
162
+ if (mod.prerender !== true) {
163
+ skipped.push({
164
+ path: route.pattern,
165
+ reason: "not opted in (`export const prerender = true`)",
166
+ })
167
+ continue
168
+ }
169
+ await renderPath(route.pattern)
170
+ }
171
+
172
+ return { prerendered, skipped, fallbacks }
173
+ }
174
+
175
+ /** A Cloudflare Pages `_routes.json` document. `exclude`d paths are served straight from the CDN (the
176
+ * Function/worker is NOT invoked); everything else in `include` hits the worker. */
177
+ export interface CloudflarePagesRoutes {
178
+ readonly version: 1
179
+ readonly include: readonly string[]
180
+ readonly exclude: readonly string[]
181
+ }
182
+
183
+ export interface CloudflarePagesRoutesOptions {
184
+ /** Prerendered request paths (e.g. `prerenderRoutes(...).prerendered.map(p => p.path)`, or the
185
+ * build's `prerendered.json`). Each is excluded from the worker — plus its static `_data.json`. */
186
+ readonly prerendered: readonly string[]
187
+ /** Extra globs to keep OFF the worker (CDN-served). Default `["/assets/*"]` (the hashed bundle). */
188
+ readonly staticGlobs?: readonly string[]
189
+ }
190
+
191
+ /**
192
+ * Build a Cloudflare Pages `_routes.json` for a HYBRID SSG deploy: the prerendered HTML + their static
193
+ * `_data.json` + the asset bundle are `exclude`d (CDN serves them directly), and everything else falls
194
+ * through to the SSR `_worker.js`. Write the result to `dist/_routes.json`.
195
+ *
196
+ * ⚠️ Cloudflare caps `_routes.json` at **100** include+exclude rules total, and each prerendered path
197
+ * costs 2 (the doc + its `_data.json`). For a large SSG site, exclude by **prefix glob** (e.g.
198
+ * `/blog/*`) instead of listing every path — pass those via `staticGlobs` and a smaller `prerendered`.
199
+ */
200
+ export function cloudflarePagesRoutes(
201
+ options: CloudflarePagesRoutesOptions,
202
+ ): CloudflarePagesRoutes {
203
+ const exclude = [...(options.staticGlobs ?? ["/assets/*"])]
204
+ for (const path of options.prerendered) {
205
+ exclude.push(path) // the prerendered document
206
+ exclude.push(path === "/" ? "/_data.json" : `${path.replace(/\/+$/, "")}/_data.json`) // its data
207
+ }
208
+ return { version: 1, include: ["/*"], exclude }
209
+ }
package/src/query.ts ADDED
@@ -0,0 +1,250 @@
1
+ /**
2
+ * The agnostic keyed query-cache — a `query(key, fn)` primitive with in-flight dedup, `staleTime`
3
+ * freshness, prefix `invalidateQueries`, and bounded GC. Pure logic, no `window`, no framework, no
4
+ * `Date.now`/timers (the clock is injected) — so it unit-tests deterministically and is safe to import
5
+ * from the SSR core's main entry. A per-adapter binding (`useQuery`/`createQuery`) subscribes to a
6
+ * query's `subscribe`/`snapshot` store (the same shape the router + fetchers use).
7
+ *
8
+ * This is a SECOND keyed cache, distinct from the router's path-keyed route cache (F16): route loaders
9
+ * + navigation use the path cache; component-level interactive data (search, infinite scroll, polling)
10
+ * uses this arbitrary-keyed query cache. The two coexist.
11
+ */
12
+
13
+ /** A query's lifecycle status. `pending` = no data yet; `success`/`error` once it has settled once. */
14
+ export type QueryStatus = "pending" | "success" | "error"
15
+
16
+ /** A query's observable state — what a binding renders. A new (frozen) object per transition, so a
17
+ * `useSyncExternalStore`/signal binding can compare by reference. */
18
+ export interface QueryState<T = unknown> {
19
+ readonly status: QueryStatus
20
+ readonly data: T | undefined
21
+ readonly error: unknown
22
+ /** True while a fetch is in flight — the initial load OR a background refetch (data may still show). */
23
+ readonly isFetching: boolean
24
+ /** When `data` was last set (via the injected clock); drives `staleTime`. `-Infinity` until first set. */
25
+ readonly updatedAt: number
26
+ }
27
+
28
+ /** A stable per-key handle: subscribe to its state, read a snapshot, trigger a fetch/refetch. */
29
+ export interface QueryHandle<T = unknown> {
30
+ snapshot: () => QueryState<T>
31
+ subscribe: (listener: () => void) => () => void
32
+ /** Fetch when stale or absent (fresh ⇒ no-op); joins an in-flight fetch (dedup). Resolves to the
33
+ * data, or rejects if the fetch threw. */
34
+ fetch: () => Promise<T>
35
+ /** Refetch regardless of staleness (still joins an in-flight fetch). */
36
+ refetch: () => Promise<T>
37
+ }
38
+
39
+ export interface QueryClientOptions {
40
+ /** Monotonic clock in ms — injected so the core stays deterministic + testable; the adapter binding
41
+ * passes `() => Date.now()`. */
42
+ readonly now: () => number
43
+ /** Data stays fresh this long after a fetch; older ⇒ refetch on the next `fetch()` (default `0`). */
44
+ readonly staleTime?: number
45
+ /** Evict an entry this long (ms) after its last subscriber leaves (default 5 min). */
46
+ readonly gcTime?: number
47
+ /** Hard cap on cached entries — LRU-evict the oldest *unsubscribed* entry past it (default 1000). */
48
+ readonly max?: number
49
+ }
50
+
51
+ /** The keyed query cache. One per app (a binding registers it like the router). */
52
+ export interface QueryClient {
53
+ /** Get the stable {@link QueryHandle} for `key`, fetched via `fn`. Re-binds the latest `fn` each
54
+ * call (closures change between renders); returns the same handle for the same (hashed) key. */
55
+ query: <T>(key: unknown, fn: () => Promise<T>) => QueryHandle<T>
56
+ /** Mark matching cached queries stale and refetch the **mounted** ones (subscribers > 0). Array
57
+ * keys match by **prefix** (`["todo"]` ⇒ every `["todo", …]`); other keys match exactly. */
58
+ invalidateQueries: (keyOrPrefix: unknown) => void
59
+ }
60
+
61
+ /**
62
+ * Hash a query key to a stable cache string. Object keys are sorted (so `{a,b}` ≡ `{b,a}`); arrays
63
+ * keep order. Keys must be serializable — a function/symbol in the key throws (it can't be a stable
64
+ * identity). Mirrors TanStack Query's structural hashing.
65
+ */
66
+ export function hashQueryKey(key: unknown): string {
67
+ return JSON.stringify(key, (_k, value) => {
68
+ if (typeof value === "function" || typeof value === "symbol")
69
+ throw new TypeError("[nifra/web] a query key must be serializable (no functions/symbols)")
70
+ if (value !== null && typeof value === "object" && !Array.isArray(value)) {
71
+ const obj = value as Record<string, unknown>
72
+ return Object.fromEntries(
73
+ Object.keys(obj)
74
+ .sort()
75
+ .map((k) => [k, obj[k]]),
76
+ )
77
+ }
78
+ return value
79
+ })
80
+ }
81
+
82
+ // Structural array-prefix match on un-hashed keys: invalidate(["todo"]) hits ["todo", id]. Non-array
83
+ // keys (or a non-array prefix) match exactly (by hash).
84
+ function keyMatchesPrefix(key: unknown, prefix: unknown): boolean {
85
+ if (!Array.isArray(key) || !Array.isArray(prefix))
86
+ return hashQueryKey(key) === hashQueryKey(prefix)
87
+ return (
88
+ prefix.length <= key.length && prefix.every((p, i) => hashQueryKey(key[i]) === hashQueryKey(p))
89
+ )
90
+ }
91
+
92
+ interface Entry {
93
+ readonly key: unknown
94
+ fn: () => Promise<unknown>
95
+ state: QueryState
96
+ promise: Promise<unknown> | undefined
97
+ /** The {@link generation} the in-flight {@link promise} started at — so a fetch begun *before* an
98
+ * `invalidateQueries` (which bumps `generation`) is recognized as superseded, not joined. */
99
+ promiseGen: number
100
+ /** Bumped by `invalidateQueries`/`refetch` to supersede any in-flight fetch begun before it. */
101
+ generation: number
102
+ invalidated: boolean
103
+ subscribers: number
104
+ /** Scheduled eviction time (set when subscribers hit 0; cleared on resubscribe). */
105
+ gcAt: number | undefined
106
+ readonly listeners: Set<() => void>
107
+ // Assigned once just after construction (the handle's closures reference this entry); not readonly.
108
+ handle: QueryHandle
109
+ }
110
+
111
+ const PENDING: QueryState = Object.freeze({
112
+ status: "pending",
113
+ data: undefined,
114
+ error: undefined,
115
+ isFetching: false,
116
+ updatedAt: Number.NEGATIVE_INFINITY,
117
+ })
118
+
119
+ export function createQueryClient(options: QueryClientOptions): QueryClient {
120
+ const { now } = options
121
+ const staleTime = options.staleTime ?? 0
122
+ const gcTime = options.gcTime ?? 5 * 60_000
123
+ const max = options.max ?? 1000
124
+ const cache = new Map<string, Entry>()
125
+
126
+ // Lazily evict entries whose GC timer has elapsed (no subscribers), then enforce the hard cap by
127
+ // dropping the oldest-inserted unsubscribed entries. Called on every `query()` — no background timer.
128
+ const sweep = (): void => {
129
+ const t = now()
130
+ for (const [h, e] of cache) {
131
+ if (e.subscribers === 0 && e.gcAt !== undefined && t >= e.gcAt) cache.delete(h)
132
+ }
133
+ if (cache.size <= max) return
134
+ for (const [h, e] of cache) {
135
+ if (cache.size <= max) break
136
+ if (e.subscribers === 0) cache.delete(h) // oldest-inserted unsubscribed first (Map order)
137
+ }
138
+ }
139
+
140
+ const query = <T>(key: unknown, fn: () => Promise<T>): QueryHandle<T> => {
141
+ sweep()
142
+ const h = hashQueryKey(key)
143
+ const existing = cache.get(h)
144
+ if (existing !== undefined) {
145
+ existing.fn = fn as () => Promise<unknown> // re-bind the latest closure
146
+ return existing.handle as QueryHandle<T>
147
+ }
148
+ const entry: Entry = {
149
+ key,
150
+ fn: fn as () => Promise<unknown>,
151
+ state: PENDING,
152
+ promise: undefined,
153
+ promiseGen: 0,
154
+ generation: 0,
155
+ invalidated: false,
156
+ subscribers: 0,
157
+ gcAt: undefined,
158
+ listeners: new Set(),
159
+ // `handle` is assigned just below; the cast lets the entry reference itself.
160
+ handle: undefined as unknown as QueryHandle,
161
+ }
162
+ const emit = (): void => {
163
+ // Iterate the live set — no per-emit copy (the common case is 0–1 subscribers; the spread
164
+ // allocated an array on every state transition ∝ subscriber count). Contract: a listener must
165
+ // not synchronously subscribe/unsubscribe during notification — `useSyncExternalStore`/signal/
166
+ // store bindings don't (un/subscribe happens in a later effect/cleanup tick).
167
+ if (entry.listeners.size === 0) return
168
+ for (const l of entry.listeners) l()
169
+ }
170
+ const setState = (next: QueryState): void => {
171
+ entry.state = Object.freeze(next)
172
+ emit()
173
+ }
174
+ const isStale = (): boolean => entry.invalidated || now() - entry.state.updatedAt >= staleTime
175
+
176
+ const run = (force: boolean): Promise<unknown> => {
177
+ // Dedup — but join the in-flight fetch ONLY when it belongs to the current generation. An
178
+ // `invalidateQueries` bumps `generation`, so a fetch that started *before* the invalidation is
179
+ // superseded and must NOT satisfy this call (its data is pre-mutation); fall through and kick a
180
+ // fresh fetch instead of returning the stale in-flight one.
181
+ if (entry.promise !== undefined && entry.promiseGen === entry.generation) return entry.promise
182
+ if (!force && entry.state.status === "success" && !isStale())
183
+ return Promise.resolve(entry.state.data) // fresh cache hit — no fn call
184
+ const gen = entry.generation
185
+ setState({ ...entry.state, isFetching: true }) // show the background/initial fetch
186
+ const promise = (async () => {
187
+ try {
188
+ const data = await entry.fn()
189
+ // A newer generation (a later invalidate/refetch) owns the entry now — discard this result
190
+ // rather than publishing pre-mutation data as fresh or clearing `invalidated`. The fetch
191
+ // that superseded this one publishes the post-mutation data.
192
+ if (gen !== entry.generation) return data
193
+ entry.invalidated = false
194
+ setState({
195
+ status: "success",
196
+ data,
197
+ error: undefined,
198
+ isFetching: false,
199
+ updatedAt: now(),
200
+ })
201
+ return data
202
+ } catch (error) {
203
+ if (gen === entry.generation)
204
+ setState({ ...entry.state, status: "error", error, isFetching: false })
205
+ throw error
206
+ } finally {
207
+ // Clear the slot only if no newer fetch took ownership. A later generation's fetch
208
+ // overwrites `entry.promiseGen`, so `promiseGen === gen` means we're still the current
209
+ // in-flight fetch; if it differs, the fetch that superseded us owns `entry.promise` now.
210
+ if (entry.promiseGen === gen) entry.promise = undefined
211
+ }
212
+ })()
213
+ entry.promise = promise
214
+ entry.promiseGen = gen
215
+ return promise
216
+ }
217
+
218
+ entry.handle = {
219
+ snapshot: () => entry.state,
220
+ subscribe: (listener: () => void) => {
221
+ entry.subscribers++
222
+ entry.gcAt = undefined // in use again — cancel any pending eviction
223
+ entry.listeners.add(listener)
224
+ return () => {
225
+ entry.listeners.delete(listener)
226
+ entry.subscribers--
227
+ if (entry.subscribers === 0) entry.gcAt = now() + gcTime
228
+ }
229
+ },
230
+ fetch: () => run(false) as Promise<T>,
231
+ refetch: () => run(true) as Promise<T>,
232
+ }
233
+ cache.set(h, entry)
234
+ return entry.handle as QueryHandle<T>
235
+ }
236
+
237
+ const invalidateQueries = (keyOrPrefix: unknown): void => {
238
+ for (const entry of cache.values()) {
239
+ if (!keyMatchesPrefix(entry.key, keyOrPrefix)) continue
240
+ entry.invalidated = true // next fetch refetches even within staleTime
241
+ // Supersede any in-flight fetch begun before this point — its result is pre-mutation and must
242
+ // not resolve as "fresh" or clear `invalidated`. The refetch below (or the next `fetch()`)
243
+ // starts a new generation that publishes the post-mutation data.
244
+ entry.generation++
245
+ if (entry.subscribers > 0) void entry.handle.refetch().catch(() => {}) // refresh mounted now (best-effort)
246
+ }
247
+ }
248
+
249
+ return { query, invalidateQueries }
250
+ }