@fuzdev/fuz_app 0.22.0 → 0.24.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.
@@ -5,16 +5,28 @@
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 { HEARTBEAT_METHOD } from './heartbeat.js';
18
30
  /** Default WebSocket close code (normal closure). */
19
31
  export const DEFAULT_CLOSE_CODE = 1000;
20
32
  /** Base reconnect delay in ms. */
@@ -23,6 +35,12 @@ export const DEFAULT_RECONNECT_DELAY = 1000;
23
35
  export const DEFAULT_RECONNECT_DELAY_MAX = 10000;
24
36
  /** Exponential backoff factor: delay = base * factor^(attempt-1). */
25
37
  export const DEFAULT_BACKOFF_FACTOR = 1.5;
38
+ /** Idle interval before sending a heartbeat (ms). */
39
+ export const DEFAULT_HEARTBEAT_INTERVAL = 30_000;
40
+ /** Max receive silence before closing with {@link WS_CLOSE_CLIENT_HEARTBEAT_TIMEOUT} (ms). */
41
+ export const DEFAULT_HEARTBEAT_RECEIVE_TIMEOUT = 60_000;
42
+ /** Default bound on buffered requests while disconnected. Overflow rejects. */
43
+ export const DEFAULT_QUEUE_MAX_SIZE = 100;
26
44
  /**
27
45
  * Reactive WebSocket client implementing `WebsocketConnection`.
28
46
  *
@@ -40,7 +58,20 @@ export class FrontendWebsocketClient {
40
58
  #reconnect_delay;
41
59
  #reconnect_delay_max;
42
60
  #backoff_factor;
61
+ #heartbeat_enabled;
62
+ #heartbeat_interval;
63
+ #heartbeat_receive_timeout;
64
+ #queue_enabled;
65
+ #queue_max_size;
43
66
  #log;
67
+ #next_request_id = 0;
68
+ #pending = new Map();
69
+ #queue = [];
70
+ #heartbeat_timer = null;
71
+ /** Epoch ms of the last outgoing send — used by the heartbeat activity check. */
72
+ #last_send_time = null;
73
+ /** Epoch ms of the last incoming message — used by the heartbeat activity check. */
74
+ #last_receive_time = null;
44
75
  ws = $state.raw(null);
45
76
  status = $state.raw('initial');
46
77
  reconnect_count = $state.raw(0);
@@ -77,6 +108,16 @@ export class FrontendWebsocketClient {
77
108
  this.#reconnect_delay = config.delay ?? DEFAULT_RECONNECT_DELAY;
78
109
  this.#reconnect_delay_max = config.delay_max ?? DEFAULT_RECONNECT_DELAY_MAX;
79
110
  this.#backoff_factor = config.factor ?? DEFAULT_BACKOFF_FACTOR;
111
+ const heartbeat = options.heartbeat;
112
+ this.#heartbeat_enabled = heartbeat !== false;
113
+ const heartbeat_config = typeof heartbeat === 'object' ? heartbeat : {};
114
+ this.#heartbeat_interval = heartbeat_config.interval ?? DEFAULT_HEARTBEAT_INTERVAL;
115
+ this.#heartbeat_receive_timeout =
116
+ heartbeat_config.receive_timeout ?? DEFAULT_HEARTBEAT_RECEIVE_TIMEOUT;
117
+ const queue = options.queue;
118
+ this.#queue_enabled = queue !== false;
119
+ const queue_config = typeof queue === 'object' ? queue : {};
120
+ this.#queue_max_size = queue_config.max_size ?? DEFAULT_QUEUE_MAX_SIZE;
80
121
  this.#log = options.log ?? null;
81
122
  }
82
123
  /**
@@ -184,10 +225,12 @@ export class FrontendWebsocketClient {
184
225
  */
185
226
  disconnect(code = DEFAULT_CLOSE_CODE) {
186
227
  this.#cancel_reconnect();
228
+ this.#cancel_heartbeat();
187
229
  this.#teardown(code);
188
230
  this.status = 'closed';
189
231
  this.reconnect_count = 0;
190
232
  this.current_reconnect_delay = 0;
233
+ this.#reject_all('client disconnected');
191
234
  }
