@granularjs/core 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/README.md +576 -0
  2. package/dist/granular.min.js +2 -0
  3. package/dist/granular.min.js.map +7 -0
  4. package/package.json +54 -0
  5. package/src/core/bootstrap.js +63 -0
  6. package/src/core/collections/observable-array.js +204 -0
  7. package/src/core/component/function-component.js +82 -0
  8. package/src/core/context.js +172 -0
  9. package/src/core/dom/dom.js +25 -0
  10. package/src/core/dom/element.js +725 -0
  11. package/src/core/dom/error-boundary.js +111 -0
  12. package/src/core/dom/input-format.js +82 -0
  13. package/src/core/dom/list.js +185 -0
  14. package/src/core/dom/portal.js +57 -0
  15. package/src/core/dom/tags.js +182 -0
  16. package/src/core/dom/virtual-list.js +242 -0
  17. package/src/core/dom/when.js +138 -0
  18. package/src/core/events/event-hub.js +97 -0
  19. package/src/core/forms/form.js +127 -0
  20. package/src/core/internal/symbols.js +5 -0
  21. package/src/core/network/websocket.js +165 -0
  22. package/src/core/query/query-client.js +529 -0
  23. package/src/core/reactivity/after-flush.js +20 -0
  24. package/src/core/reactivity/computed.js +51 -0
  25. package/src/core/reactivity/concat.js +89 -0
  26. package/src/core/reactivity/dirty-host.js +162 -0
  27. package/src/core/reactivity/observe.js +421 -0
  28. package/src/core/reactivity/persist.js +180 -0
  29. package/src/core/reactivity/resolve.js +8 -0
  30. package/src/core/reactivity/signal.js +97 -0
  31. package/src/core/reactivity/state.js +294 -0
  32. package/src/core/renderable/render-string.js +51 -0
  33. package/src/core/renderable/renderable.js +21 -0
  34. package/src/core/renderable/renderer.js +66 -0
  35. package/src/core/router/router.js +865 -0
  36. package/src/core/runtime.js +28 -0
  37. package/src/index.js +42 -0
  38. package/types/core/bootstrap.d.ts +11 -0
  39. package/types/core/collections/observable-array.d.ts +25 -0
  40. package/types/core/component/function-component.d.ts +14 -0
  41. package/types/core/context.d.ts +29 -0
  42. package/types/core/dom/dom.d.ts +13 -0
  43. package/types/core/dom/element.d.ts +10 -0
  44. package/types/core/dom/error-boundary.d.ts +8 -0
  45. package/types/core/dom/input-format.d.ts +6 -0
  46. package/types/core/dom/list.d.ts +8 -0
  47. package/types/core/dom/portal.d.ts +8 -0
  48. package/types/core/dom/tags.d.ts +114 -0
  49. package/types/core/dom/virtual-list.d.ts +8 -0
  50. package/types/core/dom/when.d.ts +13 -0
  51. package/types/core/events/event-hub.d.ts +48 -0
  52. package/types/core/forms/form.d.ts +9 -0
  53. package/types/core/internal/symbols.d.ts +4 -0
  54. package/types/core/network/websocket.d.ts +18 -0
  55. package/types/core/query/query-client.d.ts +73 -0
  56. package/types/core/reactivity/after-flush.d.ts +4 -0
  57. package/types/core/reactivity/computed.d.ts +1 -0
  58. package/types/core/reactivity/concat.d.ts +1 -0
  59. package/types/core/reactivity/dirty-host.d.ts +42 -0
  60. package/types/core/reactivity/observe.d.ts +10 -0
  61. package/types/core/reactivity/persist.d.ts +1 -0
  62. package/types/core/reactivity/resolve.d.ts +1 -0
  63. package/types/core/reactivity/signal.d.ts +11 -0
  64. package/types/core/reactivity/state.d.ts +14 -0
  65. package/types/core/renderable/render-string.d.ts +2 -0
  66. package/types/core/renderable/renderable.d.ts +15 -0
  67. package/types/core/renderable/renderer.d.ts +38 -0
  68. package/types/core/router/router.d.ts +57 -0
  69. package/types/core/runtime.d.ts +26 -0
  70. package/types/index.d.ts +2 -0
