@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/prerender.ts
ADDED
|
@@ -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
|
+
}
|