192
235
  /** Explicit-resource-management hook — supports `using client = new FrontendWebsocketClient(url)`. */
193
236
  [Symbol.dispose]() {
@@ -199,6 +242,7 @@ export class FrontendWebsocketClient {
199
242
  try {
200
243
  this.ws.send(JSON.stringify(data));
201
244
  this.last_send_error = null;
245
+ this.#last_send_time = Date.now();
202
246
  return true;
203
247
  }
204
248
  catch (error) {
@@ -207,6 +251,81 @@ export class FrontendWebsocketClient {
207
251
  return false;
208
252
  }
209
253
  }
254
+ /**
255
+ * Promise-based JSON-RPC over the socket. Auto-assigns a monotonic request
256
+ * id, tracks the pending promise, and resolves when the server sends a
257
+ * matching response (or rejects on error frame, socket close, or aborted
258
+ * signal).
259
+ *
260
+ * While the socket is disconnected, the request is buffered in a bounded
261
+ * queue (default-on, {@link DEFAULT_QUEUE_MAX_SIZE}) and flushed on
262
+ * reopen. Pass `{queue: false}` to reject immediately when disconnected
263
+ * — used internally by the heartbeat, which must not fight the queue for
264
+ * the disconnect-detection slot.
265
+ *
266
+ * `AbortSignal` integration today rejects the local promise; the
267
+ * server-side cancel protocol (sending a `cancel` notification to abort
268
+ * the in-flight handler) lands in Phase 3c as a follow-up PR.
269
+ */
270
+ request(method, params = {}, options = {}) {
271
+ return new Promise((resolve, reject) => {
272
+ const resolve_typed = resolve;
273
+ const reject_typed = reject;
274
+ if (this.#revoked) {
275
+ reject_typed(new Error('[socket] session revoked'));
276
+ return;
277
+ }
278
+ const { signal = null } = options;
279
+ if (signal?.aborted) {
280
+ reject_typed(this.#build_abort_error(method));
281
+ return;
282
+ }
283
+ const id = ++this.#next_request_id;
284
+ const frame = { jsonrpc: JSONRPC_VERSION, id, method, params };
285
+ // Bind the signal listener up-front so `#detach_signal` can find it by
286
+ // reference regardless of which settlement path runs (inline send,
287
+ // queued flush, close-time reject).
288
+ let pending = null;
289
+ const signal_handler = signal
290
+ ? () => {
291
+ if (!pending)
292
+ return;
293
+ this.#pending.delete(id);
294
+ this.#drop_queued(id);
295
+ this.#detach_signal(pending);
296
+ pending = null;
297
+ reject_typed(this.#build_abort_error(method));
298
+ }
299
+ : null;
300
+ if (signal && signal_handler)
301
+ signal.addEventListener('abort', signal_handler);
302
+ pending = { method, resolve: resolve_typed, reject: reject_typed, signal, signal_handler };
303
+ const should_queue = options.queue !== false && this.#queue_enabled;
304
+ if (this.connected && this.ws) {
305
+ const sent = this.send(frame);
306
+ if (sent) {
307
+ this.#pending.set(id, pending);
308
+ return;
309
+ }
310
+ // Send failed mid-connected (serialization, buffer full). Requeue if
311
+ // the queue is on, otherwise reject — this socket is in an odd
312
+ // state but the caller asked for non-durable semantics.
313
+ if (should_queue) {
314
+ this.#enqueue({ ...pending, id, frame });
315
+ return;
316
+ }
317
+ this.#detach_signal(pending);
318
+ reject_typed(new Error(`[socket] send failed for ${method}`));
319
+ return;
320
+ }
321
+ if (should_queue) {
322
+ this.#enqueue({ ...pending, id, frame });
323
+ return;
324
+ }
325
+ this.#detach_signal(pending);
326
+ reject_typed(new Error(`[socket] not connected (method=${method})`));
327
+ });
328
+ }
210
329
  add_message_handler(handler) {
211
330
  this.#message_handlers.add(handler);
212
331
  return () => this.#message_handlers.delete(handler);
@@ -215,6 +334,124 @@ export class FrontendWebsocketClient {
215
334
  this.#error_handlers.add(handler);
216
335
  return () => this.#error_handlers.delete(handler);
217
336
  }
337
+ #build_abort_error(method) {
338
+ return new Error(`[socket] request aborted (method=${method})`);
339
+ }
340
+ #detach_signal(pending) {
341
+ if (pending.signal && pending.signal_handler) {
342
+ pending.signal.removeEventListener('abort', pending.signal_handler);
343
+ }
344
+ }
345
+ #enqueue(queued) {
346
+ if (this.#queue.length >= this.#queue_max_size) {
347
+ this.#detach_signal(queued);
348
+ queued.reject(new Error(`[socket] request queue overflow (method=${queued.method}, max=${this.#queue_max_size})`));
349
+ return;
350
+ }
351
+ this.#queue.push(queued);
352
+ }
353
+ #drop_queued(id) {
354
+ const index = this.#queue.findIndex((q) => q.id === id);
355
+ if (index !== -1)
356
+ this.#queue.splice(index, 1);
357
+ }
358
+ #flush_queue() {
359
+ if (!this.connected || !this.ws)
360
+ return;
361
+ const queued = this.#queue;
362
+ this.#queue = [];
363
+ for (const q of queued) {
364
+ if (q.signal?.aborted) {
365
+ this.#detach_signal(q);
366
+ q.reject(this.#build_abort_error(q.method));
367
+ continue;
368
+ }
369
+ const sent = this.send(q.frame);
370
+ if (sent) {
371
+ this.#pending.set(q.id, {
372
+ method: q.method,
373
+ resolve: q.resolve,
374
+ reject: q.reject,
375
+ signal: q.signal,
376
+ signal_handler: q.signal_handler,
377
+ });
378
+ }
379
+ else {
380
+ this.#detach_signal(q);
381
+ q.reject(new Error(`[socket] queued request send failed (method=${q.method})`));
382
+ }
383
+ }
384
+ }
385
+ #reject_all(reason) {
386
+ const pending = this.#pending;
387
+ this.#pending = new Map();
388
+ for (const [id, p] of pending) {
389
+ this.#detach_signal(p);
390
+ p.reject(new Error(`[socket] ${reason} (method=${p.method}, id=${id})`));
391
+ }
392
+ const queued = this.#queue;
393
+ this.#queue = [];
394
+ for (const q of queued) {
395
+ this.#detach_signal(q);
396
+ q.reject(new Error(`[socket] ${reason} (method=${q.method})`));
397
+ }
398
+ }
399
+ #reject_pending_only(reason) {
400
+ // Socket closed but auto-reconnect will try again — pending requests were
401
+ // in flight on the old socket so we can't correlate them after reopen;
402
+ // queued requests haven't been sent yet and stay buffered for the flush.
403
+ const pending = this.#pending;
404
+ this.#pending = new Map();
405
+ for (const [id, p] of pending) {
406
+ this.#detach_signal(p);
407
+ p.reject(new Error(`[socket] ${reason} (method=${p.method}, id=${id})`));
408
+ }
409
+ }
410
+ #start_heartbeat() {
411
+ this.#cancel_heartbeat();
412
+ if (!this.#heartbeat_enabled)
413
+ return;
414
+ const now = Date.now();
415
+ this.#last_send_time = now;
416
+ this.#last_receive_time = now;
417
+ // Run the check at half the interval so any event-loop blockage pauses
418
+ // the timer itself; a dead-because-blocked socket is close enough to
419
+ // dead-because-unresponsive that closing is arguably correct.
420
+ const tick = Math.max(100, Math.floor(this.#heartbeat_interval / 2));
421
+ this.#heartbeat_timer = setInterval(() => this.#heartbeat_tick(), tick);
422
+ }
423
+ #cancel_heartbeat() {
424
+ if (this.#heartbeat_timer !== null) {
425
+ clearInterval(this.#heartbeat_timer);
426
+ this.#heartbeat_timer = null;
427
+ }
428
+ }
429
+ #heartbeat_tick() {
430
+ if (!this.connected || !this.ws)
431
+ return;
432
+ const now = Date.now();
433
+ const last_receive = this.#last_receive_time ?? now;
434
+ if (now - last_receive >= this.#heartbeat_receive_timeout) {
435
+ this.#log?.info(`[socket] receive timeout (${now - last_receive}ms) — closing ${WS_CLOSE_CLIENT_HEARTBEAT_TIMEOUT}`);
436
+ try {
437
+ this.ws.close(WS_CLOSE_CLIENT_HEARTBEAT_TIMEOUT, 'client heartbeat timeout');
438
+ }
439
+ catch (error) {
440
+ this.#log?.error('[socket] heartbeat timeout close failed:', error);
441
+ }
442
+ return;
443
+ }
444
+ const last_activity = Math.max(this.#last_send_time ?? 0, last_receive);
445
+ if (now - last_activity >= this.#heartbeat_interval) {
446
+ // Fire-and-forget the heartbeat. If it fails (network, serialization),
447
+ // receive-silence detection above will close the socket on the next
448
+ // tick. No queue — the heartbeat is the thing that tells us the
449
+ // queue needs flushing, it must not fight the queue for the slot.
450
+ void this.request(HEARTBEAT_METHOD, {}, { queue: false }).catch((error) => {
451
+ this.#log?.debug('[socket] heartbeat request failed:', error);
452
+ });
453
+ }
454
+ }
218
455
  #teardown(close_code) {
219
456
  if (!this.ws)
220
457
  return;
@@ -222,6 +459,7 @@ export class FrontendWebsocketClient {
222
459
  this.ws.removeEventListener('close', this.#handle_close);
223
460
  this.ws.removeEventListener('error', this.#handle_error);
224
461
  this.ws.removeEventListener('message', this.#handle_message);
462
+ this.#cancel_heartbeat();
225
463
  if (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING) {
226
464
  try {
227
465
  this.ws.close(close_code);
@@ -230,8 +468,10 @@ export class FrontendWebsocketClient {
230
468
  this.#log?.error('[socket] close failed:', error);
231
469
  }
232
470
  // Listeners are gone, so `#handle_close` won't fire for this close —
233
- // record it here so the client-initiated close is still observable.
471
+ // record it here so the client-initiated close is still observable,
472
+ // and reject any pending requests that can never resolve now.
234
473
  this.#record_close(close_code, '');
474
+ this.#reject_pending_only(`socket torn down (code ${close_code})`);
235
475
  }
236
476
  this.ws = null;
237
477
  }
@@ -267,12 +507,16 @@ export class FrontendWebsocketClient {
267
507
  this.current_reconnect_delay = 0;
268
508
  this.last_connect_time = Date.now();
269
509
  this.#cancel_reconnect();
510
+ this.#start_heartbeat();
511
+ // Flush buffered requests before anyone else can observe the open state.
512
+ this.#flush_queue();
270
513
  };
271
514
  #handle_close = (event) => {
272
515
  // Drop the dead-socket reference so consumers reading `client.ws` never
273
516
  // see a CLOSED WebSocket during the reconnect window.
274
517
  this.ws = null;
275
518
  this.#record_close(event.code, event.reason);
519
+ this.#cancel_heartbeat();
276
520
  // Session revocation is terminal — reconnecting would 401 in a loop.
277
521
  if (event.code === WS_CLOSE_SESSION_REVOKED) {
278
522
  this.#revoked = true;
@@ -280,8 +524,12 @@ export class FrontendWebsocketClient {
280
524
  this.#cancel_reconnect();
281
525
  this.reconnect_count = 0;
282
526
  this.current_reconnect_delay = 0;
527
+ this.#reject_all('session revoked');
283
528
  return;
284
529
  }
530
+ // Pending in-flight requests can't be correlated post-reconnect; reject
531
+ // them. Queue stays so the flush on reopen replays unsent work.
532
+ this.#reject_pending_only(`connection closed (code ${event.code})`);
285
533
  // Let `#schedule_reconnect` set `status: 'reconnecting'` directly to avoid
286
534
  // a transient `'closed'` flicker; only set `'closed'` when reconnect is off.
287
535
  if (this.#auto_reconnect) {
@@ -289,6 +537,7 @@ export class FrontendWebsocketClient {
289
537
  }
290
538
  else {
291
539
  this.status = 'closed';
540
+ this.#reject_all('connection closed, auto-reconnect disabled');
292
541
  }
293
542
  };
294
543
  #handle_error = (event) => {
@@ -304,6 +553,39 @@ export class FrontendWebsocketClient {
304
553
  // Browsers fire `close` after error; reconnect logic lives there.
305
554
  };
306
555
  #handle_message = (event) => {
556
+ this.#last_receive_time = Date.now();
557
+ // Intercept JSON-RPC responses for pending `request()` calls. Parse
558
+ // defensively — if the frame isn't valid JSON or isn't a response, fall
559
+ // through to the registered message handlers (which still see every
560
+ // notification, plus any stray response we don't own).
561
+ let json;
562
+ try {
563
+ json = JSON.parse(String(event.data));
564
+ }
565
+ catch {
566
+ json = undefined;
567
+ }
568
+ if (typeof json === 'object' &&
569
+ json !== null &&
570
+ 'id' in json &&
571
+ ('result' in json || 'error' in json)) {
572
+ const id = json.id;
573
+ if (id !== null) {
574
+ const pending = this.#pending.get(id);
575
+ if (pending) {
576
+ this.#pending.delete(id);
577
+ this.#detach_signal(pending);
578
+ if ('error' in json && json.error) {
579
+ const err = json.error;
580
+ pending.reject(new Error(`[rpc ${pending.method} #${id}] ${err.code ?? '?'} ${err.message ?? 'unknown error'}`));
581
+ }
582
+ else {
583
+ pending.resolve(json.result);
584
+ }
585
+ return;
586
+ }
587
+ }
588
+ }
307
589
  for (const handler of this.#message_handlers) {
308
590
  try {
309
591
  handler(event);
@@ -10,6 +10,10 @@ 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>;
15
19
  export interface Transport {
@@ -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,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"}
@@ -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
@@ -8,16 +8,25 @@
8
8
  * token id), and exposes a `connect()` factory returning a
9
9
  * `MockWsClient` per connection.
10
10
  *
11
- * Two layers are exported:
11
+ * Three layers are exported:
12
12
  *
13
13
  * - **Primitives** (`create_fake_ws`, `create_fake_hono_context`,
14
- * `create_stub_upgrade`, `MinimalActionEnvironment`) — used by
15
- * fuz_app's own dispatcher tests and by consumers wiring tight
16
- * one-off tests.
14
+ * `create_stub_upgrade`, `MinimalActionEnvironment`,
15
+ * `dispatch_ws_message`) — used by fuz_app's own dispatcher tests
16
+ * and by consumers wiring tight one-off tests.
17
17
  * - **Harness** (`create_ws_test_harness`, `MockWsClient`,
18
18
  * `keeper_identity`) — the high-level driver. Give it specs +
19
- * handlers, get back `{transport, connect()}`. Use this unless you
20
- * need bare primitives.
19
+ * handlers, get back `{transport, connect()}`. `connect()` is async
20
+ * and resolves after `on_socket_open` completes, so broadcasts sent
21
+ * immediately after `await harness.connect()` reach the client.
22
+ * - **Round-trip helpers** — `is_notification` / `is_notification_with`
23
+ * / `is_response_for` predicates, JSON-RPC wire-frame types
24
+ * (`JsonrpcNotificationFrame`, `JsonrpcSuccessResponseFrame`,
25
+ * `JsonrpcErrorResponseFrame` — distinct from the runtime Zod types
26
+ * in `http/jsonrpc.ts` so tests can narrow `params` / `result`),
27
+ * and `build_broadcast_api` for wiring a typed broadcast API against
28
+ * the harness's transport. Used by consumer round-trip test suites
29
+ * to replace ~100 lines of verbatim-identical glue.
21
30
  *
22
31
  * Hono's wire upgrade is skipped — the Node test runtime has no
23
32
  * `@hono/node-ws` adapter — but the full dispatch path is exercised
@@ -31,11 +40,13 @@ import type { Context } from 'hono';
31
40
  import { WSContext, type UpgradeWebSocket, type WSEvents } from 'hono/ws';
32
41
  import { Logger } from '@fuzdev/fuz_util/log.js';
33
42
  import type { ActionSpecUnion } from '../actions/action_spec.js';
43
+ import type { Action } from '../actions/action_types.js';
34
44
  import type { ActionEventEnvironment } from '../actions/action_event_types.js';
35
- import { type BaseHandlerContext, type RegisterActionWsOptions, type WsActionHandler } from '../actions/register_action_ws.js';
45
+ import { type BaseHandlerContext, type RegisterActionWsOptions } from '../actions/register_action_ws.js';
36
46
  import { BackendWebsocketTransport } from '../actions/transports_ws_backend.js';
37
47
  import { type RequestContext } from '../auth/request_context.js';
38
48
  import { type CredentialType } from '../hono_context.js';
49
+ import { JSONRPC_VERSION } from '../http/jsonrpc.js';
39
50
  import { type Uuid } from '../uuid.js';
40
51
  /**
41
52
  * A `WSContext` paired with capture arrays. Use `sends` to assert on
@@ -120,6 +131,16 @@ export interface WsConnectIdentity {
120
131
  export interface MockWsClient {
121
132
  /** Send a JSON-RPC message (request or notification) to the server. */
122
133
  send: (message: unknown) => Promise<void>;
134
+ /**
135
+ * Send a JSON-RPC request and await its response. Resolves with the
136
+ * `result`; throws with a useful message (code, text, and any `data`
137
+ * payload) on an error frame — without this, asserting on
138
+ * `result.foo` for a failed request throws
139
+ * `Cannot read property 'foo' of undefined`, which hides the real
140
+ * cause. Use `send` + `wait_for(is_response_for(id))` directly when
141
+ * you need to assert on the error frame itself.
142
+ */
143
+ request: <R = unknown>(id: number | string, method: string, params: unknown, timeout_ms?: number) => Promise<R>;
123
144
  /**
124
145
  * Close the connection, firing `onClose`. Returns a promise that
125
146
  * resolves once `on_socket_close` (and the transport's own cleanup)
@@ -132,16 +153,72 @@ export interface MockWsClient {
132
153
  * Wait until a message satisfies `predicate`. Matches are checked
133
154
  * against already-received messages first, then new arrivals until
134
155
  * the timeout (defaults to 1000ms).
156
+ *
157
+ * When `predicate` is a type guard (e.g. `is_notification_with<P>`),
158
+ * the result is narrowed automatically and callers don't need to
159
+ * spell `<JsonrpcNotificationFrame<P>>` on the call site.
135
160
  */
136
- wait_for: <T = unknown>(predicate: (msg: unknown) => boolean, timeout_ms?: number) => Promise<T>;
161
+ wait_for: {
162
+ <T>(predicate: (msg: unknown) => msg is T, timeout_ms?: number): Promise<T>;
163
+ <T = unknown>(predicate: (msg: unknown) => boolean, timeout_ms?: number): Promise<T>;
164
+ };
165
+ }
166
+ export interface JsonrpcNotificationFrame<P = unknown> {
167
+ jsonrpc: typeof JSONRPC_VERSION;
168
+ method: string;
169
+ params: P;
170
+ }
171
+ export interface JsonrpcSuccessResponseFrame<R = unknown> {
172
+ jsonrpc: typeof JSONRPC_VERSION;
173
+ id: number | string;
174
+ result: R;
175
+ }
176
+ export interface JsonrpcErrorResponseFrame<D = unknown> {
177
+ jsonrpc: typeof JSONRPC_VERSION;
178
+ id: number | string;
179
+ error: {
180
+ code: number;
181
+ message: string;
182
+ data?: D;
183
+ };
137
184
  }
185
+ /** Predicate matching a JSON-RPC notification with the given method name. */
186
+ export declare const is_notification: (method: string) => (msg: unknown) => boolean;
187
+ /**
188
+ * Type-guard combinator: match a notification whose typed `params` satisfies
189
+ * `match`. Collapses the common test pattern of casting `msg` to
190
+ * `JsonrpcNotificationFrame<P>` in every predicate body.
191
+ *
192
+ * ```ts
193
+ * const match_roster_for = (id: Uuid) =>
194
+ * is_notification_with<RosterChangedParams>(
195
+ * WORLD_METHODS.roster_changed,
196
+ * (params) => params.character_id === id && !params.removed,
197
+ * );
198
+ * const roster = await client.wait_for(match_roster_for(char_id));
199
+ * ```
200
+ */
201
+ export declare const is_notification_with: <P>(method: string, match: (params: P) => boolean) => (msg: unknown) => msg is JsonrpcNotificationFrame<P>;
202
+ /** Predicate matching a JSON-RPC response frame (success or error) for the given request id. */
203
+ export declare const is_response_for: (id: number | string) => (msg: unknown) => boolean;
138
204
  /** Options for `create_ws_test_harness`. */
139
205
  export interface CreateWsTestHarnessOptions<TCtx extends BaseHandlerContext> {
140
- specs: ReadonlyArray<ActionSpecUnion>;
141
- handlers: Record<string, WsActionHandler<TCtx>>;
206
+ /**
207
+ * The actions registered on this endpoint — matches the shape
208
+ * `register_action_ws` accepts. Each entry is a `{spec, handler?}` tuple;
209
+ * shared fuz_app primitives (like `heartbeat_action`) can be spread in
210
+ * alongside consumer-specific actions.
211
+ */
212
+ actions: ReadonlyArray<Action<TCtx>>;
142
213
  extend_context?: RegisterActionWsOptions<TCtx>['extend_context'];
143
214
  /** Pass a pre-created transport to share with a broadcast API. */
144
215
  transport?: BackendWebsocketTransport;
216
+ /**
217
+ * Threaded through to `register_action_ws`. Defaults to `false` in tests —
218
+ * fake timers + receive-silence detection need explicit opt-in and per-
219
+ * test tuning to avoid spurious closes.
220
+ */
221
+ heartbeat?: RegisterActionWsOptions<TCtx>['heartbeat'];
145
222
  /** Optional logger. Defaults to a silent `[ws-test]` logger. */
146
223
  log?: Logger;
147
224
  /** Threaded straight through to `register_action_ws`. */
@@ -152,7 +229,14 @@ export interface CreateWsTestHarnessOptions<TCtx extends BaseHandlerContext> {
152
229
  /** A harness instance — transport handle + connection factory. */
153
230
  export interface WsTestHarness {
154
231
  transport: BackendWebsocketTransport;
155
- connect: (identity?: WsConnectIdentity) => MockWsClient;
232
+ /**
233
+ * Open a mock connection. Resolves after `on_socket_open` (and the
234
+ * transport's `register_ws`) completes, so broadcasts issued
235
+ * immediately after the `await` reach the connection. Earlier
236
+ * revisions returned synchronously and required a `settle_open()`
237
+ * microtask drain — no longer necessary.
238
+ */
239
+ connect: (identity?: WsConnectIdentity) => Promise<MockWsClient>;
156
240
  }
157
241
  /**
158
242
  * Create a WebSocket test harness for the given specs + handlers.
@@ -166,4 +250,25 @@ export interface WsTestHarness {
166
250
  export declare const create_ws_test_harness: <TCtx extends BaseHandlerContext>(options: CreateWsTestHarnessOptions<TCtx>) => WsTestHarness;
167
251
  /** Convenience: default identity for keeper-authenticated connections. */
168
252
  export declare const keeper_identity: () => WsConnectIdentity;
253
+ /**
254
+ * Wire a typed broadcast API against the harness's transport, matching
255
+ * how a consumer's real backend composes the stack. Returns the typed
256
+ * API so tests can call `.tx_run_created(...)` / `.workspace_changed(...)`
257
+ * etc. directly.
258
+ *
259
+ * ```ts
260
+ * const harness = create_ws_test_harness<BaseHandlerContext>({specs, handlers});
261
+ * const broadcast = build_broadcast_api<MyBackendActionsApi>({
262
+ * harness,
263
+ * specs: my_broadcast_action_specs,
264
+ * });
265
+ * const client = await harness.connect(keeper_identity());
266
+ * await broadcast.tx_run_created({run_id: '...', ...});
267
+ * await client.wait_for(is_notification('tx_run_created'));
268
+ * ```
269
+ */
270
+ export declare const build_broadcast_api: <TApi>(options: {
271
+ harness: WsTestHarness;
272
+ specs: ReadonlyArray<ActionSpecUnion>;
273
+ }) => TApi;
169
274
  //# sourceMappingURL=ws_round_trip.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"ws_round_trip.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/testing/ws_round_trip.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"ws_round_trip.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/testing/ws_round_trip.ts"],"names":[],"mappings":"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"}