@fuzdev/fuz_app 0.23.0 → 0.25.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/dist/actions/action_codegen.d.ts +25 -0
- package/dist/actions/action_codegen.d.ts.map +1 -1
- package/dist/actions/action_codegen.js +39 -0
- package/dist/actions/action_peer.d.ts +7 -0
- package/dist/actions/action_peer.d.ts.map +1 -1
- package/dist/actions/action_peer.js +1 -1
- package/dist/actions/action_types.d.ts +72 -0
- package/dist/actions/action_types.d.ts.map +1 -0
- package/dist/actions/action_types.js +11 -0
- package/dist/actions/cancel.d.ts +78 -0
- package/dist/actions/cancel.d.ts.map +1 -0
- package/dist/actions/cancel.js +79 -0
- package/dist/actions/heartbeat.d.ts +51 -0
- package/dist/actions/heartbeat.d.ts.map +1 -0
- package/dist/actions/heartbeat.js +50 -0
- package/dist/actions/register_action_ws.d.ts +28 -30
- package/dist/actions/register_action_ws.d.ts.map +1 -1
- package/dist/actions/register_action_ws.js +103 -20
- package/dist/actions/rpc_client.d.ts +10 -0
- package/dist/actions/rpc_client.d.ts.map +1 -1
- package/dist/actions/rpc_client.js +22 -7
- package/dist/actions/socket.svelte.d.ts +88 -4
- package/dist/actions/socket.svelte.d.ts.map +1 -1
- package/dist/actions/socket.svelte.js +322 -6
- package/dist/actions/transports.d.ts +18 -3
- package/dist/actions/transports.d.ts.map +1 -1
- package/dist/actions/transports.js +4 -0
- package/dist/actions/transports_http.d.ts +3 -3
- package/dist/actions/transports_http.d.ts.map +1 -1
- package/dist/actions/transports_http.js +4 -3
- package/dist/actions/transports_ws.d.ts +33 -6
- package/dist/actions/transports_ws.d.ts.map +1 -1
- package/dist/actions/transports_ws.js +43 -46
- package/dist/actions/transports_ws_backend.d.ts +12 -3
- package/dist/actions/transports_ws_backend.d.ts.map +1 -1
- package/dist/actions/transports_ws_backend.js +12 -1
- package/dist/auth/bearer_auth.js +0 -1
- package/dist/auth/keyring.d.ts.map +1 -1
- package/dist/auth/keyring.js +0 -2
- package/dist/auth/migrations.js +4 -4
- package/dist/db/migrate.d.ts +12 -2
- package/dist/db/migrate.d.ts.map +1 -1
- package/dist/db/migrate.js +25 -16
- package/dist/db/status.d.ts.map +1 -1
- package/dist/db/status.js +0 -2
- package/dist/dev/setup.js +2 -2
- package/dist/http/db_routes.d.ts.map +1 -1
- package/dist/http/db_routes.js +0 -1
- package/dist/testing/admin_integration.d.ts.map +1 -1
- package/dist/testing/admin_integration.js +0 -3
- package/dist/testing/app_server.js +1 -1
- package/dist/testing/data_exposure.js +6 -8
- package/dist/testing/db.js +1 -1
- package/dist/testing/integration.js +0 -1
- package/dist/testing/rate_limiting.d.ts.map +1 -1
- package/dist/testing/rate_limiting.js +0 -6
- package/dist/testing/rpc_round_trip.js +4 -4
- package/dist/testing/sse_round_trip.d.ts.map +1 -1
- package/dist/testing/sse_round_trip.js +1 -2
- package/dist/testing/ws_round_trip.d.ts +15 -3
- package/dist/testing/ws_round_trip.d.ts.map +1 -1
- package/dist/testing/ws_round_trip.js +3 -3
- package/package.json +2 -2
|
@@ -5,16 +5,29 @@
|
|
|
5
5
|
* Drop into any SvelteKit frontend as the underlying connection for
|
|
6
6
|
* `FrontendWebsocketTransport`. Handles auto-reconnect with exponential
|
|
7
7
|
* backoff, respects `WS_CLOSE_SESSION_REVOKED` (no reconnect loop after the
|
|
8
|
-
* server revokes auth),
|
|
8
|
+
* server revokes auth), exposes reactive status for UI indicators, and ships
|
|
9
|
+
* three correctness primitives default-on:
|
|
9
10
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
11
|
+
* - {@link FrontendWebsocketClient.request} — promise-based JSON-RPC with
|
|
12
|
+
* auto-assigned ids and a pending-id map. Intercepts responses on the
|
|
13
|
+
* message path so request/response correlation is transport-level rather
|
|
14
|
+
* than re-invented per consumer.
|
|
15
|
+
* - **Durable queue** — `request()` calls made while disconnected buffer up
|
|
16
|
+
* to {@link DEFAULT_QUEUE_MAX_SIZE} requests and flush on reopen. Overflow
|
|
17
|
+
* rejects with `queue_overflow`. Raw {@link FrontendWebsocketClient.send}
|
|
18
|
+
* is drop-on-disconnect (fire-and-forget notifications want that).
|
|
19
|
+
* - **Activity-aware heartbeat** — idles fire a shared `heartbeat` request;
|
|
20
|
+
* receive-silence past {@link DEFAULT_HEARTBEAT_RECEIVE_TIMEOUT} closes
|
|
21
|
+
* with {@link WS_CLOSE_CLIENT_HEARTBEAT_TIMEOUT} and lets auto-reconnect
|
|
22
|
+
* pick back up.
|
|
13
23
|
*
|
|
14
24
|
* @module
|
|
15
25
|
*/
|
|
16
26
|
import { BROWSER } from 'esm-env';
|
|
17
|
-
import {
|
|
27
|
+
import { JSONRPC_VERSION } from '../http/jsonrpc.js';
|
|
28
|
+
import { WS_CLOSE_CLIENT_HEARTBEAT_TIMEOUT, WS_CLOSE_SESSION_REVOKED } from './transports.js';
|
|
29
|
+
import { CANCEL_METHOD } from './cancel.js';
|
|
30
|
+
import { HEARTBEAT_METHOD } from './heartbeat.js';
|
|
18
31
|
/** Default WebSocket close code (normal closure). */
|
|
19
32
|
export const DEFAULT_CLOSE_CODE = 1000;
|
|
20
33
|
/** Base reconnect delay in ms. */
|
|
@@ -23,6 +36,12 @@ export const DEFAULT_RECONNECT_DELAY = 1000;
|
|
|
23
36
|
export const DEFAULT_RECONNECT_DELAY_MAX = 10000;
|
|
24
37
|
/** Exponential backoff factor: delay = base * factor^(attempt-1). */
|
|
25
38
|
export const DEFAULT_BACKOFF_FACTOR = 1.5;
|
|
39
|
+
/** Idle interval before sending a heartbeat (ms). */
|
|
40
|
+
export const DEFAULT_HEARTBEAT_INTERVAL = 30_000;
|
|
41
|
+
/** Max receive silence before closing with {@link WS_CLOSE_CLIENT_HEARTBEAT_TIMEOUT} (ms). */
|
|
42
|
+
export const DEFAULT_HEARTBEAT_RECEIVE_TIMEOUT = 60_000;
|
|
43
|
+
/** Default bound on buffered requests while disconnected. Overflow rejects. */
|
|
44
|
+
export const DEFAULT_QUEUE_MAX_SIZE = 100;
|
|
26
45
|
/**
|
|
27
46
|
* Reactive WebSocket client implementing `WebsocketConnection`.
|
|
28
47
|
*
|
|
@@ -40,7 +59,20 @@ export class FrontendWebsocketClient {
|
|
|
40
59
|
#reconnect_delay;
|
|
41
60
|
#reconnect_delay_max;
|
|
42
61
|
#backoff_factor;
|
|
62
|
+
#heartbeat_enabled;
|
|
63
|
+
#heartbeat_interval;
|
|
64
|
+
#heartbeat_receive_timeout;
|
|
65
|
+
#queue_enabled;
|
|
66
|
+
#queue_max_size;
|
|
43
67
|
#log;
|
|
68
|
+
#next_request_id = 0;
|
|
69
|
+
#pending = new Map();
|
|
70
|
+
#queue = [];
|
|
71
|
+
#heartbeat_timer = null;
|
|
72
|
+
/** Epoch ms of the last outgoing send — used by the heartbeat activity check. */
|
|
73
|
+
#last_send_time = null;
|
|
74
|
+
/** Epoch ms of the last incoming message — used by the heartbeat activity check. */
|
|
75
|
+
#last_receive_time = null;
|
|
44
76
|
ws = $state.raw(null);
|
|
45
77
|
status = $state.raw('initial');
|
|
46
78
|
reconnect_count = $state.raw(0);
|
|
@@ -77,6 +109,16 @@ export class FrontendWebsocketClient {
|
|
|
77
109
|
this.#reconnect_delay = config.delay ?? DEFAULT_RECONNECT_DELAY;
|
|
78
110
|
this.#reconnect_delay_max = config.delay_max ?? DEFAULT_RECONNECT_DELAY_MAX;
|
|
79
111
|
this.#backoff_factor = config.factor ?? DEFAULT_BACKOFF_FACTOR;
|
|
112
|
+
const heartbeat = options.heartbeat;
|
|
113
|
+
this.#heartbeat_enabled = heartbeat !== false;
|
|
114
|
+
const heartbeat_config = typeof heartbeat === 'object' ? heartbeat : {};
|
|
115
|
+
this.#heartbeat_interval = heartbeat_config.interval ?? DEFAULT_HEARTBEAT_INTERVAL;
|
|
116
|
+
this.#heartbeat_receive_timeout =
|
|
117
|
+
heartbeat_config.receive_timeout ?? DEFAULT_HEARTBEAT_RECEIVE_TIMEOUT;
|
|
118
|
+
const queue = options.queue;
|
|
119
|
+
this.#queue_enabled = queue !== false;
|
|
120
|
+
const queue_config = typeof queue === 'object' ? queue : {};
|
|
121
|
+
this.#queue_max_size = queue_config.max_size ?? DEFAULT_QUEUE_MAX_SIZE;
|
|
80
122
|
this.#log = options.log ?? null;
|
|
81
123
|
}
|
|
82
124
|
/**
|
|
@@ -184,10 +226,12 @@ export class FrontendWebsocketClient {
|
|
|
184
226
|
*/
|
|
185
227
|
disconnect(code = DEFAULT_CLOSE_CODE) {
|
|
186
228
|
this.#cancel_reconnect();
|
|
229
|
+
this.#cancel_heartbeat();
|
|
187
230
|
this.#teardown(code);
|
|
188
231
|
this.status = 'closed';
|
|
189
232
|
this.reconnect_count = 0;
|
|
190
233
|
this.current_reconnect_delay = 0;
|
|
234
|
+
this.#reject_all('client disconnected');
|
|
191
235
|
}
|
|
192
236
|
/** Explicit-resource-management hook — supports `using client = new FrontendWebsocketClient(url)`. */
|
|
193
237
|
[Symbol.dispose]() {
|
|
@@ -199,6 +243,7 @@ export class FrontendWebsocketClient {
|
|
|
199
243
|
try {
|
|
200
244
|
this.ws.send(JSON.stringify(data));
|
|
201
245
|
this.last_send_error = null;
|
|
246
|
+
this.#last_send_time = Date.now();
|
|
202
247
|
return true;
|
|
203
248
|
}
|
|
204
249
|
catch (error) {
|
|
@@ -207,6 +252,101 @@ export class FrontendWebsocketClient {
|
|
|
207
252
|
return false;
|
|
208
253
|
}
|
|
209
254
|
}
|
|
255
|
+
/**
|
|
256
|
+
* Promise-based JSON-RPC over the socket. Auto-assigns a monotonic request
|
|
257
|
+
* id (or uses an explicit one supplied via `options.id` — used by
|
|
258
|
+
* `FrontendWebsocketTransport` which delegates to this method and has its
|
|
259
|
+
* own peer-minted UUID), tracks the pending promise, and resolves when the
|
|
260
|
+
* server sends a matching response (or rejects on error frame, socket
|
|
261
|
+
* close, or aborted signal).
|
|
262
|
+
*
|
|
263
|
+
* Callers supplying an explicit `options.id` are responsible for
|
|
264
|
+
* uniqueness — the pending map is keyed by id, and a duplicate silently
|
|
265
|
+
* overwrites the prior entry. Auto-minted ids are monotonic and never
|
|
266
|
+
* collide with themselves or with peer-minted UUIDs (the types differ:
|
|
267
|
+
* integer vs string).
|
|
268
|
+
*
|
|
269
|
+
* While the socket is disconnected, the request is buffered in a bounded
|
|
270
|
+
* queue (default-on, `DEFAULT_QUEUE_MAX_SIZE`) and flushed on reopen. Pass
|
|
271
|
+
* `{queue: false}` to reject immediately when disconnected — used
|
|
272
|
+
* internally by the heartbeat, which must not fight the queue for the
|
|
273
|
+
* disconnect-detection slot.
|
|
274
|
+
*
|
|
275
|
+
* On `AbortSignal` fire: rejects the local promise *and* sends the shared
|
|
276
|
+
* `cancel` notification (`CANCEL_METHOD`) so the server-side dispatcher
|
|
277
|
+
* can abort the matching handler's `ctx.signal`. Suppressed for
|
|
278
|
+
* queued-but-never-sent (server doesn't know about it) and
|
|
279
|
+
* response-beat-cancel races.
|
|
280
|
+
*/
|
|
281
|
+
request(method, params = {}, options = {}) {
|
|
282
|
+
return new Promise((resolve, reject) => {
|
|
283
|
+
const resolve_typed = resolve;
|
|
284
|
+
const reject_typed = reject;
|
|
285
|
+
if (this.#revoked) {
|
|
286
|
+
reject_typed(new Error('[socket] session revoked'));
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
const { signal = null } = options;
|
|
290
|
+
if (signal?.aborted) {
|
|
291
|
+
reject_typed(this.#build_abort_error(method));
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
const id = options.id ?? ++this.#next_request_id;
|
|
295
|
+
const frame = { jsonrpc: JSONRPC_VERSION, id, method, params };
|
|
296
|
+
// Bind the signal listener up-front so `#detach_signal` can find it by
|
|
297
|
+
// reference regardless of which settlement path runs (inline send,
|
|
298
|
+
// queued flush, close-time reject).
|
|
299
|
+
let pending = null;
|
|
300
|
+
const signal_handler = signal
|
|
301
|
+
? () => {
|
|
302
|
+
if (!pending)
|
|
303
|
+
return;
|
|
304
|
+
// `Map.delete` returns true iff the entry existed — which
|
|
305
|
+
// is our signal that the frame was actually written to
|
|
306
|
+
// the socket (pending-only tracks in-flight). If it was
|
|
307
|
+
// only queued (never sent), the server doesn't know
|
|
308
|
+
// about it and doesn't need a cancel. If the response
|
|
309
|
+
// beat the abort, `#handle_message` already deleted the
|
|
310
|
+
// entry and detached this listener, so this closure
|
|
311
|
+
// never runs in that race.
|
|
312
|
+
const was_in_flight = this.#pending.delete(id);
|
|
313
|
+
this.#drop_queued(id);
|
|
314
|
+
this.#detach_signal(pending);
|
|
315
|
+
pending = null;
|
|
316
|
+
reject_typed(this.#build_abort_error(method));
|
|
317
|
+
if (was_in_flight)
|
|
318
|
+
this.#send_cancel(id);
|
|
319
|
+
}
|
|
320
|
+
: null;
|
|
321
|
+
if (signal && signal_handler)
|
|
322
|
+
signal.addEventListener('abort', signal_handler);
|
|
323
|
+
pending = { method, resolve: resolve_typed, reject: reject_typed, signal, signal_handler };
|
|
324
|
+
const should_queue = options.queue !== false && this.#queue_enabled;
|
|
325
|
+
if (this.connected && this.ws) {
|
|
326
|
+
const sent = this.send(frame);
|
|
327
|
+
if (sent) {
|
|
328
|
+
this.#pending.set(id, pending);
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
// Send failed mid-connected (serialization, buffer full). Requeue if
|
|
332
|
+
// the queue is on, otherwise reject — this socket is in an odd
|
|
333
|
+
// state but the caller asked for non-durable semantics.
|
|
334
|
+
if (should_queue) {
|
|
335
|
+
this.#enqueue({ ...pending, id, frame });
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
this.#detach_signal(pending);
|
|
339
|
+
reject_typed(new Error(`[socket] send failed for ${method}`));
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
if (should_queue) {
|
|
343
|
+
this.#enqueue({ ...pending, id, frame });
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
this.#detach_signal(pending);
|
|
347
|
+
reject_typed(new Error(`[socket] not connected (method=${method})`));
|
|
348
|
+
});
|
|
349
|
+
}
|
|
210
350
|
add_message_handler(handler) {
|
|
211
351
|
this.#message_handlers.add(handler);
|
|
212
352
|
return () => this.#message_handlers.delete(handler);
|
|
@@ -215,6 +355,137 @@ export class FrontendWebsocketClient {
|
|
|
215
355
|
this.#error_handlers.add(handler);
|
|
216
356
|
return () => this.#error_handlers.delete(handler);
|
|
217
357
|
}
|
|
358
|
+
#build_abort_error(method) {
|
|
359
|
+
return new Error(`[socket] request aborted (method=${method})`);
|
|
360
|
+
}
|
|
361
|
+
/**
|
|
362
|
+
* Fire-and-forget cancel notification to the server. The dispatcher
|
|
363
|
+
* looks up the matching pending handler's per-request `AbortController`
|
|
364
|
+
* and aborts it; unknown ids no-op. Drops silently when disconnected —
|
|
365
|
+
* the server's own socket-close path will abort any in-flight handlers.
|
|
366
|
+
*/
|
|
367
|
+
#send_cancel(request_id) {
|
|
368
|
+
this.send({
|
|
369
|
+
jsonrpc: JSONRPC_VERSION,
|
|
370
|
+
method: CANCEL_METHOD,
|
|
371
|
+
params: { request_id },
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
#detach_signal(pending) {
|
|
375
|
+
if (pending.signal && pending.signal_handler) {
|
|
376
|
+
pending.signal.removeEventListener('abort', pending.signal_handler);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
#enqueue(queued) {
|
|
380
|
+
if (this.#queue.length >= this.#queue_max_size) {
|
|
381
|
+
this.#detach_signal(queued);
|
|
382
|
+
queued.reject(new Error(`[socket] request queue overflow (method=${queued.method}, max=${this.#queue_max_size})`));
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
this.#queue.push(queued);
|
|
386
|
+
}
|
|
387
|
+
#drop_queued(id) {
|
|
388
|
+
const index = this.#queue.findIndex((q) => q.id === id);
|
|
389
|
+
if (index !== -1)
|
|
390
|
+
this.#queue.splice(index, 1);
|
|
391
|
+
}
|
|
392
|
+
#flush_queue() {
|
|
393
|
+
if (!this.connected || !this.ws)
|
|
394
|
+
return;
|
|
395
|
+
const queued = this.#queue;
|
|
396
|
+
this.#queue = [];
|
|
397
|
+
for (const q of queued) {
|
|
398
|
+
if (q.signal?.aborted) {
|
|
399
|
+
this.#detach_signal(q);
|
|
400
|
+
q.reject(this.#build_abort_error(q.method));
|
|
401
|
+
continue;
|
|
402
|
+
}
|
|
403
|
+
const sent = this.send(q.frame);
|
|
404
|
+
if (sent) {
|
|
405
|
+
this.#pending.set(q.id, {
|
|
406
|
+
method: q.method,
|
|
407
|
+
resolve: q.resolve,
|
|
408
|
+
reject: q.reject,
|
|
409
|
+
signal: q.signal,
|
|
410
|
+
signal_handler: q.signal_handler,
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
else {
|
|
414
|
+
this.#detach_signal(q);
|
|
415
|
+
q.reject(new Error(`[socket] queued request send failed (method=${q.method})`));
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
#reject_all(reason) {
|
|
420
|
+
const pending = this.#pending;
|
|
421
|
+
this.#pending = new Map();
|
|
422
|
+
for (const [id, p] of pending) {
|
|
423
|
+
this.#detach_signal(p);
|
|
424
|
+
p.reject(new Error(`[socket] ${reason} (method=${p.method}, id=${id})`));
|
|
425
|
+
}
|
|
426
|
+
const queued = this.#queue;
|
|
427
|
+
this.#queue = [];
|
|
428
|
+
for (const q of queued) {
|
|
429
|
+
this.#detach_signal(q);
|
|
430
|
+
q.reject(new Error(`[socket] ${reason} (method=${q.method})`));
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
#reject_pending_only(reason) {
|
|
434
|
+
// Socket closed but auto-reconnect will try again — pending requests were
|
|
435
|
+
// in flight on the old socket so we can't correlate them after reopen;
|
|
436
|
+
// queued requests haven't been sent yet and stay buffered for the flush.
|
|
437
|
+
const pending = this.#pending;
|
|
438
|
+
this.#pending = new Map();
|
|
439
|
+
for (const [id, p] of pending) {
|
|
440
|
+
this.#detach_signal(p);
|
|
441
|
+
p.reject(new Error(`[socket] ${reason} (method=${p.method}, id=${id})`));
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
#start_heartbeat() {
|
|
445
|
+
this.#cancel_heartbeat();
|
|
446
|
+
if (!this.#heartbeat_enabled)
|
|
447
|
+
return;
|
|
448
|
+
const now = Date.now();
|
|
449
|
+
this.#last_send_time = now;
|
|
450
|
+
this.#last_receive_time = now;
|
|
451
|
+
// Run the check at half the interval so any event-loop blockage pauses
|
|
452
|
+
// the timer itself; a dead-because-blocked socket is close enough to
|
|
453
|
+
// dead-because-unresponsive that closing is arguably correct.
|
|
454
|
+
const tick = Math.max(100, Math.floor(this.#heartbeat_interval / 2));
|
|
455
|
+
this.#heartbeat_timer = setInterval(() => this.#heartbeat_tick(), tick);
|
|
456
|
+
}
|
|
457
|
+
#cancel_heartbeat() {
|
|
458
|
+
if (this.#heartbeat_timer !== null) {
|
|
459
|
+
clearInterval(this.#heartbeat_timer);
|
|
460
|
+
this.#heartbeat_timer = null;
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
#heartbeat_tick() {
|
|
464
|
+
if (!this.connected || !this.ws)
|
|
465
|
+
return;
|
|
466
|
+
const now = Date.now();
|
|
467
|
+
const last_receive = this.#last_receive_time ?? now;
|
|
468
|
+
if (now - last_receive >= this.#heartbeat_receive_timeout) {
|
|
469
|
+
this.#log?.info(`[socket] receive timeout (${now - last_receive}ms) — closing ${WS_CLOSE_CLIENT_HEARTBEAT_TIMEOUT}`);
|
|
470
|
+
try {
|
|
471
|
+
this.ws.close(WS_CLOSE_CLIENT_HEARTBEAT_TIMEOUT, 'client heartbeat timeout');
|
|
472
|
+
}
|
|
473
|
+
catch (error) {
|
|
474
|
+
this.#log?.error('[socket] heartbeat timeout close failed:', error);
|
|
475
|
+
}
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
const last_activity = Math.max(this.#last_send_time ?? 0, last_receive);
|
|
479
|
+
if (now - last_activity >= this.#heartbeat_interval) {
|
|
480
|
+
// Fire-and-forget the heartbeat. If it fails (network, serialization),
|
|
481
|
+
// receive-silence detection above will close the socket on the next
|
|
482
|
+
// tick. No queue — the heartbeat is the thing that tells us the
|
|
483
|
+
// queue needs flushing, it must not fight the queue for the slot.
|
|
484
|
+
void this.request(HEARTBEAT_METHOD, {}, { queue: false }).catch((error) => {
|
|
485
|
+
this.#log?.debug('[socket] heartbeat request failed:', error);
|
|
486
|
+
});
|
|
487
|
+
}
|
|
488
|
+
}
|
|
218
489
|
#teardown(close_code) {
|
|
219
490
|
if (!this.ws)
|
|
220
491
|
return;
|
|
@@ -222,6 +493,7 @@ export class FrontendWebsocketClient {
|
|
|
222
493
|
this.ws.removeEventListener('close', this.#handle_close);
|
|
223
494
|
this.ws.removeEventListener('error', this.#handle_error);
|
|
224
495
|
this.ws.removeEventListener('message', this.#handle_message);
|
|
496
|
+
this.#cancel_heartbeat();
|
|
225
497
|
if (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING) {
|
|
226
498
|
try {
|
|
227
499
|
this.ws.close(close_code);
|
|
@@ -230,8 +502,10 @@ export class FrontendWebsocketClient {
|
|
|
230
502
|
this.#log?.error('[socket] close failed:', error);
|
|
231
503
|
}
|
|
232
504
|
// Listeners are gone, so `#handle_close` won't fire for this close —
|
|
233
|
-
// record it here so the client-initiated close is still observable
|
|
505
|
+
// record it here so the client-initiated close is still observable,
|
|
506
|
+
// and reject any pending requests that can never resolve now.
|
|
234
507
|
this.#record_close(close_code, '');
|
|
508
|
+
this.#reject_pending_only(`socket torn down (code ${close_code})`);
|
|
235
509
|
}
|
|
236
510
|
this.ws = null;
|
|
237
511
|
}
|
|
@@ -267,12 +541,16 @@ export class FrontendWebsocketClient {
|
|
|
267
541
|
this.current_reconnect_delay = 0;
|
|
268
542
|
this.last_connect_time = Date.now();
|
|
269
543
|
this.#cancel_reconnect();
|
|
544
|
+
this.#start_heartbeat();
|
|
545
|
+
// Flush buffered requests before anyone else can observe the open state.
|
|
546
|
+
this.#flush_queue();
|
|
270
547
|
};
|
|
271
548
|
#handle_close = (event) => {
|
|
272
549
|
// Drop the dead-socket reference so consumers reading `client.ws` never
|
|
273
550
|
// see a CLOSED WebSocket during the reconnect window.
|
|
274
551
|
this.ws = null;
|
|
275
552
|
this.#record_close(event.code, event.reason);
|
|
553
|
+
this.#cancel_heartbeat();
|
|
276
554
|
// Session revocation is terminal — reconnecting would 401 in a loop.
|
|
277
555
|
if (event.code === WS_CLOSE_SESSION_REVOKED) {
|
|
278
556
|
this.#revoked = true;
|
|
@@ -280,8 +558,12 @@ export class FrontendWebsocketClient {
|
|
|
280
558
|
this.#cancel_reconnect();
|
|
281
559
|
this.reconnect_count = 0;
|
|
282
560
|
this.current_reconnect_delay = 0;
|
|
561
|
+
this.#reject_all('session revoked');
|
|
283
562
|
return;
|
|
284
563
|
}
|
|
564
|
+
// Pending in-flight requests can't be correlated post-reconnect; reject
|
|
565
|
+
// them. Queue stays so the flush on reopen replays unsent work.
|
|
566
|
+
this.#reject_pending_only(`connection closed (code ${event.code})`);
|
|
285
567
|
// Let `#schedule_reconnect` set `status: 'reconnecting'` directly to avoid
|
|
286
568
|
// a transient `'closed'` flicker; only set `'closed'` when reconnect is off.
|
|
287
569
|
if (this.#auto_reconnect) {
|
|
@@ -289,6 +571,7 @@ export class FrontendWebsocketClient {
|
|
|
289
571
|
}
|
|
290
572
|
else {
|
|
291
573
|
this.status = 'closed';
|
|
574
|
+
this.#reject_all('connection closed, auto-reconnect disabled');
|
|
292
575
|
}
|
|
293
576
|
};
|
|
294
577
|
#handle_error = (event) => {
|
|
@@ -304,6 +587,39 @@ export class FrontendWebsocketClient {
|
|
|
304
587
|
// Browsers fire `close` after error; reconnect logic lives there.
|
|
305
588
|
};
|
|
306
589
|
#handle_message = (event) => {
|
|
590
|
+
this.#last_receive_time = Date.now();
|
|
591
|
+
// Intercept JSON-RPC responses for pending `request()` calls. Parse
|
|
592
|
+
// defensively — if the frame isn't valid JSON or isn't a response, fall
|
|
593
|
+
// through to the registered message handlers (which still see every
|
|
594
|
+
// notification, plus any stray response we don't own).
|
|
595
|
+
let json;
|
|
596
|
+
try {
|
|
597
|
+
json = JSON.parse(String(event.data));
|
|
598
|
+
}
|
|
599
|
+
catch {
|
|
600
|
+
json = undefined;
|
|
601
|
+
}
|
|
602
|
+
if (typeof json === 'object' &&
|
|
603
|
+
json !== null &&
|
|
604
|
+
'id' in json &&
|
|
605
|
+
('result' in json || 'error' in json)) {
|
|
606
|
+
const id = json.id;
|
|
607
|
+
if (id !== null) {
|
|
608
|
+
const pending = this.#pending.get(id);
|
|
609
|
+
if (pending) {
|
|
610
|
+
this.#pending.delete(id);
|
|
611
|
+
this.#detach_signal(pending);
|
|
612
|
+
if ('error' in json && json.error) {
|
|
613
|
+
const err = json.error;
|
|
614
|
+
pending.reject(new Error(`[rpc ${pending.method} #${id}] ${err.code ?? '?'} ${err.message ?? 'unknown error'}`));
|
|
615
|
+
}
|
|
616
|
+
else {
|
|
617
|
+
pending.resolve(json.result);
|
|
618
|
+
}
|
|
619
|
+
return;
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
}
|
|
307
623
|
for (const handler of this.#message_handlers) {
|
|
308
624
|
try {
|
|
309
625
|
handler(event);
|
|
@@ -10,13 +10,28 @@ import { z } from 'zod';
|
|
|
10
10
|
import type { JsonrpcMessageFromClientToServer, JsonrpcMessageFromServerToClient, JsonrpcNotification, JsonrpcRequest, JsonrpcResponseOrError, JsonrpcErrorResponse } from '../http/jsonrpc.js';
|
|
11
11
|
/** WebSocket close code for session revocation. */
|
|
12
12
|
export declare const WS_CLOSE_SESSION_REVOKED = 4001;
|
|
13
|
+
/** WebSocket close code — client timed out waiting for a response. */
|
|
14
|
+
export declare const WS_CLOSE_CLIENT_HEARTBEAT_TIMEOUT = 4002;
|
|
15
|
+
/** WebSocket close code — server timed out with no incoming activity. */
|
|
16
|
+
export declare const WS_CLOSE_SERVER_HEARTBEAT_TIMEOUT = 4003;
|
|
13
17
|
export declare const TransportName: z.ZodString;
|
|
14
18
|
export type TransportName = z.infer<typeof TransportName>;
|
|
19
|
+
/**
|
|
20
|
+
* Per-call options accepted by every transport's `send`. Optional and
|
|
21
|
+
* extensible — adding a field is non-breaking. Today: an `AbortSignal`
|
|
22
|
+
* for cancellation that bottoms out at `FrontendWebsocketClient.request`
|
|
23
|
+
* (which sends the shared `cancel` notification on abort) and at
|
|
24
|
+
* `fetch({signal})` for HTTP. Backend transport receives the option but
|
|
25
|
+
* has no per-call abort surface to honor.
|
|
26
|
+
*/
|
|
27
|
+
export interface TransportSendOptions {
|
|
28
|
+
signal?: AbortSignal;
|
|
29
|
+
}
|
|
15
30
|
export interface Transport {
|
|
16
31
|
transport_name: TransportName;
|
|
17
|
-
send(message: JsonrpcRequest): Promise<JsonrpcResponseOrError>;
|
|
18
|
-
send(message: JsonrpcNotification): Promise<JsonrpcErrorResponse | null>;
|
|
19
|
-
send(message: JsonrpcMessageFromClientToServer): Promise<JsonrpcMessageFromServerToClient | null>;
|
|
32
|
+
send(message: JsonrpcRequest, options?: TransportSendOptions): Promise<JsonrpcResponseOrError>;
|
|
33
|
+
send(message: JsonrpcNotification, options?: TransportSendOptions): Promise<JsonrpcErrorResponse | null>;
|
|
34
|
+
send(message: JsonrpcMessageFromClientToServer, options?: TransportSendOptions): Promise<JsonrpcMessageFromServerToClient | null>;
|
|
20
35
|
is_ready: () => boolean;
|
|
21
36
|
dispose?: () => void;
|
|
22
37
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"transports.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/actions/transports.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,EAAC,CAAC,EAAC,MAAM,KAAK,CAAC;AAEtB,OAAO,KAAK,EACX,gCAAgC,EAChC,gCAAgC,EAChC,mBAAmB,EACnB,cAAc,EACd,sBAAsB,EACtB,oBAAoB,EACpB,MAAM,oBAAoB,CAAC;AAE5B,mDAAmD;AACnD,eAAO,MAAM,wBAAwB,OAAO,CAAC;
|
|
1
|
+
{"version":3,"file":"transports.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/actions/transports.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,EAAC,CAAC,EAAC,MAAM,KAAK,CAAC;AAEtB,OAAO,KAAK,EACX,gCAAgC,EAChC,gCAAgC,EAChC,mBAAmB,EACnB,cAAc,EACd,sBAAsB,EACtB,oBAAoB,EACpB,MAAM,oBAAoB,CAAC;AAE5B,mDAAmD;AACnD,eAAO,MAAM,wBAAwB,OAAO,CAAC;AAC7C,sEAAsE;AACtE,eAAO,MAAM,iCAAiC,OAAO,CAAC;AACtD,yEAAyE;AACzE,eAAO,MAAM,iCAAiC,OAAO,CAAC;AAKtD,eAAO,MAAM,aAAa,aAAa,CAAC;AACxC,MAAM,MAAM,aAAa,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,aAAa,CAAC,CAAC;AAE1D;;;;;;;GAOG;AACH,MAAM,WAAW,oBAAoB;IACpC,MAAM,CAAC,EAAE,WAAW,CAAC;CACrB;AAED,MAAM,WAAW,SAAS;IACzB,cAAc,EAAE,aAAa,CAAC;IAE9B,IAAI,CAAC,OAAO,EAAE,cAAc,EAAE,OAAO,CAAC,EAAE,oBAAoB,GAAG,OAAO,CAAC,sBAAsB,CAAC,CAAC;IAC/F,IAAI,CACH,OAAO,EAAE,mBAAmB,EAC5B,OAAO,CAAC,EAAE,oBAAoB,GAC5B,OAAO,CAAC,oBAAoB,GAAG,IAAI,CAAC,CAAC;IACxC,IAAI,CACH,OAAO,EAAE,gCAAgC,EACzC,OAAO,CAAC,EAAE,oBAAoB,GAC5B,OAAO,CAAC,gCAAgC,GAAG,IAAI,CAAC,CAAC;IACpD,QAAQ,EAAE,MAAM,OAAO,CAAC;IACxB,OAAO,CAAC,EAAE,MAAM,IAAI,CAAC;CACrB;AAED,qBAAa,UAAU;;IAItB;;;OAGG;IACH,cAAc,EAAE,OAAO,CAAQ;IAE/B;;OAEG;IACH,kBAAkB,CAAC,SAAS,EAAE,SAAS,GAAG,IAAI;IAS9C,qBAAqB,CAAC,cAAc,EAAE,aAAa,GAAG,IAAI;IAM1D;;;;;OAKG;IACH,aAAa,CAAC,cAAc,CAAC,EAAE,aAAa,GAAG,SAAS,GAAG,IAAI;IAO/D,QAAQ,IAAI,OAAO,GAAG,IAAI;IAM1B,qBAAqB,IAAI,SAAS,GAAG,IAAI;IAIzC,0BAA0B,IAAI,aAAa,GAAG,IAAI;IAIlD,qBAAqB,CAAC,cAAc,EAAE,aAAa,GAAG,SAAS,GAAG,IAAI;CAqDtE"}
|
|
@@ -9,6 +9,10 @@
|
|
|
9
9
|
import { z } from 'zod';
|
|
10
10
|
/** WebSocket close code for session revocation. */
|
|
11
11
|
export const WS_CLOSE_SESSION_REVOKED = 4001;
|
|
12
|
+
/** WebSocket close code — client timed out waiting for a response. */
|
|
13
|
+
export const WS_CLOSE_CLIENT_HEARTBEAT_TIMEOUT = 4002;
|
|
14
|
+
/** WebSocket close code — server timed out with no incoming activity. */
|
|
15
|
+
export const WS_CLOSE_SERVER_HEARTBEAT_TIMEOUT = 4003;
|
|
12
16
|
// TODO figure out the symmetry of frontend and backend transports (none/partial/full?) --
|
|
13
17
|
// we may also need orthogonal abstractions to clarify the transport role
|
|
14
18
|
export const TransportName = z.string(); // not branded for convenience, will just error at runtime, the schema is just for docs atm
|
|
@@ -4,13 +4,13 @@
|
|
|
4
4
|
* @module
|
|
5
5
|
*/
|
|
6
6
|
import type { JsonrpcNotification, JsonrpcRequest, JsonrpcResponseOrError, JsonrpcErrorResponse } from '../http/jsonrpc.js';
|
|
7
|
-
import type { Transport } from './transports.js';
|
|
7
|
+
import type { Transport, TransportSendOptions } from './transports.js';
|
|
8
8
|
export declare class FrontendHttpTransport implements Transport {
|
|
9
9
|
#private;
|
|
10
10
|
readonly transport_name: "frontend_http_rpc";
|
|
11
11
|
constructor(url: string, headers?: Record<string, string>, has_side_effects?: (method: string) => boolean);
|
|
12
|
-
send(message: JsonrpcRequest): Promise<JsonrpcResponseOrError>;
|
|
13
|
-
send(message: JsonrpcNotification): Promise<JsonrpcErrorResponse | null>;
|
|
12
|
+
send(message: JsonrpcRequest, options?: TransportSendOptions): Promise<JsonrpcResponseOrError>;
|
|
13
|
+
send(message: JsonrpcNotification, options?: TransportSendOptions): Promise<JsonrpcErrorResponse | null>;
|
|
14
14
|
is_ready(): boolean;
|
|
15
15
|
}
|
|
16
16
|
//# sourceMappingURL=transports_http.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"transports_http.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/actions/transports_http.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAeH,OAAO,KAAK,EAGX,mBAAmB,EACnB,cAAc,EACd,sBAAsB,EACtB,oBAAoB,EACpB,MAAM,oBAAoB,CAAC;AAC5B,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,iBAAiB,CAAC;
|
|
1
|
+
{"version":3,"file":"transports_http.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/actions/transports_http.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAeH,OAAO,KAAK,EAGX,mBAAmB,EACnB,cAAc,EACd,sBAAsB,EACtB,oBAAoB,EACpB,MAAM,oBAAoB,CAAC;AAC5B,OAAO,KAAK,EAAC,SAAS,EAAE,oBAAoB,EAAC,MAAM,iBAAiB,CAAC;AAErE,qBAAa,qBAAsB,YAAW,SAAS;;IACtD,QAAQ,CAAC,cAAc,EAAG,mBAAmB,CAAU;gBAOtD,GAAG,EAAE,MAAM,EACX,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,EAChC,gBAAgB,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,OAAO;IAOzC,IAAI,CACT,OAAO,EAAE,cAAc,EACvB,OAAO,CAAC,EAAE,oBAAoB,GAC5B,OAAO,CAAC,sBAAsB,CAAC;IAC5B,IAAI,CACT,OAAO,EAAE,mBAAmB,EAC5B,OAAO,CAAC,EAAE,oBAAoB,GAC5B,OAAO,CAAC,oBAAoB,GAAG,IAAI,CAAC;IAyEvC,QAAQ,IAAI,OAAO;CAGnB"}
|
|
@@ -16,7 +16,8 @@ export class FrontendHttpTransport {
|
|
|
16
16
|
this.#headers = headers ?? { 'content-type': 'application/json', accept: 'application/json' };
|
|
17
17
|
this.#has_side_effects = has_side_effects;
|
|
18
18
|
}
|
|
19
|
-
async send(message) {
|
|
19
|
+
async send(message, options) {
|
|
20
|
+
const signal = options?.signal;
|
|
20
21
|
try {
|
|
21
22
|
let response;
|
|
22
23
|
if (this.#has_side_effects && !this.#has_side_effects(message.method) && 'id' in message) {
|
|
@@ -31,6 +32,7 @@ export class FrontendHttpTransport {
|
|
|
31
32
|
response = await fetch(`${this.#url}${separator}${search_params.toString()}`, {
|
|
32
33
|
method: 'GET',
|
|
33
34
|
headers: this.#headers,
|
|
35
|
+
signal,
|
|
34
36
|
});
|
|
35
37
|
}
|
|
36
38
|
else {
|
|
@@ -38,8 +40,7 @@ export class FrontendHttpTransport {
|
|
|
38
40
|
method: 'POST',
|
|
39
41
|
headers: this.#headers,
|
|
40
42
|
body: JSON.stringify(message),
|
|
41
|
-
|
|
42
|
-
// signal: AbortSignal.timeout(REQUEST_TIMEOUT),
|
|
43
|
+
signal,
|
|
43
44
|
});
|
|
44
45
|
}
|
|
45
46
|
const result = await response.json();
|
|
@@ -1,10 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* WebSocket transport —
|
|
2
|
+
* Frontend WebSocket transport — thin adapter over `WebsocketRpcConnection`.
|
|
3
|
+
*
|
|
4
|
+
* Delegates request/response correlation, the durable queue, the heartbeat,
|
|
5
|
+
* and `AbortSignal`-driven cancel to the underlying connection (the
|
|
6
|
+
* canonical implementation is `FrontendWebsocketClient`). The transport's
|
|
7
|
+
* own job is the `Transport` contract: route inbound server-pushed
|
|
8
|
+
* messages into `peer.receive` and translate the connection's
|
|
9
|
+
* `Promise<R>`/`ThrownJsonrpcError` shape into `JsonrpcResponseOrError`
|
|
10
|
+
* envelopes. No parallel pending-request map.
|
|
3
11
|
*
|
|
4
12
|
* @module
|
|
5
13
|
*/
|
|
6
|
-
import type { JsonrpcNotification, JsonrpcRequest, JsonrpcResponseOrError, JsonrpcErrorResponse } from '../http/jsonrpc.js';
|
|
7
|
-
import type { Transport } from './transports.js';
|
|
14
|
+
import type { JsonrpcNotification, JsonrpcRequest, JsonrpcRequestId, JsonrpcResponseOrError, JsonrpcErrorResponse } from '../http/jsonrpc.js';
|
|
15
|
+
import type { Transport, TransportSendOptions } from './transports.js';
|
|
8
16
|
/**
|
|
9
17
|
* Minimal interface for a WebSocket connection, decoupled from the concrete Socket Cell.
|
|
10
18
|
*/
|
|
@@ -14,12 +22,31 @@ export interface WebsocketConnection {
|
|
|
14
22
|
add_message_handler: (handler: (event: MessageEvent) => void) => () => void;
|
|
15
23
|
add_error_handler: (handler: (event: Event) => void) => () => void;
|
|
16
24
|
}
|
|
25
|
+
/**
|
|
26
|
+
* RPC-capable WebSocket connection — a `WebsocketConnection` that also
|
|
27
|
+
* handles request/response correlation with timeout, queue,
|
|
28
|
+
* `AbortSignal` cancel, and explicit-id support. Required by
|
|
29
|
+
* `FrontendWebsocketTransport` so it can delegate the pending-map
|
|
30
|
+
* bookkeeping to one canonical implementation
|
|
31
|
+
* (`FrontendWebsocketClient`) instead of running a parallel one.
|
|
32
|
+
*
|
|
33
|
+
* Consumer wrappers around `FrontendWebsocketClient` (e.g. zzz's
|
|
34
|
+
* `Socket`) implement this by adding a one-line delegate to the
|
|
35
|
+
* underlying client's `request`.
|
|
36
|
+
*/
|
|
37
|
+
export interface WebsocketRpcConnection extends WebsocketConnection {
|
|
38
|
+
request: (method: string, params: unknown, options?: {
|
|
39
|
+
signal?: AbortSignal;
|
|
40
|
+
queue?: boolean;
|
|
41
|
+
id?: JsonrpcRequestId;
|
|
42
|
+
}) => Promise<unknown>;
|
|
43
|
+
}
|
|
17
44
|
export declare class FrontendWebsocketTransport implements Transport {
|
|
18
45
|
#private;
|
|
19
46
|
readonly transport_name: "frontend_websocket_rpc";
|
|
20
|
-
constructor(connection:
|
|
21
|
-
send(message: JsonrpcRequest): Promise<JsonrpcResponseOrError>;
|
|
22
|
-
send(message: JsonrpcNotification): Promise<JsonrpcErrorResponse | null>;
|
|
47
|
+
constructor(connection: WebsocketRpcConnection, receive: (data: unknown) => Promise<unknown>);
|
|
48
|
+
send(message: JsonrpcRequest, options?: TransportSendOptions): Promise<JsonrpcResponseOrError>;
|
|
49
|
+
send(message: JsonrpcNotification, options?: TransportSendOptions): Promise<JsonrpcErrorResponse | null>;
|
|
23
50
|
is_ready(): boolean;
|
|
24
51
|
dispose(): void;
|
|
25
52
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"transports_ws.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/actions/transports_ws.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"transports_ws.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/actions/transports_ws.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAWH,OAAO,KAAK,EAGX,mBAAmB,EACnB,cAAc,EACd,gBAAgB,EAChB,sBAAsB,EACtB,oBAAoB,EACpB,MAAM,oBAAoB,CAAC;AAC5B,OAAO,KAAK,EAAC,SAAS,EAAE,oBAAoB,EAAC,MAAM,iBAAiB,CAAC;AAIrE;;GAEG;AACH,MAAM,WAAW,mBAAmB;IACnC,IAAI,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC;IAChC,QAAQ,CAAC,SAAS,EAAE,OAAO,CAAC;IAC5B,mBAAmB,EAAE,CAAC,OAAO,EAAE,CAAC,KAAK,EAAE,YAAY,KAAK,IAAI,KAAK,MAAM,IAAI,CAAC;IAC5E,iBAAiB,EAAE,CAAC,OAAO,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,IAAI,KAAK,MAAM,IAAI,CAAC;CACnE;AAED;;;;;;;;;;;GAWG;AACH,MAAM,WAAW,sBAAuB,SAAQ,mBAAmB;IAClE,OAAO,EAAE,CACR,MAAM,EAAE,MAAM,EACd,MAAM,EAAE,OAAO,EACf,OAAO,CAAC,EAAE;QAAC,MAAM,CAAC,EAAE,WAAW,CAAC;QAAC,KAAK,CAAC,EAAE,OAAO,CAAC;QAAC,EAAE,CAAC,EAAE,gBAAgB,CAAA;KAAC,KACpE,OAAO,CAAC,OAAO,CAAC,CAAC;CACtB;AAED,qBAAa,0BAA2B,YAAW,SAAS;;IAC3D,QAAQ,CAAC,cAAc,EAAG,wBAAwB,CAAU;gBAOhD,UAAU,EAAE,sBAAsB,EAAE,OAAO,EAAE,CAAC,IAAI,EAAE,OAAO,KAAK,OAAO,CAAC,OAAO,CAAC;IAyBtF,IAAI,CACT,OAAO,EAAE,cAAc,EACvB,OAAO,CAAC,EAAE,oBAAoB,GAC5B,OAAO,CAAC,sBAAsB,CAAC;IAC5B,IAAI,CACT,OAAO,EAAE,mBAAmB,EAC5B,OAAO,CAAC,EAAE,oBAAoB,GAC5B,OAAO,CAAC,oBAAoB,GAAG,IAAI,CAAC;IAoDvC,QAAQ,IAAI,OAAO;IAInB,OAAO,IAAI,IAAI;CAUf"}
|