@xoong/data 0.0.0-stage → 0.0.34

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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Duong Tran Xuan
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,34 @@
1
- # Temporary Holding Version
1
+ # @xoong/data
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The data layer of xoong: a typed client generated from your OpenAPI document, a query cache keyed by operation and parameters, a shared entity store, `<xo-view>` and live updates over server-sent events.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install @xoong/data
9
+ ```
10
+
11
+ ## Use
12
+
13
+ ```js
14
+ import { configure } from '@xoong/data';
15
+ import '@xoong/data/view';
16
+ import operations from './.xoong/operations.json' with { type: 'json' }; // written by `npx xoong sync openapi.json`
17
+
18
+ configure({ api: operations, baseUrl: '/api' });
19
+ ```
20
+ ```html
21
+ <xo-view get="listPeople">
22
+ <p xo-if="$loading">Loading…</p>
23
+ <ul><li xo-each="items">{name}</li></ul>
24
+ </xo-view>
25
+ ```
26
+
27
+ ## More
28
+
29
+ * Documentation and demos: https://github.com/fpt-duongtx/xoong#readme
30
+ * Source: https://github.com/fpt-duongtx/xoong/tree/main/packages/data
31
+ * All packages share one version number and are released together: https://github.com/fpt-duongtx/xoong/releases
32
+ * xoong is a working prototype (0.0.x): the HTML syntax is still Draft.
33
+
34
+ MIT licence.
package/package.json CHANGED
@@ -1,6 +1,45 @@
1
1
  {
2
2
  "name": "@xoong/data",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.0.34",
4
+ "description": "xoong data layer: OpenAPI-driven client, auto cache, entity store, derived views, live updates. Depends only on @xoong/core.",
5
+ "type": "module",
6
+ "main": "src/index.js",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./src/index.d.ts",
10
+ "default": "./src/index.js"
11
+ },
12
+ "./view": "./src/view.js",
13
+ "./format": "./src/format.js"
14
+ },
15
+ "sideEffects": false,
16
+ "license": "MIT",
17
+ "dependencies": {
18
+ "@xoong/core": "0.0.34"
19
+ },
20
+ "files": [
21
+ "src"
22
+ ],
23
+ "keywords": [
24
+ "xoong",
25
+ "web-framework",
26
+ "openapi",
27
+ "custom-elements"
28
+ ],
29
+ "author": "Duong Tran Xuan <duongtx1505@icloud.com>",
30
+ "repository": {
31
+ "type": "git",
32
+ "url": "git+https://github.com/fpt-duongtx/xoong.git",
33
+ "directory": "packages/data"
34
+ },
35
+ "bugs": {
36
+ "url": "https://github.com/fpt-duongtx/xoong/issues"
37
+ },
38
+ "homepage": "https://github.com/fpt-duongtx/xoong#readme",
39
+ "publishConfig": {
40
+ "access": "public"
41
+ },
42
+ "engines": {
43
+ "node": ">=22.12"
44
+ }
45
+ }
package/src/client.js ADDED
@@ -0,0 +1,435 @@
1
+ // @ts-check
2
+ import { signal, computed, batch, disposeComputed } from '@xoong/core/signals';
3
+ import { nearest } from '@xoong/core/closest';
4
+ import { createStore, normalize } from './store.js';
5
+ import { fillPath, pathsOverlap } from './invalidate.js';
6
+
7
+ /** Normalized API error. Built from application/problem+json when available. */
8
+ export class XoongError extends Error {
9
+ /** @param {{status?:number,title?:string,detail?:string,code?:string,retryAfterS?:number,problem?:any,operation?:string,cause?:any,fields?:Record<string,string>}} init */
10
+ constructor({ status = 0, title, detail, code, retryAfterS, problem, operation, cause, fields }) {
11
+ super(detail || title || (status ? `HTTP ${status}` : 'Network error'));
12
+ this.name = 'XoongError';
13
+ this.status = status;
14
+ this.title = title;
15
+ this.detail = detail;
16
+ this.code = code;
17
+ this.retryAfterS = retryAfterS;
18
+ this.problem = problem;
19
+ this.operation = operation;
20
+ /** Per-field messages ({ email: 'is not valid' }) when the server reports them; empty otherwise. */
21
+ this.fields = fields ?? {};
22
+ if (cause) this.cause = cause;
23
+ }
24
+ }
25
+
26
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
27
+
28
+ /**
29
+ * Build an error from a failed response. Understands RFC 9457 problem+json,
30
+ * `{ error: { code, message, retry_after_s, fields } }` envelopes and FastAPI-style
31
+ * `{ detail: [{ loc, msg }] }` validation errors. Field messages end up in `error.fields`.
32
+ */
33
+ function fieldErrors(body, env) {
34
+ const out = {};
35
+ const add = (name, msg) => { if (name && msg && !(name in out)) out[String(name)] = String(msg); };
36
+ if (env?.fields && typeof env.fields === 'object') for (const [k, v] of Object.entries(env.fields)) add(k, v);
37
+ if (Array.isArray(body?.detail)) for (const d of body.detail) if (Array.isArray(d?.loc)) add(d.loc.filter((x) => x !== 'body').join('.'), d.msg);
38
+ if (Array.isArray(body?.errors)) for (const e of body.errors) add(e?.field ?? String(e?.pointer ?? '').replace(/^#?\//, '').replace(/\//g, '.'), e?.detail ?? e?.message);
39
+ return out;
40
+ }
41
+
42
+ function errorFromBody(status, statusText, body, operation) {
43
+ const env = body?.error && typeof body.error === 'object' ? body.error : undefined;
44
+ const detail = Array.isArray(body?.detail)
45
+ ? body.detail.map((d) => d?.msg ?? String(d)).join('; ')
46
+ : (typeof body?.detail === 'string' ? body.detail : env?.message ?? body?.message);
47
+ return new XoongError({
48
+ status, operation, problem: body,
49
+ title: body?.title ?? env?.code ?? statusText,
50
+ detail,
51
+ code: env?.code ?? (typeof body?.code === 'string' ? body.code : undefined),
52
+ retryAfterS: env?.retry_after_s ?? undefined,
53
+ fields: fieldErrors(body, env),
54
+ });
55
+ }
56
+
57
+ /** Stable key part for params: sorted keys, undefined/null dropped. */
58
+ function canonical(params = {}) {
59
+ const out = {};
60
+ for (const k of Object.keys(params).sort()) if (params[k] != null) out[k] = Array.isArray(params[k]) ? params[k].map(String) : String(params[k]); // a param is its text in the URL: 1 and "1" are one query
61
+ return JSON.stringify(out);
62
+ }
63
+
64
+ function buildQuery(names = [], params = {}) {
65
+ const sp = new URLSearchParams();
66
+ for (const name of names) {
67
+ const v = params[name];
68
+ if (v == null) continue;
69
+ for (const item of Array.isArray(v) ? v : [v]) sp.append(name, String(item));
70
+ }
71
+ const s = sp.toString();
72
+ return s ? `?${s}` : '';
73
+ }
74
+
75
+ /**
76
+ * @typedef {object} Operation
77
+ * @property {string} id
78
+ * @property {string} method
79
+ * @property {string} path
80
+ * @property {string[]} [query]
81
+ * @property {boolean} [body]
82
+ * @property {any} [response] descriptor
83
+ *
84
+ * @typedef {object} ClientOptions
85
+ * @property {{operations: Record<string, Operation>, entities?: Record<string, any>}} api contents of .xoong/operations.json
86
+ * @property {string} [baseUrl]
87
+ * @property {typeof fetch} [fetch]
88
+ * @property {() => Record<string,string> | Promise<Record<string,string>>} [headers] per-request headers (auth)
89
+ * @property {RequestCredentials} [credentials]
90
+ * @property {number} [staleTime] ms a GET result stays fresh (default 30000)
91
+ * @property {number} [gcTime] ms an unobserved query is kept (default 300000)
92
+ * @property {boolean} [evictEntities] drop entities no cached query refers to when a query is collected (default true)
93
+ * @property {number} [retry] GET retries on network/502/503/504 (default 2)
94
+ * @property {number} [retryDelay] base delay in ms, doubled per retry (default 300)
95
+ * @property {Record<string,string[]>} [invalidate] extra rules: mutation operationId -> operationIds to invalidate
96
+ * @property {(e: XoongError) => void} [onError] global error handler
97
+ * @property {(e: XoongError) => void} [onUnauthorized]
98
+ * @property {string} [streamUrl] SSE endpoint for `live` views
99
+ */
100
+
101
+ /** @param {ClientOptions} options */
102
+ export function createClient(options) {
103
+ const {
104
+ api, baseUrl = '', headers, credentials, staleTime = 30_000, gcTime = 300_000, evictEntities = true,
105
+ retry = 2, retryDelay = 300, invalidate: overrides = {}, onError, onUnauthorized,
106
+ } = options;
107
+ const doFetch = options.fetch ?? globalThis.fetch?.bind(globalThis);
108
+ const store = createStore(api.entities ?? {});
109
+ /** @type {Map<string, Query>} */
110
+ const queries = new Map();
111
+ let mutating = 0;
112
+
113
+ const byRoute = new Map();
114
+ for (const operation of Object.values(api.operations)) byRoute.set(`${operation.method.toUpperCase()} ${operation.path}`, operation);
115
+
116
+ /** Resolve "listUsers" or "GET /users". */
117
+ function op(ref) {
118
+ const found = (Object.hasOwn(api.operations, ref) ? api.operations[ref] : undefined) ?? byRoute.get(ref.replace(/\s+/g, ' ').trim());
119
+ if (!found) {
120
+ const hint = nearest(ref, [...Object.keys(api.operations), ...byRoute.keys()]);
121
+ throw new Error(`xoong: unknown operation "${ref}"${hint ? `. Closest: "${hint}"` : ''}`);
122
+ }
123
+ return found;
124
+ }
125
+
126
+ /** JSON by default; FormData (multipart operations) is sent as it is so the browser sets the boundary. */
127
+ function setBody(init, body) {
128
+ if (body === undefined) return;
129
+ if (typeof FormData !== 'undefined' && body instanceof FormData) init.body = body;
130
+ else { init.headers['content-type'] = 'application/json'; init.body = JSON.stringify(body); }
131
+ }
132
+
133
+ /** Fill the fields the hints declare as idempotency keys with a fresh UUID (one per call, kept by the caller on retry). */
134
+ function withKeys(o, body) {
135
+ if (!o.uuid?.length) return body;
136
+ if (typeof FormData !== 'undefined' && body instanceof FormData) {
137
+ for (const f of o.uuid) if (!body.has(f)) body.set(f, crypto.randomUUID());
138
+ return body;
139
+ }
140
+ const out = { ...(body ?? {}) };
141
+ for (const f of o.uuid) if (out[f] == null || out[f] === '') out[f] = crypto.randomUUID();
142
+ return out;
143
+ }
144
+
145
+ async function request(o, params, body, abort) {
146
+ const url = baseUrl + fillPath(o.path, params, o.id) + buildQuery(o.query, params);
147
+ const extra = headers ? await headers() : {};
148
+ const init = { method: o.method.toUpperCase(), headers: { accept: 'application/json', ...extra }, signal: abort, credentials };
149
+ setBody(init, body);
150
+ const attempts = o.method.toUpperCase() === 'GET' ? retry : 0;
151
+ for (let n = 0; ; n++) {
152
+ let res;
153
+ try {
154
+ res = await doFetch(url, init);
155
+ } catch (cause) {
156
+ if (abort?.aborted) throw cause;
157
+ if (n < attempts) { await sleep(retryDelay * 2 ** n); continue; }
158
+ throw new XoongError({ operation: o.id, cause });
159
+ }
160
+ if (res.ok) {
161
+ if (res.status === 204) return null;
162
+ const text = await res.text();
163
+ const json = text ? JSON.parse(text) : null;
164
+ if (o.unwrap && json && typeof json === 'object') { // { code, message, data } envelopes
165
+ if (!(o.unwrap in json)) throw new XoongError({ status: res.status, operation: o.id, detail: `the response has no "${o.unwrap}" field (hint "unwrap" expects an envelope)` });
166
+ return json[o.unwrap] ?? null;
167
+ }
168
+ return json;
169
+ }
170
+ if ([502, 503, 504].includes(res.status) && n < attempts) { await sleep(retryDelay * 2 ** n); continue; }
171
+ let problem;
172
+ try { problem = await res.json(); } catch { /* not JSON */ }
173
+ throw errorFromBody(res.status, res.statusText, problem, o.id);
174
+ }
175
+ }
176
+
177
+ function fail(err) {
178
+ if (err.status === 401) onUnauthorized?.(err);
179
+ onError?.(err);
180
+ }
181
+
182
+ class Query {
183
+ /** @param {Operation} o @param {any} params */
184
+ constructor(o, params) {
185
+ this.op = o;
186
+ this.params = params;
187
+ this.key = `${o.id}:${canonical(params)}`;
188
+ this.path = fillPath(o.path, params, o.id);
189
+ /** normalized root: entity refs inside */
190
+ this.root = signal(undefined);
191
+ this.status = signal(/** @type {'idle'|'loading'|'success'|'error'} */ ('idle'));
192
+ this.error = signal(/** @type {XoongError|null} */ (null));
193
+ this.data = computed(() => store.denormalize(this.root.value));
194
+ this.updatedAt = 0;
195
+ this.stale = true;
196
+ this.observers = 0;
197
+ /** @type {Promise<any>|null} */ this.inflight = null;
198
+ this.refetchAfter = false;
199
+ this.waiters = 0;
200
+ /** @type {AbortController|null} */ this.abort = null;
201
+ this.gcTimer = undefined;
202
+ }
203
+ isFresh() { return !this.stale && Date.now() - this.updatedAt < staleTime; }
204
+
205
+ /** Drop this query after gcTime unless something starts observing it. */
206
+ armGc() {
207
+ clearTimeout(this.gcTimer);
208
+ if (!Number.isFinite(gcTime)) return;
209
+ this.gcTimer = setTimeout(() => {
210
+ if (this.observers === 0 && queries.get(this.key) === this) {
211
+ queries.delete(this.key); disposeComputed(this.data);
212
+ if (evictEntities) store.sweep([...queries.values()].map((q) => q.root.peek())); // entities nothing on screen or in cache refers to
213
+ }
214
+ }, gcTime);
215
+ this.gcTimer.unref?.();
216
+ }
217
+
218
+ /** Fetch now (deduplicated). Resolves with denormalized data. */
219
+ fetch() {
220
+ if (this.inflight) return this.inflight;
221
+ batch(() => { this.status.value = 'loading'; this.error.value = null; });
222
+ const ctl = new AbortController();
223
+ this.abort = ctl;
224
+ /** @type {Promise<any>} */ let self;
225
+ self = request(this.op, this.params, undefined, ctl.signal)
226
+ .then((json) => {
227
+ batch(() => {
228
+ this.root.value = normalize(store, this.op.response, json);
229
+ this.status.value = 'success';
230
+ this.updatedAt = Date.now();
231
+ this.stale = false;
232
+ });
233
+ return this.data.peek();
234
+ })
235
+ .catch((err) => {
236
+ if (ctl.signal.aborted) { // nobody wants this result any more: back to "not loaded", no error
237
+ if (!this.inflight || this.inflight === self) { // unless a newer fetch of this query already took over
238
+ batch(() => { this.status.value = this.root.peek() === undefined ? 'idle' : 'success'; });
239
+ this.stale = true;
240
+ }
241
+ throw err;
242
+ }
243
+ const e = err instanceof XoongError ? err : new XoongError({ operation: this.op.id, cause: err, detail: String(err?.message ?? err) });
244
+ batch(() => { this.status.value = 'error'; this.error.value = e; });
245
+ fail(e);
246
+ throw e;
247
+ })
248
+ .finally(() => {
249
+ if (this.inflight && this.inflight !== self) return; // a newer fetch owns the query now
250
+ this.inflight = null;
251
+ if (this.refetchAfter) { this.refetchAfter = false; this.fetch().catch(() => {}); }
252
+ });
253
+ this.inflight = self;
254
+ return self;
255
+ }
256
+
257
+ /** Start observing (a view is showing this query). Fetches if stale. */
258
+ retain() {
259
+ this.observers++;
260
+ clearTimeout(this.gcTimer);
261
+ if (!this.isFresh()) this.fetch().catch(() => {});
262
+ }
263
+ release() {
264
+ this.observers = Math.max(0, this.observers - 1);
265
+ // The view moved on (params changed or it was removed) while the request runs: cancel it, unless `load()` waits for it.
266
+ if (this.observers === 0 && this.waiters === 0 && this.inflight) {
267
+ this.abort?.abort();
268
+ this.inflight = null; // a retain() right after (same params selected again) must start a fresh request
269
+ this.abort = null;
270
+ }
271
+ if (this.observers === 0) this.armGc();
272
+ }
273
+ /** Mark stale; refetch right away only if something is watching. */
274
+ invalidate() {
275
+ this.stale = true;
276
+ if (this.observers > 0) {
277
+ if (this.inflight) this.refetchAfter = true; else this.fetch().catch(() => {});
278
+ }
279
+ }
280
+ }
281
+
282
+ const client = {
283
+ store, op,
284
+
285
+ /** Get (or create) the shared Query for an operation + params. Does not fetch. */
286
+ query(ref, params = {}) {
287
+ const o = op(ref);
288
+ if (o.method.toUpperCase() !== 'GET') throw new Error(`xoong: "${o.id}" is a ${o.method} operation; use mutate()`);
289
+ const key = `${o.id}:${canonical(params)}`;
290
+ let q = queries.get(key);
291
+ if (!q) { queries.set(key, (q = new Query(o, params))); q.armGc(); }
292
+ return q;
293
+ },
294
+
295
+ /**
296
+ * Put data the server already has (rendered into the page, a bootstrap payload) into the cache as if the GET had run: entities are
297
+ * shared like any response, and a view that asks for this operation and these params shows it at once, with no request until it is stale.
298
+ */
299
+ seed(ref, params = {}, json) {
300
+ const q = client.query(ref, params);
301
+ batch(() => {
302
+ q.root.value = normalize(store, q.op.response, json);
303
+ q.status.value = 'success';
304
+ q.error.value = null;
305
+ q.updatedAt = Date.now();
306
+ q.stale = false;
307
+ });
308
+ return q.data.peek();
309
+ },
310
+
311
+ /** Fetch a GET operation once (respecting freshness) and resolve with plain data. */
312
+ async load(ref, params = {}) {
313
+ const q = client.query(ref, params);
314
+ if (q.isFresh()) return q.data.peek();
315
+ q.waiters++;
316
+ try { return await q.fetch(); } finally { q.waiters--; }
317
+ },
318
+
319
+ /**
320
+ * Call a mutating operation. Upserts returned entities, then invalidates
321
+ * affected queries (path-prefix rule + overrides from `options.invalidate`).
322
+ * `options.optimistic`: [{ $ref: 'User#1', patch: { status: 'disabled' } }] applies a provisional change to loaded entities
323
+ * until the call finishes, and undoes it if the call fails.
324
+ */
325
+ async mutate(ref, params = {}, body, { optimistic } = {}) {
326
+ const o = op(ref);
327
+ if (o.method.toUpperCase() === 'GET') throw new Error(`xoong: "${o.id}" is a GET operation; use query()/load()`);
328
+ let json;
329
+ mutating++;
330
+ // Optimistic updates: show the expected result at once; take it back if the call fails.
331
+ const undo = (optimistic ?? []).map((u) => store.optimistic(u.$ref, u.patch));
332
+ try {
333
+ json = await request(o, params, withKeys(o, body));
334
+ } catch (err) {
335
+ undo.reverse().forEach((u) => u());
336
+ const e = err instanceof XoongError ? err : new XoongError({ operation: o.id, cause: err });
337
+ fail(e);
338
+ throw e;
339
+ } finally {
340
+ mutating--;
341
+ }
342
+ /** @type {any} */ let root;
343
+ batch(() => {
344
+ const touched = new Set();
345
+ root = json == null ? json : normalize(store, o.response, json, touched);
346
+ const concrete = fillPath(o.path, params, o.id);
347
+ const extra = new Set((overrides[o.id] ?? []).map((r) => op(r).id));
348
+ for (const q of queries.values()) {
349
+ if (!(extra.has(q.op.id) || pathsOverlap(q.path, concrete))) continue;
350
+ // The response just rewrote the very entity this detail query shows: it is already fresh.
351
+ const shown = q.root.peek();
352
+ if (!extra.has(q.op.id) && shown && typeof shown.$ref === 'string' && touched.has(shown.$ref)) {
353
+ q.stale = false; q.updatedAt = Date.now();
354
+ continue;
355
+ }
356
+ q.invalidate();
357
+ }
358
+ });
359
+ return store.denormalize(root);
360
+ },
361
+
362
+ /**
363
+ * Call an operation that answers with Server-Sent Events (for example a chat
364
+ * reply streamed over POST) and iterate its events:
365
+ *
366
+ * for await (const ev of client.stream('POST /v1/sessions/{sid}/messages/stream', { sid }, body)) ...
367
+ *
368
+ * Each event is { event, data, json, id }; `json` is `data` parsed when it is valid JSON.
369
+ * Pass { signal } to cancel. Not cached; use a normal operation for data you want in the store.
370
+ */
371
+ async *stream(ref, params = {}, body, { signal: abort } = {}) {
372
+ const o = op(ref);
373
+ const url = baseUrl + fillPath(o.path, params, o.id) + buildQuery(o.query, params);
374
+ const extra = headers ? await headers() : {};
375
+ const init = { method: o.method.toUpperCase(), headers: { accept: 'text/event-stream', ...extra }, signal: abort, credentials };
376
+ setBody(init, withKeys(o, body));
377
+ let res;
378
+ try { res = await doFetch(url, init); } catch (cause) {
379
+ if (abort?.aborted) throw cause;
380
+ const e = new XoongError({ operation: o.id, cause }); fail(e); throw e;
381
+ }
382
+ if (!res.ok) {
383
+ let problem; try { problem = await res.json(); } catch { /* not JSON */ }
384
+ const e = errorFromBody(res.status, res.statusText, problem, o.id); fail(e); throw e;
385
+ }
386
+ if (!res.body) return;
387
+ const reader = res.body.getReader();
388
+ abort?.addEventListener('abort', () => reader.cancel().catch(() => {}), { once: true }); // stop reading even if fetch ignores the signal
389
+ const decoder = new TextDecoder();
390
+ let buffer = '';
391
+ try {
392
+ for (;;) {
393
+ const { done, value } = await reader.read();
394
+ buffer += decoder.decode(value, { stream: !done });
395
+ let m;
396
+ while ((m = /\r?\n\r?\n/.exec(buffer))) {
397
+ const ev = parseSse(buffer.slice(0, m.index));
398
+ buffer = buffer.slice(m.index + m[0].length);
399
+ if (ev) yield ev;
400
+ }
401
+ if (done) { const ev = parseSse(buffer); if (ev) yield ev; return; }
402
+ }
403
+ } finally {
404
+ reader.cancel().catch(() => {});
405
+ }
406
+ },
407
+
408
+ /** Invalidate every cached query (e.g. after a stream resync). */
409
+ invalidateAll() {
410
+ for (const q of queries.values()) q.invalidate();
411
+ },
412
+
413
+ /** Number of requests in flight (used by test helpers to wait until idle). */
414
+ get pending() { let n = mutating; for (const q of queries.values()) if (q.inflight) n++; return n; },
415
+
416
+ /** Number of cached queries (diagnostics). */
417
+ get size() { return queries.size; },
418
+ };
419
+ return client;
420
+ }
421
+
422
+ function parseSse(block) {
423
+ let event = 'message', id, data = [];
424
+ for (const line of block.split(/\r?\n/)) {
425
+ if (!line || line.startsWith(':')) continue;
426
+ const i = line.indexOf(':');
427
+ const field = i < 0 ? line : line.slice(0, i);
428
+ const value = i < 0 ? '' : line.slice(i + 1).replace(/^ /, '');
429
+ if (field === 'event') event = value; else if (field === 'data') data.push(value); else if (field === 'id') id = value;
430
+ }
431
+ if (!data.length) return null;
432
+ const raw = data.join('\n');
433
+ let json; try { json = JSON.parse(raw); } catch { /* plain text */ }
434
+ return { event, data: raw, json, id };
435
+ }
package/src/default.js ADDED
@@ -0,0 +1,54 @@
1
+ // @ts-check
2
+ import { createClient } from './client.js';
3
+
4
+ /** @type {ReturnType<typeof createClient> | null} */
5
+ let current = null;
6
+
7
+ /**
8
+ * Create the app-wide client once, at startup.
9
+ * @param {import('./client.js').ClientOptions} options
10
+ */
11
+ export function configure(options) {
12
+ stream?.close(); // a stream is bound to its client; start fresh
13
+ stream = null;
14
+ current = createClient(options);
15
+ setStreamUrl(options.streamUrl);
16
+ warnedNoStream = false;
17
+ return current;
18
+ }
19
+
20
+ /** The app-wide client. Throws a clear error if configure() was not called. */
21
+ export function getClient() {
22
+ if (!current) throw new Error('xoong: call configure({ api, ... }) before using views or derived()');
23
+ return current;
24
+ }
25
+
26
+ let streamUrl = null;
27
+ /** @type {{ close(): void } | null} */
28
+ let stream = null;
29
+ let streamUsers = 0;
30
+ let warnedNoStream = false;
31
+
32
+ /** Set the SSE endpoint used by `live` views. Called by configure(). */
33
+ export function setStreamUrl(url) { streamUrl = url ?? null; }
34
+
35
+ /**
36
+ * Share one stream connection between all `live` views (ref-counted).
37
+ * Returns a release function.
38
+ */
39
+ export function acquireStream() {
40
+ streamUsers++;
41
+ if (!streamUrl && !warnedNoStream) { warnedNoStream = true; console.warn('xoong: <xo-view live> needs `streamUrl` in configure({ ... }); live updates are disabled'); } // once, not once per view
42
+ if (!stream && streamUrl) {
43
+ // Imported lazily so non-live apps never pay for it.
44
+ import('./stream.js').then(({ connectStream }) => {
45
+ if (streamUsers > 0 && !stream && current) stream = connectStream({ client: current, url: streamUrl });
46
+ });
47
+ }
48
+ let released = false;
49
+ return () => {
50
+ if (released) return;
51
+ released = true;
52
+ if (--streamUsers === 0 && stream) { stream.close(); stream = null; }
53
+ };
54
+ }
package/src/derived.js ADDED
@@ -0,0 +1,28 @@
1
+ // @ts-check
2
+ import { computed } from '@xoong/core/signals';
3
+ import { getClient } from './default.js';
4
+
5
+ /**
6
+ * A computed value over the shared entity store.
7
+ *
8
+ * const revenueByRegion = derived(({ all }) =>
9
+ * groupBy(all('Order'), 'region'))
10
+ *
11
+ * `all(name)` returns every currently loaded entity of that type as plain
12
+ * objects (tracked: recomputes when any of them, or the set, changes).
13
+ * `get(name, id)` returns one entity. `derived` never triggers requests:
14
+ * fetching is declared on a view (`<xo-view get="...">`).
15
+ *
16
+ * @template T
17
+ * @param {(ctx: { all(name: string): any[], get(name: string, id: string|number): any }) => T} fn
18
+ * @param {ReturnType<typeof import('./client.js').createClient>} [client] defaults to the configured client
19
+ */
20
+ export function derived(fn, client) {
21
+ return computed(() => {
22
+ const { store } = client ?? getClient();
23
+ return fn({
24
+ all: (name) => store.refs(name).map((r) => store.denormalize(r)).filter((x) => x !== undefined),
25
+ get: (name, id) => store.denormalize({ $ref: `${name}#${id}` }),
26
+ });
27
+ });
28
+ }