@ayepi/core 0.1.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.
@@ -0,0 +1,1472 @@
1
+ //#region src/path.ts
2
+ /**
3
+ * Split a spec-author `:key` pattern string into {@link PathPart}s.
4
+ *
5
+ * The input is author-controlled (not user input), so a plain `split('/')` is
6
+ * appropriate here. The leading empty segment from a leading `/` is dropped.
7
+ */
8
+ function splitPattern(pattern) {
9
+ return pattern.split("/").filter((seg, i) => !(i === 0 && seg === "")).map((seg) => seg.startsWith(":") ? {
10
+ t: "param",
11
+ k: seg.slice(1)
12
+ } : {
13
+ t: "lit",
14
+ v: seg
15
+ });
16
+ }
17
+ /** Render {@link PathPart}s back into a `:key` pattern string (the inverse of {@link splitPattern}). */
18
+ function joinPattern(parts) {
19
+ return "/" + parts.map((p) => p.t === "param" ? `:${p.k}` : p.v).join("/");
20
+ }
21
+ /**
22
+ * Match a concrete pathname against {@link PathPart}s.
23
+ *
24
+ * Walks segment by segment: literals must equal, params must be non-empty and
25
+ * are individually `decodeURIComponent`-decoded. Returns the raw (decoded but
26
+ * un-coerced) param map, or `null` on any mismatch (literal differs, length
27
+ * differs, or an empty param segment).
28
+ */
29
+ function matchParts(parts, pathname) {
30
+ const segs = pathname.split("/").filter((seg, i) => !(i === 0 && seg === ""));
31
+ if (segs.length !== parts.length) return null;
32
+ const out = {};
33
+ for (let i = 0; i < parts.length; i++) {
34
+ const part = parts[i];
35
+ const seg = segs[i];
36
+ if (part.t === "lit") {
37
+ if (part.v !== seg) return null;
38
+ } else {
39
+ if (seg === "") return null;
40
+ out[part.k] = decodeURIComponent(seg);
41
+ }
42
+ }
43
+ return out;
44
+ }
45
+ /**
46
+ * Build a concrete pathname from {@link PathPart}s and a value map. Each param
47
+ * value is stringified and `encodeURIComponent`-encoded per-segment.
48
+ *
49
+ * @throws if a declared param has no value.
50
+ */
51
+ function buildParts(parts, values) {
52
+ return "/" + parts.map((p) => {
53
+ if (p.t === "lit") return p.v;
54
+ const v = values[p.k];
55
+ if (v === void 0) throw new Error(`path is missing a value for ":${p.k}"`);
56
+ return encodeURIComponent(String(v));
57
+ }).join("/");
58
+ }
59
+ /** Extract the param keys (in order) from {@link PathPart}s. */
60
+ function paramKeys(parts) {
61
+ return parts.filter((p) => p.t === "param").map((p) => p.k);
62
+ }
63
+ /**
64
+ * Tagged template for typed paths. Each interpolation is a single
65
+ * `{ name: schema }` object; the schema both **declares** and **types** that
66
+ * param and must accept string input.
67
+ *
68
+ * @example
69
+ * ```ts
70
+ * const userPost = path`/users/${{ id: z.uuid() }}/posts/${{ slug: z.string() }}`
71
+ * userPost.pattern // '/users/:id/posts/:slug'
72
+ * userPost.build({ id, slug }) // '/users/3f…/posts/intro'
73
+ * userPost.parse('/users/3f…/posts/intro') // { id, slug } | null
74
+ *
75
+ * path`/x/${{ n: z.number() }}` // ❌ compile error: schema must accept string input
76
+ * path`/x/${{ n: z.coerce.number() }}` // ✅ ok: coerces from string
77
+ * ```
78
+ *
79
+ * @throws at definition time if a param does not occupy a whole segment, an
80
+ * interpolation is not a single-key object, or a key is declared twice.
81
+ */
82
+ function path(strings, ...interpolations) {
83
+ const keys = [];
84
+ const schemas = {};
85
+ const parts = [];
86
+ let i = 0;
87
+ for (let s = 0; s < strings.length; s++) {
88
+ const lits = strings[s].split("/").filter((seg, j) => !(s === 0 && j === 0 && seg === ""));
89
+ for (let j = 0; j < lits.length; j++) {
90
+ const lit = lits[j];
91
+ if (lit !== "") parts.push({
92
+ t: "lit",
93
+ v: lit
94
+ });
95
+ else if (j > 0 && j < lits.length - 1) parts.push({
96
+ t: "lit",
97
+ v: ""
98
+ });
99
+ }
100
+ const part = interpolations[s];
101
+ if (!part) continue;
102
+ if (strings[s].length > 0 && !strings[s].endsWith("/")) throw new Error(`path template param #${i + 1} must occupy a whole segment (missing "/" before it)`);
103
+ const after = strings[s + 1];
104
+ if (after !== void 0 && after.length > 0 && !after.startsWith("/")) throw new Error(`path template param #${i + 1} must occupy a whole segment (missing "/" after it)`);
105
+ const entries = Object.entries(part);
106
+ if (entries.length !== 1) throw new Error(`path template interpolation #${i + 1} must be a single { name: schema } object`);
107
+ const [key, schema] = entries[0];
108
+ if (keys.includes(key)) throw new Error(`path template declares param ":${key}" twice`);
109
+ keys.push(key);
110
+ schemas[key] = schema;
111
+ parts.push({
112
+ t: "param",
113
+ k: key
114
+ });
115
+ i++;
116
+ }
117
+ return {
118
+ kind: "path",
119
+ parts,
120
+ pattern: joinPattern(parts),
121
+ keys,
122
+ schemas,
123
+ build(params) {
124
+ for (const key of keys) schemas[key].parse(String(params[key]));
125
+ return buildParts(parts, params);
126
+ },
127
+ parse(input) {
128
+ const raw = matchParts(parts, input);
129
+ if (!raw) return null;
130
+ const out = {};
131
+ for (const key of keys) out[key] = schemas[key].parse(raw[key]);
132
+ return out;
133
+ }
134
+ };
135
+ }
136
+ //#endregion
137
+ //#region src/errors.ts
138
+ /**
139
+ * # Errors
140
+ *
141
+ * The error envelope used on both sides of the wire. This module is deliberately
142
+ * **zod-free** so it can be imported by the browser client without pulling zod
143
+ * into the bundle.
144
+ *
145
+ * @module
146
+ */
147
+ /**
148
+ * A transport-level API error.
149
+ *
150
+ * Thrown server-side to short-circuit a request with a status + machine code,
151
+ * and re-constructed client-side from an HTTP error envelope (`{ error: { code,
152
+ * message } }`) or a ws response frame whose `$status` is not 2xx (`$error`/`$code`
153
+ * + an optional typed `data` body), so the same `instanceof ApiError` check works
154
+ * everywhere.
155
+ *
156
+ * @example
157
+ * ```ts
158
+ * try {
159
+ * await sdk.call('getUser', { id: 'nope' })
160
+ * } catch (err) {
161
+ * if (err instanceof ApiError && err.status === 404) showNotFound()
162
+ * }
163
+ * ```
164
+ */
165
+ var ApiError = class extends Error {
166
+ status;
167
+ code;
168
+ data;
169
+ /**
170
+ * @param status HTTP (or ws-mapped) status code.
171
+ * @param code Stable machine-readable error code (e.g. `'UNAUTHORIZED'`).
172
+ * @param message Optional human-readable message; defaults to `code`.
173
+ * @param data Optional structured payload — the parsed error body for
174
+ * declared typed errors, or the raw envelope otherwise.
175
+ */
176
+ constructor(status, code, message, data) {
177
+ super(message ?? code);
178
+ this.status = status;
179
+ this.code = code;
180
+ this.data = data;
181
+ this.name = "ApiError";
182
+ }
183
+ };
184
+ /**
185
+ * Internal control-flow signal thrown by a handler's `fail()`.
186
+ *
187
+ * Carries an **already-validated** declared-error body so the server can emit it
188
+ * verbatim with the declared status. Not part of the public surface.
189
+ *
190
+ * @internal
191
+ */
192
+ var ApiFailure = class extends Error {
193
+ status;
194
+ data;
195
+ constructor(status, data) {
196
+ super(`declared error ${status}`);
197
+ this.status = status;
198
+ this.data = data;
199
+ this.name = "ApiFailure";
200
+ }
201
+ };
202
+ /**
203
+ * Construct an {@link ApiError} to `throw` from a handler or middleware.
204
+ *
205
+ * @example
206
+ * ```ts
207
+ * const auth = middleware('auth', async (io) => {
208
+ * if (!io.req.headers.get('authorization')) throw reject(401, 'UNAUTHORIZED')
209
+ * return io.next()
210
+ * })
211
+ * ```
212
+ */
213
+ function reject(status, code, message) {
214
+ return new ApiError(status, code, message);
215
+ }
216
+ //#endregion
217
+ //#region src/caller.ts
218
+ const sortDeep = (v) => {
219
+ if (Array.isArray(v)) return v.map(sortDeep);
220
+ if (v && typeof v === "object") {
221
+ const out = {};
222
+ for (const key of Object.keys(v).sort()) out[key] = sortDeep(v[key]);
223
+ return out;
224
+ }
225
+ return v;
226
+ };
227
+ /** Deterministic JSON (sorted keys) — the default cache key for a call's `data`. */
228
+ const stableStringify = (value) => JSON.stringify(sortDeep(value)) ?? "null";
229
+ /** Whether a value round-trips through JSON (only such results are cacheable — excludes `undefined`/functions). */
230
+ const isJsonSafe = (v) => {
231
+ try {
232
+ return JSON.stringify(v) !== void 0;
233
+ } catch {
234
+ return false;
235
+ }
236
+ };
237
+ /** A `Storage`-backed {@link KVStore} (localStorage/sessionStorage), namespaced by `prefix`. */
238
+ const storageKV = (storage, prefix) => ({
239
+ get: (key) => storage.getItem(prefix + key) ?? void 0,
240
+ set: (key, value) => storage.setItem(prefix + key, value),
241
+ delete: (key) => {
242
+ const full = prefix + key;
243
+ const had = storage.getItem(full) !== null;
244
+ storage.removeItem(full);
245
+ return had;
246
+ },
247
+ keys: function* () {
248
+ for (let i = 0; i < storage.length; i++) {
249
+ const k = storage.key(i);
250
+ if (k !== null && k.startsWith(prefix)) yield k.slice(prefix.length);
251
+ }
252
+ }
253
+ });
254
+ /** Resolve a {@link CacheStoreSpec} to a concrete {@link KVStore}, falling back to memory (SSR-safe). */
255
+ const resolveStore = (spec, prefix) => {
256
+ if (typeof spec === "object") return spec;
257
+ if (spec !== "memory") try {
258
+ const storage = spec === "local" ? globalThis.localStorage : globalThis.sessionStorage;
259
+ if (storage) return storageKV(storage, prefix);
260
+ } catch {}
261
+ const map = /* @__PURE__ */ new Map();
262
+ return {
263
+ get: (k) => map.get(k),
264
+ set: (k, v) => void map.set(k, v),
265
+ delete: (k) => map.delete(k),
266
+ keys: () => map.keys()
267
+ };
268
+ };
269
+ /** Create a tag-aware LRU cache over the chosen backend. */
270
+ function createClientCache(opts = {}) {
271
+ const max = opts.max ?? 500;
272
+ const defaultTtl = opts.ttl ?? 0;
273
+ const now = opts.now ?? Date.now;
274
+ const kv = resolveStore(opts.store ?? "memory", opts.prefix ?? "ayepi:cache:");
275
+ const order = /* @__PURE__ */ new Map();
276
+ for (const k of kv.keys()) order.set(k, true);
277
+ const touch = (key) => {
278
+ order.delete(key);
279
+ order.set(key, true);
280
+ };
281
+ const parse = (key) => {
282
+ const raw = kv.get(key);
283
+ if (raw === void 0) return;
284
+ try {
285
+ return JSON.parse(raw);
286
+ } catch {
287
+ kv.delete(key);
288
+ return;
289
+ }
290
+ };
291
+ const drop = (key) => {
292
+ order.delete(key);
293
+ return kv.delete(key);
294
+ };
295
+ const evict = () => {
296
+ while (order.size > max) drop(order.keys().next().value);
297
+ };
298
+ return {
299
+ read: (key) => {
300
+ const e = parse(key);
301
+ if (!e) return;
302
+ const t = now();
303
+ if (e.exp && t >= e.exp) {
304
+ if (!e.stale || t >= e.stale) {
305
+ drop(key);
306
+ return;
307
+ }
308
+ touch(key);
309
+ return {
310
+ value: e.v,
311
+ stale: true
312
+ };
313
+ }
314
+ touch(key);
315
+ return {
316
+ value: e.v,
317
+ stale: false
318
+ };
319
+ },
320
+ write: (key, value, o) => {
321
+ if (!isJsonSafe(value)) return;
322
+ const ttl = o?.ttl ?? defaultTtl;
323
+ const exp = ttl > 0 ? now() + ttl : 0;
324
+ const entry = {
325
+ v: value,
326
+ exp,
327
+ stale: exp && o?.staleWhileRevalidate ? exp + o.staleWhileRevalidate : 0,
328
+ tags: o?.tags ?? []
329
+ };
330
+ kv.set(key, JSON.stringify(entry));
331
+ touch(key);
332
+ evict();
333
+ },
334
+ remove: (key) => void drop(key),
335
+ removeWhere: (pred) => {
336
+ let n = 0;
337
+ for (const key of [...kv.keys()]) if (pred(key) && drop(key)) n += 1;
338
+ return n;
339
+ },
340
+ invalidateTags: (tags) => {
341
+ if (tags.length === 0) return 0;
342
+ const want = new Set(tags);
343
+ let n = 0;
344
+ for (const key of [...kv.keys()]) {
345
+ const e = parse(key);
346
+ if (e && e.tags.some((tag) => want.has(tag)) && drop(key)) n += 1;
347
+ }
348
+ return n;
349
+ },
350
+ clear: () => {
351
+ for (const key of [...kv.keys()]) kv.delete(key);
352
+ order.clear();
353
+ }
354
+ };
355
+ }
356
+ /** Create the shared caller context. `defaults` seed each store's cache (`max`/`ttl`/`store`). */
357
+ function createCallerContext(defaults = {}) {
358
+ const caches = /* @__PURE__ */ new Map();
359
+ const cacheFor = (store) => {
360
+ const spec = store ?? defaults.store ?? "memory";
361
+ let c = caches.get(spec);
362
+ if (!c) {
363
+ c = createClientCache({
364
+ ...defaults,
365
+ store: spec
366
+ });
367
+ caches.set(spec, c);
368
+ }
369
+ return c;
370
+ };
371
+ return {
372
+ cacheFor,
373
+ invalidateTags: (tags) => {
374
+ cacheFor(void 0);
375
+ for (const c of caches.values()) c.invalidateTags(tags);
376
+ }
377
+ };
378
+ }
379
+ /** Rejected when a rate-limited caller is over budget with `onLimit: 'drop'`/`'throw'`. */
380
+ var CallerRateLimited = class extends Error {
381
+ constructor(message = "caller rate limit exceeded") {
382
+ super(message);
383
+ this.name = "CallerRateLimited";
384
+ }
385
+ };
386
+ const KEY_SEP = "\0";
387
+ const abortError = (reason) => new DOMException(reason, "AbortError");
388
+ /** A signal that aborts when any input signal aborts (a portable `AbortSignal.any`). */
389
+ const anySignal = (signals) => {
390
+ const real = signals.filter((s) => s !== void 0);
391
+ if (real.length === 1) return real[0];
392
+ const ctrl = new AbortController();
393
+ const onAbort = () => {
394
+ ctrl.abort();
395
+ for (const s of real) s.removeEventListener("abort", onAbort);
396
+ };
397
+ for (const s of real) {
398
+ if (s.aborted) {
399
+ ctrl.abort();
400
+ return ctrl.signal;
401
+ }
402
+ s.addEventListener("abort", onAbort, { once: true });
403
+ }
404
+ return ctrl.signal;
405
+ };
406
+ /** Sleep `ms`, rejecting early if `signal` aborts. */
407
+ const sleep = (ms, signal) => new Promise((resolve, reject) => {
408
+ if (signal.aborted) {
409
+ reject(abortError("aborted"));
410
+ return;
411
+ }
412
+ const onAbort = () => {
413
+ clearTimeout(timer);
414
+ reject(abortError("aborted"));
415
+ };
416
+ const timer = setTimeout(() => {
417
+ signal.removeEventListener("abort", onAbort);
418
+ resolve();
419
+ }, ms);
420
+ signal.addEventListener("abort", onAbort, { once: true });
421
+ });
422
+ const compose = (base, layers) => layers.reduceRight((next, layer) => layer(next), base);
423
+ const resolveTags = (tagger, data, result) => tagger === void 0 ? [] : typeof tagger === "function" ? tagger(data, result) : tagger;
424
+ const withHooks = (o, pending) => (next) => async (call, signal) => {
425
+ pending.n += 1;
426
+ o.onStart?.(call.data);
427
+ try {
428
+ const r = await next(call, signal);
429
+ o.onSuccess?.(r, call.data);
430
+ return r;
431
+ } catch (err) {
432
+ o.onError?.(err, call.data);
433
+ throw err;
434
+ } finally {
435
+ pending.n -= 1;
436
+ o.onSettled?.(call.data);
437
+ }
438
+ };
439
+ const withInvalidate = (tagger, when, ctx) => (next) => async (call, signal) => {
440
+ if (when !== "success") ctx.invalidateTags(resolveTags(tagger, call.data, void 0));
441
+ const r = await next(call, signal);
442
+ if (when !== "start") ctx.invalidateTags(resolveTags(tagger, call.data, r));
443
+ return r;
444
+ };
445
+ const withCache = (cfg, cache, keyOf) => (next) => async (call, signal) => {
446
+ const key = keyOf(call.data);
447
+ const store = (result) => cache.write(key, result, {
448
+ ttl: cfg.ttl,
449
+ staleWhileRevalidate: cfg.staleWhileRevalidate,
450
+ tags: resolveTags(cfg.tags, call.data, result)
451
+ });
452
+ const hit = cache.read(key);
453
+ if (hit && !hit.stale) return hit.value;
454
+ if (hit && hit.stale) {
455
+ next(call, signal).then(store).catch(() => {});
456
+ return hit.value;
457
+ }
458
+ const r = await next(call, signal);
459
+ store(r);
460
+ return r;
461
+ };
462
+ const withDedupe = (keyOf) => {
463
+ const inflight = /* @__PURE__ */ new Map();
464
+ return (next) => (call, signal) => {
465
+ const key = keyOf(call.data);
466
+ const existing = inflight.get(key);
467
+ if (existing) return existing;
468
+ const p = next(call, signal).finally(() => inflight.delete(key));
469
+ inflight.set(key, p);
470
+ return p;
471
+ };
472
+ };
473
+ const withLastOnly = () => {
474
+ let current = null;
475
+ return (next) => (call, signal) => {
476
+ current?.abort(abortError("superseded"));
477
+ const ctrl = new AbortController();
478
+ current = ctrl;
479
+ const linked = anySignal([signal, ctrl.signal]);
480
+ return new Promise((resolve, reject) => {
481
+ linked.addEventListener("abort", () => reject(abortError("superseded")), { once: true });
482
+ next(call, linked).then(resolve, reject);
483
+ }).finally(() => {
484
+ if (current === ctrl) current = null;
485
+ });
486
+ };
487
+ };
488
+ const withRateLimit = (cfg) => {
489
+ const onLimit = cfg.onLimit ?? "wait";
490
+ const perToken = cfg.window / cfg.limit;
491
+ let tokens = cfg.limit;
492
+ let last = Date.now();
493
+ const refill = () => {
494
+ const t = Date.now();
495
+ const add = (t - last) / cfg.window * cfg.limit;
496
+ if (add > 0) {
497
+ tokens = Math.min(cfg.limit, tokens + add);
498
+ last = t;
499
+ }
500
+ };
501
+ return (next) => async (call, signal) => {
502
+ refill();
503
+ if (tokens < 1) {
504
+ if (onLimit === "throw" || onLimit === "drop") throw new CallerRateLimited();
505
+ await sleep((1 - tokens) * perToken, signal);
506
+ refill();
507
+ }
508
+ tokens -= 1;
509
+ return next(call, signal);
510
+ };
511
+ };
512
+ const withRetry = (cfg) => {
513
+ const attempts = cfg.attempts ?? 3;
514
+ const base = cfg.base ?? 200;
515
+ const factor = cfg.factor ?? 2;
516
+ const max = cfg.max ?? 3e4;
517
+ const jitter = cfg.jitter ?? .5;
518
+ return (next) => async (call, signal) => {
519
+ let lastErr;
520
+ for (let attempt = 1; attempt <= attempts; attempt += 1) try {
521
+ return await next(call, signal);
522
+ } catch (err) {
523
+ lastErr = err;
524
+ if (attempt >= attempts) break;
525
+ await sleep(Math.min(max, base * factor ** (attempt - 1)) * (1 - jitter * Math.random()), signal);
526
+ }
527
+ throw lastErr;
528
+ };
529
+ };
530
+ const withDebounce = (cfg, cancellers) => {
531
+ let timer = null;
532
+ let queue = [];
533
+ let firstAt = 0;
534
+ return (next) => {
535
+ const fire = (signal) => {
536
+ if (timer) {
537
+ clearTimeout(timer);
538
+ timer = null;
539
+ }
540
+ const batch = queue;
541
+ queue = [];
542
+ firstAt = 0;
543
+ const dataList = batch.map((q) => q.call.data);
544
+ next(cfg.accumulate ? {
545
+ data: cfg.accumulate(dataList),
546
+ opts: batch[batch.length - 1].call.opts
547
+ } : batch[batch.length - 1].call, signal).then((r) => {
548
+ const results = cfg.spread ? cfg.spread(r, dataList) : null;
549
+ batch.forEach((q, i) => q.resolve(results ? results[i] : r));
550
+ }, (e) => batch.forEach((q) => q.reject(e)));
551
+ };
552
+ cancellers.push(() => {
553
+ if (timer) {
554
+ clearTimeout(timer);
555
+ timer = null;
556
+ }
557
+ const batch = queue;
558
+ queue = [];
559
+ firstAt = 0;
560
+ for (const q of batch) q.reject(abortError("cancelled"));
561
+ });
562
+ return (call, signal) => new Promise((resolve, reject) => {
563
+ const now = Date.now();
564
+ if (queue.length === 0) firstAt = now;
565
+ queue.push({
566
+ call,
567
+ resolve,
568
+ reject
569
+ });
570
+ const lead = cfg.leading && timer === null && queue.length === 1;
571
+ if (timer) clearTimeout(timer);
572
+ const overdue = cfg.maxWait !== void 0 && now - firstAt >= cfg.maxWait;
573
+ if (lead || overdue) {
574
+ fire(signal);
575
+ return;
576
+ }
577
+ const delay = cfg.maxWait !== void 0 ? Math.min(cfg.wait, firstAt + cfg.maxWait - now) : cfg.wait;
578
+ timer = setTimeout(() => fire(signal), delay);
579
+ });
580
+ };
581
+ };
582
+ /**
583
+ * Build a {@link Caller} for one endpoint: normalize args → `{ data, opts }`, fold the enabled
584
+ * policy layers around a base that calls `rawCall(data?, opts)`, and expose `cancel`/`invalidate`/
585
+ * `pending`. Streaming endpoints bypass every layer (policies are for unary request/response calls).
586
+ */
587
+ function makeCaller(name, m, rawCall, ctx, options) {
588
+ if (m.items || m.itemsIn || m.streamIn !== null || m.streamOut !== null) return {
589
+ call: (...args) => rawCall(...args),
590
+ cancel: () => {},
591
+ invalidate: () => {},
592
+ pending: 0
593
+ };
594
+ const hasData = m.p.length > 0 || m.q.length > 0 || m.hasBody || m.f.length > 0;
595
+ const cacheCfg = options.cache === true ? {} : options.cache === void 0 || options.cache === false ? {} : options.cache;
596
+ const dataKey = (data) => cacheCfg.key ? cacheCfg.key(data) : stableStringify(data);
597
+ const namespaced = (data) => `${name}${KEY_SEP}${dataKey(data)}`;
598
+ const cache = options.cache ? ctx.cacheFor(cacheCfg.store) : null;
599
+ const pending = { n: 0 };
600
+ const cancellers = [];
601
+ let callerCtrl = new AbortController();
602
+ const base = (call, signal) => {
603
+ const opts = {
604
+ ...call.opts ?? {},
605
+ signal
606
+ };
607
+ return Promise.resolve(hasData ? rawCall(call.data, opts) : rawCall(opts));
608
+ };
609
+ const layers = [];
610
+ if (options.onStart || options.onSuccess || options.onError || options.onSettled) layers.push(withHooks(options, pending));
611
+ if (options.invalidates) layers.push(withInvalidate(options.invalidates, options.invalidateOn ?? "success", ctx));
612
+ if (cache) layers.push(withCache(cacheCfg, cache, namespaced));
613
+ if (options.dedupe) layers.push(withDedupe(namespaced));
614
+ if (options.lastOnly) layers.push(withLastOnly());
615
+ if (options.debounce !== void 0) {
616
+ const dc = typeof options.debounce === "number" ? { wait: options.debounce } : options.debounce;
617
+ layers.push(withDebounce(dc, cancellers));
618
+ }
619
+ if (options.rateLimit) layers.push(withRateLimit(options.rateLimit));
620
+ if (options.retry) layers.push(withRetry(options.retry));
621
+ const invoke = compose(base, layers);
622
+ const call = (...args) => {
623
+ const data = hasData ? args[0] : void 0;
624
+ const opts = hasData ? args[1] : args[0];
625
+ const signal = anySignal([opts?.signal, callerCtrl.signal]);
626
+ return invoke({
627
+ data,
628
+ opts
629
+ }, signal);
630
+ };
631
+ return {
632
+ call,
633
+ cancel: () => {
634
+ callerCtrl.abort(abortError("cancelled"));
635
+ callerCtrl = new AbortController();
636
+ for (const c of cancellers) c();
637
+ },
638
+ invalidate: () => void cache?.removeWhere((key) => key.startsWith(`${name}${KEY_SEP}`)),
639
+ get pending() {
640
+ return pending.n;
641
+ }
642
+ };
643
+ }
644
+ //#endregion
645
+ //#region src/client.ts
646
+ /** Response statuses that must carry a null body (the `Response` constructor rejects a body for these). */
647
+ const NULL_BODY_STATUS = new Set([
648
+ 101,
649
+ 204,
650
+ 205,
651
+ 304
652
+ ]);
653
+ /**
654
+ * Send a **non-streaming** request via `XMLHttpRequest` so the caller can observe upload progress —
655
+ * `fetch` has no upload-progress events. Resolves a normal {@link Response} (built from the buffered
656
+ * reply) so the rest of the client treats it identically, and rejects with fetch-compatible errors
657
+ * (`TypeError` for network failures, an `AbortError` `DOMException` for aborts).
658
+ */
659
+ function xhrSend(method, url, headers, body, signal, onProgress) {
660
+ return new Promise((resolve, reject) => {
661
+ if (signal?.aborted) {
662
+ reject(new DOMException("The operation was aborted.", "AbortError"));
663
+ return;
664
+ }
665
+ const xhr = new XMLHttpRequest();
666
+ xhr.open(method, url, true);
667
+ xhr.responseType = "arraybuffer";
668
+ for (const [k, v] of Object.entries(headers)) xhr.setRequestHeader(k, v);
669
+ xhr.upload.onprogress = (e) => {
670
+ if (e.lengthComputable) onProgress({
671
+ loaded: e.loaded,
672
+ total: e.total
673
+ });
674
+ };
675
+ const onAbort = () => xhr.abort();
676
+ const cleanup = () => signal?.removeEventListener("abort", onAbort);
677
+ xhr.onload = () => {
678
+ cleanup();
679
+ const resBody = NULL_BODY_STATUS.has(xhr.status) ? null : xhr.response;
680
+ resolve(new Response(resBody, {
681
+ status: xhr.status,
682
+ statusText: xhr.statusText
683
+ }));
684
+ };
685
+ xhr.onerror = () => {
686
+ cleanup();
687
+ reject(/* @__PURE__ */ new TypeError("Network request failed"));
688
+ };
689
+ xhr.onabort = () => {
690
+ cleanup();
691
+ reject(new DOMException("The operation was aborted.", "AbortError"));
692
+ };
693
+ signal?.addEventListener("abort", onAbort, { once: true });
694
+ xhr.send(body);
695
+ });
696
+ }
697
+ /** Duck-type a zod schema without referencing `z` as a value (keeps the bundle zod-free). */
698
+ function isSchema(v) {
699
+ return !!v && typeof v.parse === "function";
700
+ }
701
+ /**
702
+ * Accept either a {@link Manifest} or a spec and return the manifest. A spec carries its
703
+ * zod-free manifest builder under `Symbol.for('ayepi.manifest')` (stamped by `spec()`); we
704
+ * read it off the value rather than importing the deriver, so a manifest-only bundle never
705
+ * pulls in that (zod-bearing) code. A plain manifest has no such method and is used as-is.
706
+ */
707
+ function resolveManifest(src) {
708
+ const build = src[Symbol.for("ayepi.manifest")];
709
+ return typeof build === "function" ? build() : src;
710
+ }
711
+ /** A ws call response is successful when its reserved `$status` is in the 2xx range. */
712
+ function is2xx(status) {
713
+ return typeof status === "number" && status >= 200 && status < 300;
714
+ }
715
+ /** Default human messages for common statuses — used when an error frame omits `$error`. */
716
+ const STATUS_TEXT = {
717
+ 400: "Bad Request",
718
+ 401: "Unauthorized",
719
+ 403: "Forbidden",
720
+ 404: "Not Found",
721
+ 405: "Method Not Allowed",
722
+ 409: "Conflict",
723
+ 410: "Gone",
724
+ 422: "Unprocessable Entity",
725
+ 429: "Too Many Requests",
726
+ 500: "Internal Server Error",
727
+ 502: "Bad Gateway",
728
+ 503: "Service Unavailable"
729
+ };
730
+ /**
731
+ * Build an {@link ApiError} from a ws error frame. Frames carry the reserved
732
+ * `$status` (always), `$error` (message), and `$code` (machine code) fields, plus
733
+ * an optional typed `data` body for declared errors. Missing `$error`/`$code` fall
734
+ * back to a status-derived message / `'ERROR'`.
735
+ */
736
+ function frameError(frame) {
737
+ const status = Number(frame.$status);
738
+ return new ApiError(status, typeof frame.$code === "string" ? frame.$code : "ERROR", typeof frame.$error === "string" ? frame.$error : STATUS_TEXT[status] ?? `Request failed with status ${status}`, frame.data);
739
+ }
740
+ /**
741
+ * Create a typed client from a {@link Manifest}.
742
+ *
743
+ * @typeParam S - the spec type, used purely for inference (no runtime schemas).
744
+ *
745
+ * @example
746
+ * ```ts
747
+ * const sdk = client<typeof api>({ baseUrl, manifest, ws })
748
+ * const user = await sdk.call('getUser', { id: 'u1' }) // fully typed
749
+ * for await (const row of sdk.call('streamRows', { n: 4 })) … // typed item stream
750
+ * const off = sdk.on('jobProgress', { jobId }, (d) => …) // typed event
751
+ * ```
752
+ */
753
+ function client(opts) {
754
+ const manifest = resolveManifest(opts.manifest);
755
+ const doFetch = opts.fetchImpl ?? ((req) => fetch(req));
756
+ const callerCtx = createCallerContext(opts.cache);
757
+ const baseHeaders = () => typeof opts.headers === "function" ? opts.headers() : opts.headers ?? {};
758
+ const vcfg = (name) => opts.validate?.endpoints[name]?.cfg;
759
+ const vParse = (name, data) => {
760
+ const c = vcfg(name);
761
+ return c?.response ? c.response.parse(data) : data;
762
+ };
763
+ const vParseItem = (name, item) => {
764
+ const c = vcfg(name);
765
+ return c && isSchema(c.streamOut) ? c.streamOut.parse(item) : item;
766
+ };
767
+ const vParseMulti = (name, status, data) => {
768
+ const schema = vcfg(name)?.responses?.[status];
769
+ return schema ? schema.parse(data) : data;
770
+ };
771
+ let frameSeq = 0;
772
+ const pending = /* @__PURE__ */ new Map();
773
+ const streamQueues = /* @__PURE__ */ new Map();
774
+ const listeners = /* @__PURE__ */ new Map();
775
+ /** per-call abort-listener removers, run when a call settles so signals don't leak listeners */
776
+ const abortCleanups = /* @__PURE__ */ new Map();
777
+ const runCleanup = (id) => {
778
+ const c = abortCleanups.get(id);
779
+ if (c) {
780
+ abortCleanups.delete(id);
781
+ c();
782
+ }
783
+ };
784
+ /**
785
+ * Wire `opts.signal` to a ws call: on abort, send an `{ id, abort: true }` frame
786
+ * and fail the local pending/queue. `fail` returns whether the call was still live.
787
+ */
788
+ const wireAbort = (id, signal, fail) => {
789
+ if (!signal) return;
790
+ const onAbort = () => {
791
+ abortCleanups.delete(id);
792
+ if (fail(signal.reason)) opts.ws?.send(JSON.stringify({
793
+ id,
794
+ abort: true
795
+ }));
796
+ };
797
+ if (signal.aborted) {
798
+ onAbort();
799
+ return;
800
+ }
801
+ signal.addEventListener("abort", onAbort, { once: true });
802
+ abortCleanups.set(id, () => signal.removeEventListener("abort", onAbort));
803
+ };
804
+ const canon = (v) => JSON.stringify(v ?? {}, Object.keys(v ?? {}).sort());
805
+ if (opts.ws) opts.ws.onMessage((raw) => {
806
+ const frame = JSON.parse(raw);
807
+ if (typeof frame.id !== "string") {
808
+ if (typeof frame.type === "string") {
809
+ const key = `${frame.type}|${canon(frame.params)}`;
810
+ for (const cb of listeners.get(key) ?? []) cb(frame.data);
811
+ }
812
+ return;
813
+ }
814
+ const errored = "$status" in frame && !is2xx(frame.$status);
815
+ if (streamQueues.has(frame.id)) {
816
+ const q = streamQueues.get(frame.id);
817
+ if ("chunk" in frame) q.push(frame.chunk);
818
+ else if (frame.end === true) {
819
+ streamQueues.delete(frame.id);
820
+ runCleanup(frame.id);
821
+ q.end();
822
+ } else if (errored) {
823
+ streamQueues.delete(frame.id);
824
+ runCleanup(frame.id);
825
+ q.fail(frameError(frame));
826
+ }
827
+ } else if (pending.has(frame.id)) {
828
+ const p = pending.get(frame.id);
829
+ pending.delete(frame.id);
830
+ runCleanup(frame.id);
831
+ if (errored) p.reject(frameError(frame));
832
+ else p.resolve(frame.data);
833
+ }
834
+ });
835
+ function clientQueue() {
836
+ const buf = [];
837
+ let done = false;
838
+ let err;
839
+ let wake = null;
840
+ return {
841
+ push(v) {
842
+ buf.push(v);
843
+ wake?.();
844
+ },
845
+ end() {
846
+ done = true;
847
+ wake?.();
848
+ },
849
+ fail(e) {
850
+ err = e;
851
+ done = true;
852
+ wake?.();
853
+ },
854
+ async *iterate() {
855
+ for (;;) {
856
+ if (buf.length > 0) {
857
+ yield buf.shift();
858
+ continue;
859
+ }
860
+ if (err !== void 0) throw err;
861
+ if (done) return;
862
+ await new Promise((r) => wake = r);
863
+ wake = null;
864
+ }
865
+ }
866
+ };
867
+ }
868
+ /** Pump a client item iterable to the server as chunk frames. */
869
+ function pumpItems(id, src) {
870
+ const iterable = typeof src === "function" ? src() : src;
871
+ (async () => {
872
+ try {
873
+ for await (const item of iterable) opts.ws.send(JSON.stringify({
874
+ id,
875
+ chunk: item
876
+ }));
877
+ opts.ws.send(JSON.stringify({
878
+ id,
879
+ end: true
880
+ }));
881
+ } catch {
882
+ opts.ws.send(JSON.stringify({
883
+ id,
884
+ end: true
885
+ }));
886
+ }
887
+ })();
888
+ }
889
+ /** the call frame: explicit ws id, or the un-injected url pattern + http method */
890
+ function callFrame(id, m, data) {
891
+ return JSON.stringify(m.ws !== null ? {
892
+ id,
893
+ type: m.ws,
894
+ data
895
+ } : {
896
+ id,
897
+ type: m.path,
898
+ method: m.method,
899
+ data
900
+ });
901
+ }
902
+ /** ws transport for typed item streams: chunk frames both directions. */
903
+ function wsStreamCall(name, m, data, stream, signal) {
904
+ if (!opts.ws) {
905
+ const err = /* @__PURE__ */ new Error("no websocket transport configured");
906
+ if (!m.items) return Promise.reject(err);
907
+ throw err;
908
+ }
909
+ const id = `c${++frameSeq}`;
910
+ if (m.items) {
911
+ const queue = clientQueue();
912
+ streamQueues.set(id, queue);
913
+ wireAbort(id, signal, (reason) => {
914
+ if (!streamQueues.delete(id)) return false;
915
+ queue.fail(reason);
916
+ return true;
917
+ });
918
+ opts.ws.send(callFrame(id, m, data));
919
+ if (m.itemsIn) pumpItems(id, stream);
920
+ return (async function* () {
921
+ for await (const item of queue.iterate()) yield vParseItem(name, item);
922
+ })();
923
+ }
924
+ const result = new Promise((resolve, reject) => {
925
+ pending.set(id, {
926
+ resolve,
927
+ reject
928
+ });
929
+ wireAbort(id, signal, (reason) => {
930
+ const p = pending.get(id);
931
+ if (!p) return false;
932
+ pending.delete(id);
933
+ p.reject(reason);
934
+ return true;
935
+ });
936
+ opts.ws.send(callFrame(id, m, data));
937
+ });
938
+ if (m.itemsIn) pumpItems(id, stream);
939
+ return result.then((d) => m.multi ? d : vParse(name, d));
940
+ }
941
+ function wsRequest(payload, signal) {
942
+ if (!opts.ws) return Promise.reject(/* @__PURE__ */ new Error("no websocket transport configured"));
943
+ const id = `c${++frameSeq}`;
944
+ return new Promise((resolve, reject) => {
945
+ pending.set(id, {
946
+ resolve,
947
+ reject
948
+ });
949
+ wireAbort(id, signal, (reason) => {
950
+ const p = pending.get(id);
951
+ if (!p) return false;
952
+ pending.delete(id);
953
+ p.reject(reason);
954
+ return true;
955
+ });
956
+ opts.ws.send(JSON.stringify({
957
+ id,
958
+ ...payload
959
+ }));
960
+ });
961
+ }
962
+ function splitData(m, data) {
963
+ if (m.b === "raw") return {
964
+ p: {},
965
+ q: {},
966
+ b: data,
967
+ f: {}
968
+ };
969
+ const p = {};
970
+ const q = {};
971
+ const bObj = {};
972
+ const f = {};
973
+ const pSet = new Set(m.p);
974
+ const qSet = new Set(m.q);
975
+ const bSet = new Set(m.b ?? []);
976
+ const fSet = new Set(m.f);
977
+ for (const [k, v] of Object.entries(data ?? {})) if (pSet.has(k)) p[k] = v;
978
+ else if (qSet.has(k)) q[k] = v;
979
+ else if (bSet.has(k)) bObj[k] = v;
980
+ else if (fSet.has(k)) f[k] = v;
981
+ else throw new Error(`key "${k}" does not belong to endpoint data`);
982
+ return {
983
+ p,
984
+ q,
985
+ b: m.hasBody ? bObj : void 0,
986
+ f
987
+ };
988
+ }
989
+ function buildUrl(m, p, q) {
990
+ const path = buildParts(splitPattern(m.path), p);
991
+ const url = new URL(path.slice(1), opts.baseUrl.endsWith("/") ? opts.baseUrl : opts.baseUrl + "/");
992
+ for (const [k, v] of Object.entries(q)) {
993
+ if (v === void 0) continue;
994
+ if (Array.isArray(v)) for (const item of v) url.searchParams.append(k, String(item));
995
+ else url.searchParams.set(k, String(v));
996
+ }
997
+ return url;
998
+ }
999
+ /** AsyncIterable (or generator function) of items → lazy NDJSON request body. */
1000
+ function encodeItems(src) {
1001
+ const it = (typeof src === "function" ? src() : src)[Symbol.asyncIterator]();
1002
+ const enc = new TextEncoder();
1003
+ return new ReadableStream({
1004
+ async pull(controller) {
1005
+ const { done, value } = await it.next();
1006
+ if (done) return controller.close();
1007
+ controller.enqueue(enc.encode(JSON.stringify(value) + "\n"));
1008
+ },
1009
+ async cancel() {
1010
+ await it.return?.(void 0);
1011
+ }
1012
+ });
1013
+ }
1014
+ async function httpRequest(m, data, callOpts) {
1015
+ const { p, q, b, f } = splitData(m, data);
1016
+ const stream = callOpts?.stream;
1017
+ const url = buildUrl(m, p, q);
1018
+ const headers = {
1019
+ ...baseHeaders(),
1020
+ ...callOpts?.headers ?? {}
1021
+ };
1022
+ let body = null;
1023
+ let duplex = false;
1024
+ if (m.streamIn) {
1025
+ headers["content-type"] = m.streamIn;
1026
+ if (m.itemsIn) {
1027
+ body = encodeItems(stream);
1028
+ duplex = true;
1029
+ } else {
1030
+ body = stream;
1031
+ duplex = stream instanceof ReadableStream;
1032
+ }
1033
+ } else if (m.f.length > 0) {
1034
+ const form = new FormData();
1035
+ if (b !== void 0) form.set("body", JSON.stringify(b));
1036
+ for (const [k, v] of Object.entries(f)) {
1037
+ if (v === void 0) continue;
1038
+ if (Array.isArray(v)) for (const file of v) form.append(k, file);
1039
+ else form.set(k, v);
1040
+ }
1041
+ body = form;
1042
+ } else if (m.hasBody) if (m.bodyEnc === "urlencoded") {
1043
+ headers["content-type"] = "application/x-www-form-urlencoded";
1044
+ const sp = new URLSearchParams();
1045
+ for (const [k, v] of Object.entries(b ?? {})) {
1046
+ if (v === void 0) continue;
1047
+ if (Array.isArray(v)) for (const item of v) sp.append(k, String(item));
1048
+ else sp.set(k, String(v));
1049
+ }
1050
+ body = sp.toString();
1051
+ } else {
1052
+ headers["content-type"] = "application/json";
1053
+ body = JSON.stringify(b);
1054
+ }
1055
+ const init = {
1056
+ method: m.method,
1057
+ headers,
1058
+ body,
1059
+ signal: callOpts?.signal
1060
+ };
1061
+ if (duplex) init.duplex = "half";
1062
+ const onProgress = callOpts?.onUploadProgress;
1063
+ const res = onProgress && !m.streamIn && !m.streamOut && typeof XMLHttpRequest !== "undefined" ? await xhrSend(m.method, url.toString(), headers, body, callOpts?.signal, onProgress) : await doFetch(new Request(url, init));
1064
+ if (!res.ok) {
1065
+ const errBody = await res.json().catch(() => ({}));
1066
+ const env = errBody?.error;
1067
+ throw new ApiError(res.status, env?.code ?? "ERROR", env?.message, errBody);
1068
+ }
1069
+ return res;
1070
+ }
1071
+ async function httpCall(name, m, data, callOpts) {
1072
+ const res = await httpRequest(m, data, callOpts);
1073
+ if (m.streamOut) return res.body ?? new ReadableStream({ start: (c) => c.close() });
1074
+ if (m.multi) return {
1075
+ status: res.status,
1076
+ data: vParseMulti(name, res.status, await res.json())
1077
+ };
1078
+ if (res.status === 204) return;
1079
+ return vParse(name, await res.json());
1080
+ }
1081
+ /** Lazy NDJSON/SSE consumer — the request fires on first pull, items decode as they arrive. */
1082
+ async function* iterateItems(name, m, data, callOpts) {
1083
+ const res = await httpRequest(m, data, callOpts);
1084
+ if (!res.body) return;
1085
+ const sse = m.streamOut === "text/event-stream";
1086
+ const sep = sse ? "\n\n" : "\n";
1087
+ const decodeLine = (chunk) => {
1088
+ const text = sse ? chunk.split("\n").filter((l) => l.startsWith("data:")).map((l) => l.slice(5).trimStart()).join("\n") : chunk;
1089
+ if (!text.trim()) return;
1090
+ return vParseItem(name, JSON.parse(text));
1091
+ };
1092
+ const reader = res.body.getReader();
1093
+ const dec = new TextDecoder();
1094
+ let buf = "";
1095
+ try {
1096
+ for (;;) {
1097
+ const { done, value } = await reader.read();
1098
+ if (done) break;
1099
+ buf += dec.decode(value, { stream: true });
1100
+ let nl;
1101
+ while ((nl = buf.indexOf(sep)) >= 0) {
1102
+ const chunk = buf.slice(0, nl);
1103
+ buf = buf.slice(nl + sep.length);
1104
+ const item = decodeLine(chunk);
1105
+ if (item !== void 0) yield item;
1106
+ }
1107
+ }
1108
+ buf += dec.decode();
1109
+ const item = decodeLine(buf);
1110
+ if (item !== void 0) yield item;
1111
+ } finally {
1112
+ await reader.cancel().catch(() => {});
1113
+ }
1114
+ }
1115
+ function call(name, ...rest) {
1116
+ const m = manifest.endpoints[name];
1117
+ if (!m) return Promise.reject(/* @__PURE__ */ new Error(`unknown endpoint "${name}"`));
1118
+ const hasData = m.p.length > 0 || m.q.length > 0 || m.hasBody || m.f.length > 0 || m.streamIn !== null;
1119
+ const data = hasData ? rest[0] : void 0;
1120
+ const callOpts = hasData ? rest[1] : rest[0];
1121
+ const transport = callOpts?.transport ?? (opts.prefer === "ws" && !m.httpOnly && opts.ws ? "ws" : "http");
1122
+ if (transport === "ws" && m.httpOnly) {
1123
+ const err = /* @__PURE__ */ new Error(`endpoint "${name}" is http-only`);
1124
+ if (m.items) throw err;
1125
+ return Promise.reject(err);
1126
+ }
1127
+ if (m.items || m.itemsIn) {
1128
+ if (transport === "ws") return wsStreamCall(name, m, data, callOpts?.stream, callOpts?.signal);
1129
+ if (m.items) return iterateItems(name, m, data, callOpts);
1130
+ return httpCall(name, m, data, callOpts);
1131
+ }
1132
+ if (transport === "ws") return wsRequest(m.ws !== null ? {
1133
+ type: m.ws,
1134
+ data
1135
+ } : {
1136
+ type: m.path,
1137
+ method: m.method,
1138
+ data
1139
+ }, callOpts?.signal).then((d) => m.multi ? d : vParse(name, d));
1140
+ return httpCall(name, m, data, callOpts);
1141
+ }
1142
+ function url(name, ...rest) {
1143
+ const m = manifest.endpoints[name];
1144
+ if (!m) throw new Error(`unknown endpoint "${name}"`);
1145
+ if (m.method !== "GET") throw new Error(`url() requires a GET endpoint; "${name}" is ${m.method}`);
1146
+ const { p, q } = splitData(m, rest[0]);
1147
+ return buildUrl(m, p, q).toString();
1148
+ }
1149
+ function on(name, ...rest) {
1150
+ const ev = manifest.events[name];
1151
+ if (!ev) throw new Error(`unknown event "${name}"`);
1152
+ if (!opts.ws) throw new Error("no websocket transport configured");
1153
+ const params = ev.hasParams ? rest[0] : void 0;
1154
+ const cb = ev.hasParams ? rest[1] : rest[0];
1155
+ const key = `${ev.ws}|${canon(params)}`;
1156
+ let set = listeners.get(key);
1157
+ if (!set) listeners.set(key, set = /* @__PURE__ */ new Set());
1158
+ set.add(cb);
1159
+ wsRequest({
1160
+ sub: ev.ws,
1161
+ params
1162
+ });
1163
+ return () => {
1164
+ set.delete(cb);
1165
+ if (set.size === 0) wsRequest({
1166
+ unsub: ev.ws,
1167
+ params
1168
+ });
1169
+ };
1170
+ }
1171
+ function caller(name, options) {
1172
+ const m = manifest.endpoints[name];
1173
+ if (!m) throw new Error(`unknown endpoint "${name}"`);
1174
+ return makeCaller(name, m, (...args) => call(name, ...args), callerCtx, options ?? {});
1175
+ }
1176
+ return {
1177
+ call,
1178
+ on,
1179
+ url,
1180
+ caller
1181
+ };
1182
+ }
1183
+ //#endregion
1184
+ //#region src/ws-transport.ts
1185
+ /** Reconnect backoff defaults: 500ms → … → 30s, doubling, with jitter. */
1186
+ const DEFAULT_BACKOFF = {
1187
+ initial: 500,
1188
+ max: 3e4,
1189
+ factor: 2,
1190
+ jitter: true
1191
+ };
1192
+ /** Heartbeat defaults: ping every 30s, expect a pong within 10s. */
1193
+ const DEFAULT_HEARTBEAT = {
1194
+ interval: 3e4,
1195
+ timeout: 1e4
1196
+ };
1197
+ /** Synthetic status for locally-failed calls (disconnected / never sent) — there is no HTTP response. */
1198
+ const DISCONNECTED_STATUS = 0;
1199
+ /**
1200
+ * Create a resilient {@link WsTransport} for {@link client}'s `ws` option.
1201
+ *
1202
+ * @param url - the WebSocket URL (e.g. `wss://host/ws`), or a function returning
1203
+ * it. The function form is **resolved at each (re)connect**, so it's the place
1204
+ * to inject auth that isn't known up front — e.g. a token as a query param:
1205
+ * `wsTransport(() => \`wss://host/ws?access_token=${getToken()}\`)`. (Browsers
1206
+ * can't set headers on a ws handshake, so the token rides the URL or a subprotocol.)
1207
+ * @param opts - reconnect / heartbeat / policy tuning.
1208
+ */
1209
+ function wsTransport(url, opts = {}) {
1210
+ const maybeWS = opts.WebSocket ?? globalThis.WebSocket;
1211
+ if (!maybeWS) throw new Error("wsTransport: no WebSocket implementation available; pass opts.WebSocket");
1212
+ const WS = maybeWS;
1213
+ const policy = opts.whileDisconnected ?? "fail";
1214
+ const bo = {
1215
+ ...DEFAULT_BACKOFF,
1216
+ ...opts.backoff
1217
+ };
1218
+ const hb = opts.heartbeat === false ? null : {
1219
+ ...DEFAULT_HEARTBEAT,
1220
+ ...opts.heartbeat ?? {}
1221
+ };
1222
+ const maxRetries = opts.maxRetries ?? Infinity;
1223
+ let sock = null;
1224
+ let state = "closed";
1225
+ let messageCb = null;
1226
+ let retries = 0;
1227
+ let everConnected = false;
1228
+ let manualClose = false;
1229
+ let reconnectTimer = null;
1230
+ let hbInterval = null;
1231
+ let hbDeadline = null;
1232
+ const outbox = [];
1233
+ const subs = /* @__PURE__ */ new Map();
1234
+ const openCalls = /* @__PURE__ */ new Set();
1235
+ const setState = (s) => {
1236
+ if (s === state) return;
1237
+ state = s;
1238
+ opts.onStateChange?.(s);
1239
+ };
1240
+ const deliver = (raw) => messageCb?.(raw);
1241
+ const canon = (v) => JSON.stringify(v ?? {}, Object.keys(v ?? {}).sort());
1242
+ /** Synthesize disconnect errors so the client rejects in-flight pendings / fails item streams. */
1243
+ function failOpenCalls() {
1244
+ for (const id of openCalls) deliver(JSON.stringify({
1245
+ id,
1246
+ $status: DISCONNECTED_STATUS,
1247
+ $code: "DISCONNECTED",
1248
+ $error: "connection closed"
1249
+ }));
1250
+ openCalls.clear();
1251
+ }
1252
+ function clearHeartbeat() {
1253
+ if (hbInterval) clearInterval(hbInterval);
1254
+ if (hbDeadline) clearTimeout(hbDeadline);
1255
+ hbInterval = hbDeadline = null;
1256
+ }
1257
+ function startHeartbeat() {
1258
+ if (!hb) return;
1259
+ clearHeartbeat();
1260
+ hbInterval = setInterval(() => {
1261
+ if (state !== "open" || !sock) return;
1262
+ sock.send(JSON.stringify({ ping: true }));
1263
+ if (hbDeadline) clearTimeout(hbDeadline);
1264
+ hbDeadline = setTimeout(() => sock?.close(), hb.timeout);
1265
+ }, hb.interval);
1266
+ }
1267
+ function onMessage(raw) {
1268
+ let f = null;
1269
+ try {
1270
+ f = JSON.parse(raw);
1271
+ } catch {
1272
+ deliver(raw);
1273
+ return;
1274
+ }
1275
+ if (f && f.pong === true) {
1276
+ if (hbDeadline) {
1277
+ clearTimeout(hbDeadline);
1278
+ hbDeadline = null;
1279
+ }
1280
+ return;
1281
+ }
1282
+ if (f && typeof f.id === "string" && ("$status" in f || f.end === true)) openCalls.delete(f.id);
1283
+ deliver(raw);
1284
+ }
1285
+ function onOpen() {
1286
+ if (manualClose) return;
1287
+ everConnected = true;
1288
+ retries = 0;
1289
+ setState("open");
1290
+ for (const raw of subs.values()) sock?.send(raw);
1291
+ const queued = outbox.splice(0);
1292
+ for (const raw of queued) sock?.send(raw);
1293
+ startHeartbeat();
1294
+ }
1295
+ function onClose() {
1296
+ clearHeartbeat();
1297
+ sock = null;
1298
+ failOpenCalls();
1299
+ if (manualClose) {
1300
+ setState("closed");
1301
+ return;
1302
+ }
1303
+ scheduleReconnect();
1304
+ }
1305
+ function scheduleReconnect() {
1306
+ if (manualClose) return;
1307
+ if (retries >= maxRetries) {
1308
+ setState("closed");
1309
+ return;
1310
+ }
1311
+ const base = Math.min(bo.max, bo.initial * Math.pow(bo.factor, retries));
1312
+ const delay = bo.jitter ? base / 2 + Math.random() * (base / 2) : base;
1313
+ retries++;
1314
+ setState("connecting");
1315
+ reconnectTimer = setTimeout(connect, delay);
1316
+ }
1317
+ function connect() {
1318
+ if (manualClose || sock) return;
1319
+ if (reconnectTimer) {
1320
+ clearTimeout(reconnectTimer);
1321
+ reconnectTimer = null;
1322
+ }
1323
+ setState("connecting");
1324
+ let s;
1325
+ try {
1326
+ s = new WS(typeof url === "function" ? url() : url, typeof opts.protocols === "function" ? opts.protocols() : opts.protocols);
1327
+ } catch (err) {
1328
+ opts.onError?.(err);
1329
+ scheduleReconnect();
1330
+ return;
1331
+ }
1332
+ sock = s;
1333
+ s.addEventListener("open", () => onOpen());
1334
+ s.addEventListener("message", (ev) => onMessage(String(ev.data)));
1335
+ s.addEventListener("close", () => onClose());
1336
+ s.addEventListener("error", (ev) => opts.onError?.(ev));
1337
+ }
1338
+ function send(frame) {
1339
+ let f = {};
1340
+ try {
1341
+ f = JSON.parse(frame);
1342
+ } catch {}
1343
+ if (typeof f.sub === "string") subs.set(`${f.sub}|${canon(f.params)}`, frame);
1344
+ else if (typeof f.unsub === "string") subs.delete(`${f.unsub}|${canon(f.params)}`);
1345
+ if (typeof f.id === "string" && typeof f.type === "string") openCalls.add(f.id);
1346
+ const isSubCtl = typeof f.sub === "string" || typeof f.unsub === "string";
1347
+ if (state === "open" && sock) {
1348
+ sock.send(frame);
1349
+ return;
1350
+ }
1351
+ connect();
1352
+ if (isSubCtl) return;
1353
+ if (!everConnected || policy === "queue") outbox.push(frame);
1354
+ else if (typeof f.id === "string") deliver(JSON.stringify({
1355
+ id: f.id,
1356
+ $status: DISCONNECTED_STATUS,
1357
+ $code: "DISCONNECTED",
1358
+ $error: "not connected"
1359
+ }));
1360
+ }
1361
+ return {
1362
+ send,
1363
+ onMessage(cb) {
1364
+ messageCb = cb;
1365
+ },
1366
+ connect,
1367
+ close() {
1368
+ manualClose = true;
1369
+ if (reconnectTimer) clearTimeout(reconnectTimer);
1370
+ reconnectTimer = null;
1371
+ clearHeartbeat();
1372
+ failOpenCalls();
1373
+ sock?.close();
1374
+ sock = null;
1375
+ setState("closed");
1376
+ },
1377
+ get state() {
1378
+ return state;
1379
+ }
1380
+ };
1381
+ }
1382
+ //#endregion
1383
+ Object.defineProperty(exports, "ApiError", {
1384
+ enumerable: true,
1385
+ get: function() {
1386
+ return ApiError;
1387
+ }
1388
+ });
1389
+ Object.defineProperty(exports, "ApiFailure", {
1390
+ enumerable: true,
1391
+ get: function() {
1392
+ return ApiFailure;
1393
+ }
1394
+ });
1395
+ Object.defineProperty(exports, "CallerRateLimited", {
1396
+ enumerable: true,
1397
+ get: function() {
1398
+ return CallerRateLimited;
1399
+ }
1400
+ });
1401
+ Object.defineProperty(exports, "buildParts", {
1402
+ enumerable: true,
1403
+ get: function() {
1404
+ return buildParts;
1405
+ }
1406
+ });
1407
+ Object.defineProperty(exports, "client", {
1408
+ enumerable: true,
1409
+ get: function() {
1410
+ return client;
1411
+ }
1412
+ });
1413
+ Object.defineProperty(exports, "createCallerContext", {
1414
+ enumerable: true,
1415
+ get: function() {
1416
+ return createCallerContext;
1417
+ }
1418
+ });
1419
+ Object.defineProperty(exports, "createClientCache", {
1420
+ enumerable: true,
1421
+ get: function() {
1422
+ return createClientCache;
1423
+ }
1424
+ });
1425
+ Object.defineProperty(exports, "joinPattern", {
1426
+ enumerable: true,
1427
+ get: function() {
1428
+ return joinPattern;
1429
+ }
1430
+ });
1431
+ Object.defineProperty(exports, "matchParts", {
1432
+ enumerable: true,
1433
+ get: function() {
1434
+ return matchParts;
1435
+ }
1436
+ });
1437
+ Object.defineProperty(exports, "paramKeys", {
1438
+ enumerable: true,
1439
+ get: function() {
1440
+ return paramKeys;
1441
+ }
1442
+ });
1443
+ Object.defineProperty(exports, "path", {
1444
+ enumerable: true,
1445
+ get: function() {
1446
+ return path;
1447
+ }
1448
+ });
1449
+ Object.defineProperty(exports, "reject", {
1450
+ enumerable: true,
1451
+ get: function() {
1452
+ return reject;
1453
+ }
1454
+ });
1455
+ Object.defineProperty(exports, "splitPattern", {
1456
+ enumerable: true,
1457
+ get: function() {
1458
+ return splitPattern;
1459
+ }
1460
+ });
1461
+ Object.defineProperty(exports, "stableStringify", {
1462
+ enumerable: true,
1463
+ get: function() {
1464
+ return stableStringify;
1465
+ }
1466
+ });
1467
+ Object.defineProperty(exports, "wsTransport", {
1468
+ enumerable: true,
1469
+ get: function() {
1470
+ return wsTransport;
1471
+ }
1472
+ });