@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.
Files changed (63) hide show
  1. package/dist/actions/action_codegen.d.ts +25 -0
  2. package/dist/actions/action_codegen.d.ts.map +1 -1
  3. package/dist/actions/action_codegen.js +39 -0
  4. package/dist/actions/action_peer.d.ts +7 -0
  5. package/dist/actions/action_peer.d.ts.map +1 -1
  6. package/dist/actions/action_peer.js +1 -1
  7. package/dist/actions/action_types.d.ts +72 -0
  8. package/dist/actions/action_types.d.ts.map +1 -0
  9. package/dist/actions/action_types.js +11 -0
  10. package/dist/actions/cancel.d.ts +78 -0
  11. package/dist/actions/cancel.d.ts.map +1 -0
  12. package/dist/actions/cancel.js +79 -0
  13. package/dist/actions/heartbeat.d.ts +51 -0
  14. package/dist/actions/heartbeat.d.ts.map +1 -0
  15. package/dist/actions/heartbeat.js +50 -0
  16. package/dist/actions/register_action_ws.d.ts +28 -30
  17. package/dist/actions/register_action_ws.d.ts.map +1 -1
  18. package/dist/actions/register_action_ws.js +103 -20
  19. package/dist/actions/rpc_client.d.ts +10 -0
  20. package/dist/actions/rpc_client.d.ts.map +1 -1
  21. package/dist/actions/rpc_client.js +22 -7
  22. package/dist/actions/socket.svelte.d.ts +88 -4
  23. package/dist/actions/socket.svelte.d.ts.map +1 -1
  24. package/dist/actions/socket.svelte.js +322 -6
  25. package/dist/actions/transports.d.ts +18 -3
  26. package/dist/actions/transports.d.ts.map +1 -1
  27. package/dist/actions/transports.js +4 -0
  28. package/dist/actions/transports_http.d.ts +3 -3
  29. package/dist/actions/transports_http.d.ts.map +1 -1
  30. package/dist/actions/transports_http.js +4 -3
  31. package/dist/actions/transports_ws.d.ts +33 -6
  32. package/dist/actions/transports_ws.d.ts.map +1 -1
  33. package/dist/actions/transports_ws.js +43 -46
  34. package/dist/actions/transports_ws_backend.d.ts +12 -3
  35. package/dist/actions/transports_ws_backend.d.ts.map +1 -1
  36. package/dist/actions/transports_ws_backend.js +12 -1
  37. package/dist/auth/bearer_auth.js +0 -1
  38. package/dist/auth/keyring.d.ts.map +1 -1
  39. package/dist/auth/keyring.js +0 -2
  40. package/dist/auth/migrations.js +4 -4
  41. package/dist/db/migrate.d.ts +12 -2
  42. package/dist/db/migrate.d.ts.map +1 -1
  43. package/dist/db/migrate.js +25 -16
  44. package/dist/db/status.d.ts.map +1 -1
  45. package/dist/db/status.js +0 -2
  46. package/dist/dev/setup.js +2 -2
  47. package/dist/http/db_routes.d.ts.map +1 -1
  48. package/dist/http/db_routes.js +0 -1
  49. package/dist/testing/admin_integration.d.ts.map +1 -1
  50. package/dist/testing/admin_integration.js +0 -3
  51. package/dist/testing/app_server.js +1 -1
  52. package/dist/testing/data_exposure.js +6 -8
  53. package/dist/testing/db.js +1 -1
  54. package/dist/testing/integration.js +0 -1
  55. package/dist/testing/rate_limiting.d.ts.map +1 -1
  56. package/dist/testing/rate_limiting.js +0 -6
  57. package/dist/testing/rpc_round_trip.js +4 -4
  58. package/dist/testing/sse_round_trip.d.ts.map +1 -1
  59. package/dist/testing/sse_round_trip.js +1 -2
  60. package/dist/testing/ws_round_trip.d.ts +15 -3
  61. package/dist/testing/ws_round_trip.d.ts.map +1 -1
  62. package/dist/testing/ws_round_trip.js +3 -3
  63. 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), and exposes reactive status for UI indicators.
8
+ * server revokes auth), exposes reactive status for UI indicators, and ships
9
+ * three correctness primitives default-on:
9
10
  *
10
- * First cut: no message queue, no heartbeat. Those live in consumer-specific
11
- * wrappers today (see zzz's `Socket` Cell); extract into fuz_app when two
12
- * independent consumers motivate the shape.
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 { WS_CLOSE_SESSION_REVOKED } from './transports.js';
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;AAK7C,eAAO,MAAM,aAAa,aAAa,CAAC;AACxC,MAAM,MAAM,aAAa,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,aAAa,CAAC,CAAC;AAE1D,MAAM,WAAW,SAAS;IACzB,cAAc,EAAE,aAAa,CAAC;IAE9B,IAAI,CAAC,OAAO,EAAE,cAAc,GAAG,OAAO,CAAC,sBAAsB,CAAC,CAAC;IAC/D,IAAI,CAAC,OAAO,EAAE,mBAAmB,GAAG,OAAO,CAAC,oBAAoB,GAAG,IAAI,CAAC,CAAC;IACzE,IAAI,CAAC,OAAO,EAAE,gCAAgC,GAAG,OAAO,CAAC,gCAAgC,GAAG,IAAI,CAAC,CAAC;IAClG,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"}
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;AAE/C,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,CAAC,OAAO,EAAE,cAAc,GAAG,OAAO,CAAC,sBAAsB,CAAC;IAC9D,IAAI,CAAC,OAAO,EAAE,mBAAmB,GAAG,OAAO,CAAC,oBAAoB,GAAG,IAAI,CAAC;IAuE9E,QAAQ,IAAI,OAAO;CAGnB"}
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
- // TODO
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 — sends JSON-RPC messages via WebSocket with request tracking.
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: WebsocketConnection, receive: (data: unknown) => Promise<unknown>, request_timeout_ms?: number);
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;;;;GAIG;AAeH,OAAO,KAAK,EAGX,mBAAmB,EACnB,cAAc,EACd,sBAAsB,EACtB,oBAAoB,EACpB,MAAM,oBAAoB,CAAC;AAE5B,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,iBAAiB,CAAC;AAI/C;;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,qBAAa,0BAA2B,YAAW,SAAS;;IAC3D,QAAQ,CAAC,cAAc,EAAG,wBAAwB,CAAU;gBAS3D,UAAU,EAAE,mBAAmB,EAC/B,OAAO,EAAE,CAAC,IAAI,EAAE,OAAO,KAAK,OAAO,CAAC,OAAO,CAAC,EAC5C,kBAAkB,CAAC,EAAE,MAAM;IAkCtB,IAAI,CAAC,OAAO,EAAE,cAAc,GAAG,OAAO,CAAC,sBAAsB,CAAC;IAC9D,IAAI,CAAC,OAAO,EAAE,mBAAmB,GAAG,OAAO,CAAC,oBAAoB,GAAG,IAAI,CAAC;IA8C9E,QAAQ,IAAI,OAAO;IAInB,OAAO,IAAI,IAAI;CAUf"}
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"}