@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.
- package/LICENSE +21 -0
- package/README.md +89 -0
- package/dist/client/index.cjs +5 -0
- package/dist/client/index.d.cts +2 -0
- package/dist/client/index.d.ts +2 -0
- package/dist/client/index.js +2 -0
- package/dist/doer.cjs +110 -0
- package/dist/doer.d.cts +75 -0
- package/dist/doer.d.ts +75 -0
- package/dist/doer.js +106 -0
- package/dist/errors.d.cts +1729 -0
- package/dist/errors.d.ts +1729 -0
- package/dist/index.cjs +2004 -0
- package/dist/index.d.cts +5 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +1968 -0
- package/dist/retry.cjs +135 -0
- package/dist/retry.d.cts +90 -0
- package/dist/retry.d.ts +90 -0
- package/dist/retry.js +129 -0
- package/dist/stats.cjs +0 -0
- package/dist/stats.d.cts +150 -0
- package/dist/stats.d.ts +150 -0
- package/dist/stats.js +0 -0
- package/dist/types.d.cts +54 -0
- package/dist/types.d.ts +54 -0
- package/dist/ws-transport.cjs +1472 -0
- package/dist/ws-transport.js +1383 -0
- package/package.json +110 -0
|
@@ -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
|
+
});
|