@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.
- package/dist/actions/action_types.d.ts +57 -0
- package/dist/actions/action_types.d.ts.map +1 -0
- package/dist/actions/action_types.js +11 -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 +53 -3
- package/dist/actions/socket.svelte.d.ts +76 -4
- package/dist/actions/socket.svelte.d.ts.map +1 -1
- package/dist/actions/socket.svelte.js +288 -6
- package/dist/actions/transports.d.ts +4 -0
- package/dist/actions/transports.d.ts.map +1 -1
- package/dist/actions/transports.js +4 -0
- package/dist/testing/ws_round_trip.d.ts +116 -11
- package/dist/testing/ws_round_trip.d.ts.map +1 -1
- package/dist/testing/ws_round_trip.js +137 -56
- package/package.json +1 -1
|
@@ -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),
|
|
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 { 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;
|
|
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
|
-
*
|
|
11
|
+
* Three layers are exported:
|
|
12
12
|
*
|
|
13
13
|
* - **Primitives** (`create_fake_ws`, `create_fake_hono_context`,
|
|
14
|
-
* `create_stub_upgrade`, `MinimalActionEnvironment
|
|
15
|
-
* fuz_app's own dispatcher tests
|
|
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()}`.
|
|
20
|
-
*
|
|
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
|
|
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:
|
|
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
|
-
|
|
141
|
-
|
|
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
|
-
|
|
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":"AAAA
|
|
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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"}
|