@@ -0,0 +1,165 @@
1
+ import { EventHub } from '../events/event-hub.js';
2
+ import { state } from '../reactivity/state.js';
3
+
4
+ function defaultSerialize(value) {
5
+ if (typeof value === 'string') return value;
6
+ if (value instanceof ArrayBuffer) return value;
7
+ if (value instanceof Blob) return value;
8
+ return JSON.stringify(value);
9
+ }
10
+
11
+ function defaultParse(value) {
12
+ return value;
13
+ }
14
+
15
+ function defaultDelay(attempt) {
16
+ return Math.min(1000 * Math.pow(2, Math.max(0, attempt - 1)), 10_000);
17
+ }
18
+
19
+ export class WebSocketClient {
20
+ #url;
21
+ #protocols;
22
+ #ws = null;
23
+ #events = new EventHub();
24
+ #state;
25
+ #manualClose = false;
26
+ #reconnectTimer = null;
27
+ #serialize;
28
+ #parse;
29
+ #reconnect;
30
+ #maxRetries;
31
+ #delay;
32
+
33
+ constructor(options = {}) {
34
+ this.#url = options.url;
35
+ this.#protocols = options.protocols;
36
+ this.#serialize = typeof options.serialize === 'function' ? options.serialize : defaultSerialize;
37
+ this.#parse = typeof options.parse === 'function' ? options.parse : defaultParse;
38
+ this.#reconnect = options.reconnect ?? true;
39
+ this.#maxRetries = options.maxRetries ?? Infinity;
40
+ this.#delay = typeof options.reconnectDelay === 'function' ? options.reconnectDelay : defaultDelay;
41
+
42
+ this.#state = state({
43
+ status: 'idle',
44
+ connected: false,
45
+ reconnecting: false,
46
+ attempts: 0,
47
+ lastMessage: null,
48
+ lastError: null,
49
+ });
50
+
51
+ if (options.autoConnect ?? true) {
52
+ this.connect();
53
+ }
54
+ }
55
+
56
+ state() {
57
+ return this.#state;
58
+ }
59
+
60
+ before() {
61
+ return this.#events.phase('before');
62
+ }
63
+
64
+ after() {
65
+ return this.#events.phase('after');
66
+ }
67
+
68
+ setUrl(next) {
69
+ this.#url = next;
70
+ }
71
+
72
+ connect() {
73
+ if (!this.#url) throw new Error('WebSocketClient.connect: url is required');
74
+ if (this.#ws && (this.#ws.readyState === WebSocket.OPEN || this.#ws.readyState === WebSocket.CONNECTING)) {
75
+ return;
76
+ }
77
+ this.#clearReconnect();
78
+ this.#manualClose = false;
79
+ this.#state.set().status = 'connecting';
80
+ this.#state.set().reconnecting = false;
81
+
82
+ const ws = new WebSocket(this.#url, this.#protocols);
83
+ this.#ws = ws;
84
+
85
+ ws.addEventListener('open', (event) => {
86
+ this.#state.set().status = 'open';
87
+ this.#state.set().connected = true;
88
+ this.#state.set().reconnecting = false;
89
+ this.#state.set().attempts = 0;
90
+ this.#events.emitAfter('open', { event }, { client: this });
91
+ });
92
+
93
+ ws.addEventListener('message', (event) => {
94
+ let data = event.data;
95
+ try {
96
+ data = this.#parse(data);
97
+ } catch (err) {
98
+ this.#state.set().lastError = err;
99
+ this.#events.emitAfter('error', { error: err }, { client: this });
100
+ return;
101
+ }
102
+ const payload = { data, raw: event.data };
103
+ const ok = this.#events.emitBefore('message', payload, { client: this });
104
+ if (!ok) return;
105
+ this.#state.set().lastMessage = data;
106
+ this.#events.emitAfter('message', payload, { client: this });
107
+ });
108
+
109
+ ws.addEventListener('error', (event) => {
110
+ this.#state.set().lastError = event;
111
+ this.#events.emitAfter('error', { error: event }, { client: this });
112
+ });
113
+
114
+ ws.addEventListener('close', (event) => {
115
+ this.#state.set().status = 'closed';
116
+ this.#state.set().connected = false;
117
+ this.#events.emitAfter('close', { event }, { client: this });
118
+ if (this.#manualClose) return;
119
+ if (!this.#reconnect) return;
120
+ this.#scheduleReconnect();
121
+ });
122
+ }
123
+
124
+ send(value) {
125
+ if (!this.#ws || this.#ws.readyState !== WebSocket.OPEN) {
126
+ throw new Error('WebSocketClient.send: socket is not open');
127
+ }
128
+ const payload = { data: value };
129
+ const ok = this.#events.emitBefore('send', payload, { client: this });
130
+ if (!ok) return;
131
+ const raw = this.#serialize(value);
132
+ this.#ws.send(raw);
133
+ this.#events.emitAfter('send', { data: value, raw }, { client: this });
134
+ }
135
+
136
+ close(code, reason) {
137
+ this.#manualClose = true;
138
+ this.#clearReconnect();
139
+ this.#ws?.close(code, reason);
140
+ }
141
+
142
+ #scheduleReconnect() {
143
+ if (this.#reconnectTimer) return;
144
+ const attempts = this.#state.get().attempts + 1;
145
+ if (attempts > this.#maxRetries) return;
146
+ this.#state.set().attempts = attempts;
147
+ this.#state.set().reconnecting = true;
148
+ const delay = Math.max(0, this.#delay(attempts));
149
+ this.#events.emitAfter('reconnect', { attempt: attempts, delay }, { client: this });
150
+ this.#reconnectTimer = setTimeout(() => {
151
+ this.#reconnectTimer = null;
152
+ this.connect();
153
+ }, delay);
154
+ }
155
+
156
+ #clearReconnect() {
157
+ if (!this.#reconnectTimer) return;
158
+ clearTimeout(this.#reconnectTimer);
159
+ this.#reconnectTimer = null;
160
+ }
161
+ }
162
+
163
+ export function createWebSocket(options) {
164
+ return new WebSocketClient(options);
165
+ }
@@ -0,0 +1,529 @@
1
+ import { state } from '../reactivity/state.js';
2
+
3
+ /**
4
+ * @typedef {string | number | boolean | null} QueryKeyAtom
5
+ */
6
+ /**
7
+ * @typedef {QueryKeyAtom | QueryKeyAtom[]} QueryKey
8
+ */
9
+
10
+ /**
11
+ * @typedef {'idle'|'loading'|'success'|'error'} QueryStatus
12
+ */
13
+
14
+ /**
15
+ * @typedef {Object} QueryState
16
+ * @property {any} data
17
+ * @property {any} error
18
+ * @property {QueryStatus} status
19
+ * @property {boolean} fetching
20
+ * @property {number|null} updatedAt
21
+ * @property {number|null} errorAt
22
+ * @property {boolean} invalidated
23
+ */
24
+
25
+ /**
26
+ * @typedef {Object} QueryContext
27
+ * @property {QueryKey} key
28
+ * @property {AbortSignal} signal
29
+ */
30
+
31
+ /**
32
+ * @typedef {Object} QueryOptions
33
+ * @property {QueryKey} key
34
+ * @property {(ctx: QueryContext) => Promise<any>} fetcher
35
+ * @property {number} [staleTime] ms
36
+ * @property {number} [cacheTime] ms
37
+ * @property {boolean} [refetchOnFocus]
38
+ * @property {boolean} [refetchOnReconnect]
39
+ * @property {number} [retry]
40
+ * @property {(attempt: number) => number} [retryDelay]
41
+ * @property {boolean} [dedupe]
42
+ * @property {boolean} [refetchOnInvalidate]
43
+ */
44
+
45
+ function defaultRetryDelay(attempt) {
46
+ // 250ms, 500ms, 1000ms, 2000ms...
47
+ return 250 * Math.pow(2, Math.max(0, attempt - 1));
48
+ }
49
+
50
+ function normalizeKey(key) {
51
+ if (Array.isArray(key)) return JSON.stringify(key);
52
+ return JSON.stringify([key]);
53
+ }
54
+
55
+ function now() {
56
+ return Date.now();
57
+ }
58
+
59
+ function buildQuery(query) {
60
+ if (!query || typeof query !== 'object') return '';
61
+ const params = new URLSearchParams();
62
+ for (const [k, v] of Object.entries(query)) {
63
+ if (Array.isArray(v)) {
64
+ for (const item of v) params.append(k, String(item));
65
+ } else if (v != null) {
66
+ params.set(k, String(v));
67
+ }
68
+ }
69
+ const str = params.toString();
70
+ return str ? `?${str}` : '';
71
+ }
72
+
73
+ function interpolatePath(path, params) {
74
+ if (!params) return path;
75
+ return String(path).replace(/:([A-Za-z0-9_]+)/g, (match, key) => {
76
+ if (!Object.prototype.hasOwnProperty.call(params, key)) {
77
+ throw new Error(`Missing route param "${key}" for "${path}"`);
78
+ }
79
+ return encodeURIComponent(String(params[key]));
80
+ });
81
+ }
82
+
83
+ function isPlainObject(value) {
84
+ if (!value || typeof value !== 'object') return false;
85
+ if (Array.isArray(value)) return false;
86
+ if (value instanceof FormData) return false;
87
+ if (value instanceof URLSearchParams) return false;
88
+ if (value instanceof Blob) return false;
89
+ if (value instanceof ArrayBuffer) return false;
90
+ return Object.prototype.toString.call(value) === '[object Object]';
91
+ }
92
+
93
+ async function parseResponse(res) {
94
+ const type = res.headers.get('content-type') || '';
95
+ if (type.includes('application/json')) return await res.json();
96
+ return await res.text();
97
+ }
98
+
99
+ function compose(middlewares, core) {
100
+ return async (ctx) => {
101
+ let index = -1;
102
+ const dispatch = async (i) => {
103
+ if (i <= index) throw new Error('Middleware next() called multiple times');
104
+ index = i;
105
+ const fn = middlewares[i] || core;
106
+ if (!fn) return undefined;
107
+ if (fn === core) return await core(ctx);
108
+ return await fn(ctx, () => dispatch(i + 1));
109
+ };
110
+ return await dispatch(0);
111
+ };
112
+ }
113
+
114
+ function isStale(query) {
115
+ if (query.invalidated) return true;
116
+ if (query.updatedAt == null) return true;
117
+ const st = query.staleTime ?? 0;
118
+ return st === 0 ? true : now() - query.updatedAt > st;
119
+ }
120
+
121
+ class Query {
122
+ key;
123
+ fetcher;
124
+
125
+ staleTime;
126
+ cacheTime;
127
+ refetchOnFocus;
128
+ refetchOnReconnect;
129
+ retry;
130
+ retryDelay;
131
+ dedupe;
132
+ refetchOnInvalidate;
133
+
134
+ #state = null;
135
+
136
+ #inFlight = null;
137
+ #abort = null;
138
+ #gcTimer = null;
139
+ #refCount = 0;
140
+ #onGarbageCollect = null;
141
+
142
+ constructor(options) {
143
+ this.key = options.key;
144
+ this.fetcher = options.fetcher;
145
+
146
+ this.staleTime = options.staleTime ?? 0;
147
+ this.cacheTime = options.cacheTime ?? 5 * 60_000;
148
+ this.refetchOnFocus = options.refetchOnFocus ?? true;
149
+ this.refetchOnReconnect = options.refetchOnReconnect ?? true;
150
+ this.retry = options.retry ?? 0;
151
+ this.retryDelay = options.retryDelay ?? defaultRetryDelay;
152
+ this.dedupe = options.dedupe ?? true;
153
+ this.refetchOnInvalidate = options.refetchOnInvalidate ?? true;
154
+
155
+ this.#state = state({
156
+ data: undefined,
157
+ error: null,
158
+ status: /** @type {QueryStatus} */ ('idle'),
159
+ fetching: false,
160
+ updatedAt: null,
161
+ errorAt: null,
162
+ invalidated: false,
163
+ });
164
+ }
165
+
166
+ get data() {
167
+ return this.#state.get().data;
168
+ }
169
+ get error() {
170
+ return this.#state.get().error;
171
+ }
172
+ get status() {
173
+ return this.#state.get().status;
174
+ }
175
+ get fetching() {
176
+ return this.#state.get().fetching;
177
+ }
178
+ get updatedAt() {
179
+ return this.#state.get().updatedAt;
180
+ }
181
+ get errorAt() {
182
+ return this.#state.get().errorAt;
183
+ }
184
+ get invalidated() {
185
+ return this.#state.get().invalidated;
186
+ }
187
+
188
+ /**
189
+ * @returns {boolean}
190
+ */
191
+ get isStale() {
192
+ return isStale(this);
193
+ }
194
+
195
+ /**
196
+ * Starts a fetch if needed.
197
+ * - If `dedupe` is true and a request is already running, returns the existing promise.
198
+ * - If data exists, keeps status as success but flips `fetching`.
199
+ *
200
+ * @returns {Promise<any>}
201
+ */
202
+ async refetch() {
203
+ if (this.dedupe && this.#inFlight) return this.#inFlight;
204
+ return await this.#runFetch({ force: true });
205
+ }
206
+
207
+ /**
208
+ * Marks query as invalidated (stale).
209
+ */
210
+ invalidate() {
211
+ this.setState({ invalidated: true });
212
+ if (this.refetchOnInvalidate) this.refetch();
213
+ }
214
+
215
+ /**
216
+ * Cancels an in-flight request.
217
+ */
218
+ cancel() {
219
+ this.#abort?.abort();
220
+ }
221
+
222
+ /**
223
+ * Internal: optionally triggers fetch depending on stale-ness.
224
+ * @returns {Promise<any>|null}
225
+ */
226
+ ensure() {
227
+ if (!this.isStale) return null;
228
+ return this.#runFetch({ force: false });
229
+ }
230
+
231
+ /**
232
+ * @override
233
+ */
234
+ subscribe(selectorOrListener, listener, equalityFn) {
235
+ this.#refCount++;
236
+ this.#clearGc();
237
+ const unsub = this.#subscribe(selectorOrListener, listener, equalityFn);
238
+ return () => {
239
+ unsub();
240
+ this.#refCount = Math.max(0, this.#refCount - 1);
241
+ this.#scheduleGc();
242
+ };
243
+ }
244
+
245
+ #scheduleGc() {
246
+ if (this.#refCount > 0) return;
247
+ const ct = this.cacheTime ?? 0;
248
+ if (ct <= 0) return;
249
+ this.#gcTimer = setTimeout(() => {
250
+ if (this.#refCount > 0) return;
251
+ this.cancel();
252
+ this.#onGarbageCollect?.();
253
+ }, ct);
254
+ }
255
+
256
+ #clearGc() {
257
+ if (!this.#gcTimer) return;
258
+ clearTimeout(this.#gcTimer);
259
+ this.#gcTimer = null;
260
+ }
261
+
262
+ /**
263
+ * Internal: set by QueryClient to evict cached queries.
264
+ * @param {() => void} fn
265
+ */
266
+ setGcHandler(fn) {
267
+ this.#onGarbageCollect = fn;
268
+ }
269
+
270
+ async #runFetch({ force }) {
271
+ if (!force && !this.isStale) return this.data;
272
+ if (this.dedupe && this.#inFlight) return this.#inFlight;
273
+
274
+ const controller = new AbortController();
275
+ this.#abort = controller;
276
+ const ctx = { key: this.key, signal: controller.signal };
277
+
278
+ const hadData = this.updatedAt != null;
279
+ this.setState({
280
+ fetching: true,
281
+ status: hadData ? this.status : 'loading',
282
+ error: null,
283
+ });
284
+
285
+ const run = async () => {
286
+ const maxRetry = Math.max(0, this.retry ?? 0);
287
+ for (let attempt = 1; attempt <= maxRetry + 1; attempt++) {
288
+ try {
289
+ const data = await this.fetcher(ctx);
290
+ this.setState({
291
+ data,
292
+ error: null,
293
+ status: 'success',
294
+ fetching: false,
295
+ updatedAt: now(),
296
+ errorAt: null,
297
+ invalidated: false,
298
+ });
299
+ return data;
300
+ } catch (err) {
301
+ if (controller.signal.aborted) {
302
+ this.setState({ fetching: false });
303
+ throw err;
304
+ }
305
+ if (attempt > maxRetry) {
306
+ this.setState({
307
+ error: err,
308
+ status: 'error',
309
+ fetching: false,
310
+ errorAt: now(),
311
+ });
312
+ throw err;
313
+ }
314
+ const delay = this.retryDelay?.(attempt) ?? defaultRetryDelay(attempt);
315
+ await new Promise((r) => setTimeout(r, delay));
316
+ }
317
+ }
318
+ return undefined;
319
+ };
320
+
321
+ this.#inFlight = run().finally(() => {
322
+ this.#inFlight = null;
323
+ });
324
+ return this.#inFlight;
325
+ }
326
+
327
+ state() {
328
+ return this.#state;
329
+ }
330
+
331
+ getState() {
332
+ return this.#state.get();
333
+ }
334
+
335
+ setState(partial) {
336
+ const current = this.#state.get();
337
+ this.#state.set({ ...current, ...(partial || {}) });
338
+ }
339
+
340
+ #subscribe(selectorOrListener, listener, equalityFn) {
341
+ if (typeof selectorOrListener === 'function' && listener === undefined) {
342
+ const l = selectorOrListener;
343
+ return this.#state.subscribe((next, prev) => l(next, prev));
344
+ }
345
+ const selector = selectorOrListener;
346
+ if (typeof selector !== 'function' || typeof listener !== 'function') {
347
+ throw new Error('subscribe(selector, listener, equalityFn?): invalid arguments');
348
+ }
349
+ const eq = typeof equalityFn === 'function' ? equalityFn : Object.is;
350
+ let prevSelected = selector(this.#state.get());
351
+ return this.#state.subscribe((next) => {
352
+ const nextSelected = selector(next);
353
+ if (eq(prevSelected, nextSelected)) return;
354
+ const p = prevSelected;
355
+ prevSelected = nextSelected;
356
+ listener(nextSelected, p);
357
+ });
358
+ }
359
+ }
360
+
361
+ /**
362
+ * Query manager with caching and refetch orchestration.
363
+ */
364
+ export class QueryClient {
365
+ #queries = new Map(); // keyString -> Query
366
+ #listening = false;
367
+ #middlewares = [];
368
+
369
+ constructor() {
370
+ this.#ensureWindowListeners();
371
+ }
372
+
373
+ /**
374
+ * Gets (or creates) a query instance for the given key.
375
+ *
376
+ * @param {QueryOptions} options
377
+ * @returns {Store & QueryState & { refetch(): Promise<any>, invalidate(): void, cancel(): void, ensure(): (Promise<any>|null), isStale: boolean }}
378
+ */
379
+ query(options) {
380
+ const keyStr = normalizeKey(options.key);
381
+ const existing = this.#queries.get(keyStr);
382
+ if (existing) {
383
+ existing.ensure();
384
+ return existing;
385
+ }
386
+
387
+ const q = new Query(options);
388
+ q.setGcHandler(() => this.#queries.delete(keyStr));
389
+ this.#queries.set(keyStr, q);
390
+ q.ensure();
391
+ return q;
392
+ }
393
+
394
+ use(middleware) {
395
+ if (typeof middleware !== 'function') {
396
+ throw new Error('QueryClient.use(middleware): middleware must be a function');
397
+ }
398
+ this.#middlewares.push(middleware);
399
+ return () => {
400
+ const index = this.#middlewares.indexOf(middleware);
401
+ if (index >= 0) this.#middlewares.splice(index, 1);
402
+ };
403
+ }
404
+
405
+ service(config = {}) {
406
+ const baseUrl = config.baseUrl || '';
407
+ const serviceMiddlewares = Array.isArray(config.middlewares) ? config.middlewares.slice() : [];
408
+ const endpoints = config.endpoints || {};
409
+ const client = this;
410
+
411
+ const request = async (endpoint, input = {}) => {
412
+ if (!endpoint || typeof endpoint !== 'object') {
413
+ throw new Error('service.request(endpoint, params, options): invalid endpoint');
414
+ }
415
+ const params = input.params || {};
416
+ const method = (endpoint.method || 'GET').toUpperCase();
417
+ const path = interpolatePath(endpoint.path || '', params);
418
+ const query = input.query || endpoint.query || null;
419
+ const body = input.body !== undefined ? input.body : undefined;
420
+ const headers = { ...(endpoint.headers || {}), ...(input.headers || {}) };
421
+ const map = input.map || endpoint.map || null;
422
+ const middlewares = [
423
+ ...client.#middlewares,
424
+ ...serviceMiddlewares,
425
+ ...(endpoint.middlewares || []),
426
+ ...(input.middlewares || []),
427
+ ];
428
+
429
+ const url = `${baseUrl}${path}${buildQuery(query)}`;
430
+ const core = async (ctx) => {
431
+ const init = { method: ctx.method, headers: ctx.headers, signal: ctx.signal };
432
+ if (ctx.body !== undefined && ctx.method !== 'GET' && ctx.method !== 'HEAD') {
433
+ if (isPlainObject(ctx.body)) {
434
+ if (!init.headers['Content-Type']) init.headers['Content-Type'] = 'application/json';
435
+ init.body = JSON.stringify(ctx.body);
436
+ } else {
437
+ init.body = ctx.body;
438
+ }
439
+ }
440
+ const res = await fetch(ctx.url, init);
441
+ const data = await parseResponse(res);
442
+ if (!res.ok) {
443
+ const err = new Error(`Request failed: ${res.status}`);
444
+ err.status = res.status;
445
+ err.data = data;
446
+ throw err;
447
+ }
448
+ return data;
449
+ };
450
+
451
+ const ctx = {
452
+ method,
453
+ url,
454
+ path,
455
+ baseUrl,
456
+ headers,
457
+ query,
458
+ body,
459
+ params,
460
+ endpoint,
461
+ signal: input.signal,
462
+ };
463
+
464
+ const run = compose(middlewares, core);
465
+ const data = await run(ctx);
466
+ return typeof map === 'function' ? map(data) : data;
467
+ };
468
+
469
+ const api = { request };
470
+ for (const [name, def] of Object.entries(endpoints)) {
471
+ api[name] = (input = {}) => request(def, input);
472
+ }
473
+ return api;
474
+ }
475
+
476
+ /**
477
+ * Marks a query as invalidated.
478
+ * @param {QueryKey} key
479
+ */
480
+ invalidate(key) {
481
+ const q = this.#queries.get(normalizeKey(key));
482
+ if (!q) return;
483
+ q.invalidate();
484
+ }
485
+
486
+ /**
487
+ * Refetches a query immediately.
488
+ * @param {QueryKey} key
489
+ * @returns {Promise<any>|null}
490
+ */
491
+ refetch(key) {
492
+ const q = this.#queries.get(normalizeKey(key));
493
+ if (!q) return null;
494
+ return q.refetch();
495
+ }
496
+
497
+ /**
498
+ * Removes a query from cache (cancels in-flight).
499
+ * @param {QueryKey} key
500
+ */
501
+ remove(key) {
502
+ const keyStr = normalizeKey(key);
503
+ const q = this.#queries.get(keyStr);
504
+ if (!q) return;
505
+ q.cancel();
506
+ this.#queries.delete(keyStr);
507
+ }
508
+
509
+ #ensureWindowListeners() {
510
+ if (this.#listening) return;
511
+ if (typeof window === 'undefined') return;
512
+ this.#listening = true;
513
+
514
+ window.addEventListener('focus', () => {
515
+ for (const q of this.#queries.values()) {
516
+ if (!q.refetchOnFocus) continue;
517
+ if (q.isStale) q.refetch();
518
+ }
519
+ });
520
+
521
+ window.addEventListener('online', () => {
522
+ for (const q of this.#queries.values()) {
523
+ if (!q.refetchOnReconnect) continue;
524
+ if (q.isStale) q.refetch();
525
+ }
526
+ });
527
+ }
528
+ }
529
+
@@ -0,0 +1,20 @@
1
+ let scheduled = false;
2
+ const watchers = new Set();
3
+
4
+ function flush() {
5
+ scheduled = false;
6
+ for (const run of watchers) run();
7
+ }
8
+
9
+ export const AfterFlush = {
10
+ schedule() {
11
+ if (scheduled) return;
12
+ scheduled = true;
13
+ queueMicrotask(flush);
14
+ },
15
+ add(run) {
16
+ watchers.add(run);
17
+ return () => watchers.delete(run);
18
+ },
19
+ };
20
+