@fuzdev/fuz_app 0.113.0 → 0.114.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/CLAUDE.md +46 -20
- package/dist/actions/action_dispatcher.d.ts +1 -1
- package/dist/actions/action_dispatcher.d.ts.map +1 -1
- package/dist/actions/action_dispatcher.js +22 -16
- package/dist/actions/action_event.d.ts +18 -2
- package/dist/actions/action_event.d.ts.map +1 -1
- package/dist/actions/action_event.js +63 -19
- package/dist/actions/action_event_types.d.ts +22 -0
- package/dist/actions/action_event_types.d.ts.map +1 -1
- package/dist/actions/action_event_types.js +19 -0
- package/dist/actions/compile_action_registry.d.ts +4 -2
- package/dist/actions/compile_action_registry.d.ts.map +1 -1
- package/dist/actions/compile_action_registry.js +8 -3
- package/dist/actions/frontend_rpc_client.d.ts +6 -0
- package/dist/actions/frontend_rpc_client.d.ts.map +1 -1
- package/dist/actions/frontend_rpc_client.js +2 -1
- package/dist/actions/heartbeat.d.ts +8 -4
- package/dist/actions/heartbeat.d.ts.map +1 -1
- package/dist/actions/heartbeat.js +8 -4
- package/dist/actions/peer_ping.d.ts.map +1 -1
- package/dist/actions/peer_ping.js +3 -1
- package/dist/actions/perform_action.d.ts +1 -1
- package/dist/actions/perform_action.d.ts.map +1 -1
- package/dist/actions/perform_action.js +6 -4
- package/dist/actions/register_action_ws.d.ts +13 -0
- package/dist/actions/register_action_ws.d.ts.map +1 -1
- package/dist/actions/register_action_ws.js +23 -2
- package/dist/actions/rpc_client.d.ts +1 -1
- package/dist/actions/rpc_client.d.ts.map +1 -1
- package/dist/actions/rpc_client.js +2 -1
- package/dist/actions/socket.svelte.d.ts +53 -10
- package/dist/actions/socket.svelte.d.ts.map +1 -1
- package/dist/actions/socket.svelte.js +84 -23
- package/dist/actions/transports.d.ts +24 -0
- package/dist/actions/transports.d.ts.map +1 -1
- package/dist/actions/transports.js +30 -0
- package/dist/actions/transports_http.d.ts +11 -4
- package/dist/actions/transports_http.d.ts.map +1 -1
- package/dist/actions/transports_http.js +63 -18
- package/dist/actions/transports_ws.d.ts +28 -3
- package/dist/actions/transports_ws.d.ts.map +1 -1
- package/dist/actions/transports_ws.js +33 -4
- package/dist/actions/ws_endpoint_spec.d.ts +7 -0
- package/dist/actions/ws_endpoint_spec.d.ts.map +1 -1
- package/dist/http/CLAUDE.md +7 -3
- package/dist/http/jsonrpc_helpers.d.ts +0 -5
- package/dist/http/jsonrpc_helpers.d.ts.map +1 -1
- package/dist/http/jsonrpc_helpers.js +0 -13
- package/dist/http/schema_helpers.d.ts +56 -0
- package/dist/http/schema_helpers.d.ts.map +1 -1
- package/dist/http/schema_helpers.js +91 -0
- package/dist/server/app_server.d.ts.map +1 -1
- package/dist/server/app_server.js +1 -0
- package/dist/testing/CLAUDE.md +17 -12
- package/dist/testing/cross_backend/peer_ping_ws.d.ts.map +1 -1
- package/dist/testing/cross_backend/peer_ping_ws.js +18 -0
- package/dist/testing/cross_backend/ws_round_trip.d.ts +11 -3
- package/dist/testing/cross_backend/ws_round_trip.d.ts.map +1 -1
- package/dist/testing/cross_backend/ws_round_trip.js +74 -12
- package/dist/testing/transports/ws_client.d.ts +6 -0
- package/dist/testing/transports/ws_client.d.ts.map +1 -1
- package/dist/testing/transports/ws_transport.d.ts.map +1 -1
- package/dist/testing/transports/ws_transport.js +6 -1
- package/dist/testing/ws_round_trip.d.ts.map +1 -1
- package/dist/testing/ws_round_trip.js +5 -0
- package/dist/ui/form_state.svelte.d.ts +9 -4
- package/dist/ui/form_state.svelte.d.ts.map +1 -1
- package/dist/ui/form_state.svelte.js +28 -8
- package/package.json +2 -2
- package/dist/actions/request_tracker.svelte.d.ts +0 -77
- package/dist/actions/request_tracker.svelte.d.ts.map +0 -1
- package/dist/actions/request_tracker.svelte.js +0 -169
|
@@ -16,10 +16,11 @@
|
|
|
16
16
|
* to `DEFAULT_QUEUE_MAX_SIZE` requests and flush on reopen. Overflow
|
|
17
17
|
* rejects with `queue_overflow`. Raw `FrontendWebsocketClient.send`
|
|
18
18
|
* is drop-on-disconnect (fire-and-forget notifications want that).
|
|
19
|
-
* - **Activity-aware heartbeat** —
|
|
20
|
-
* receive-silence past
|
|
21
|
-
*
|
|
22
|
-
*
|
|
19
|
+
* - **Activity-aware heartbeat** — a shared `heartbeat` request fires when
|
|
20
|
+
* either direction has been idle for the interval; receive-silence past
|
|
21
|
+
* the receive timeout (`DEFAULT_HEARTBEAT_RECEIVE_TIMEOUT`, never less
|
|
22
|
+
* than `HEARTBEAT_RECEIVE_TIMEOUT_MIN_FACTOR` × the interval) closes with
|
|
23
|
+
* `WS_CLOSE_CLIENT_HEARTBEAT_TIMEOUT` and lets auto-reconnect pick back up.
|
|
23
24
|
*
|
|
24
25
|
* @module
|
|
25
26
|
*/
|
|
@@ -39,8 +40,31 @@ export const DEFAULT_RECONNECT_DELAY_MAX = 10000;
|
|
|
39
40
|
export const DEFAULT_BACKOFF_FACTOR = 1.5;
|
|
40
41
|
/** Idle interval before sending a heartbeat (ms). */
|
|
41
42
|
export const DEFAULT_HEARTBEAT_INTERVAL = 30_000;
|
|
42
|
-
/**
|
|
43
|
+
/**
|
|
44
|
+
* Max receive silence before closing with `WS_CLOSE_CLIENT_HEARTBEAT_TIMEOUT`
|
|
45
|
+
* (ms), for intervals up to half of it — see `resolve_heartbeat_receive_timeout`.
|
|
46
|
+
*/
|
|
43
47
|
export const DEFAULT_HEARTBEAT_RECEIVE_TIMEOUT = 60_000;
|
|
48
|
+
/**
|
|
49
|
+
* Minimum ratio of the heartbeat receive timeout to the heartbeat interval.
|
|
50
|
+
*
|
|
51
|
+
* A heartbeat goes out once a direction has been idle for `interval`, and the
|
|
52
|
+
* check runs every `interval / 2`, so on an idle socket it can leave up to
|
|
53
|
+
* 1.5 × `interval` after the last receive — and its response needs a round
|
|
54
|
+
* trip on top. A receive timeout under 2 × `interval` can close a healthy
|
|
55
|
+
* idle socket before its heartbeat is answered, so it's clamped up to this.
|
|
56
|
+
*/
|
|
57
|
+
export const HEARTBEAT_RECEIVE_TIMEOUT_MIN_FACTOR = 2;
|
|
58
|
+
/**
|
|
59
|
+
* Resolve the effective heartbeat receive timeout for `interval`: the given
|
|
60
|
+
* `receive_timeout` (or `DEFAULT_HEARTBEAT_RECEIVE_TIMEOUT`), raised to at
|
|
61
|
+
* least `HEARTBEAT_RECEIVE_TIMEOUT_MIN_FACTOR` × `interval`.
|
|
62
|
+
*
|
|
63
|
+
* @param interval - heartbeat idle interval in ms
|
|
64
|
+
* @param receive_timeout - requested receive timeout in ms
|
|
65
|
+
* @returns the receive timeout in ms the client enforces
|
|
66
|
+
*/
|
|
67
|
+
export const resolve_heartbeat_receive_timeout = (interval, receive_timeout = DEFAULT_HEARTBEAT_RECEIVE_TIMEOUT) => Math.max(receive_timeout, interval * HEARTBEAT_RECEIVE_TIMEOUT_MIN_FACTOR);
|
|
44
68
|
/** Default bound on buffered requests while disconnected. Overflow rejects. */
|
|
45
69
|
export const DEFAULT_QUEUE_MAX_SIZE = 100;
|
|
46
70
|
/**
|
|
@@ -70,10 +94,12 @@ export class FrontendWebsocketClient {
|
|
|
70
94
|
#pending = new Map();
|
|
71
95
|
#queue = [];
|
|
72
96
|
#heartbeat_timer = null;
|
|
73
|
-
/**
|
|
74
|
-
#
|
|
75
|
-
/** Epoch ms of the last
|
|
76
|
-
#
|
|
97
|
+
/** Whether a heartbeat request is awaiting its response — at most one is outstanding. */
|
|
98
|
+
#heartbeat_in_flight = false;
|
|
99
|
+
/** Epoch ms of the last outgoing send, reset on open — the heartbeat activity baseline. */
|
|
100
|
+
#heartbeat_send_mark = null;
|
|
101
|
+
/** Epoch ms of the last incoming message, reset on open — the heartbeat activity baseline. */
|
|
102
|
+
#heartbeat_receive_mark = null;
|
|
77
103
|
ws = $state.raw(null);
|
|
78
104
|
status = $state.raw('initial');
|
|
79
105
|
reconnect_count = $state.raw(0);
|
|
@@ -86,6 +112,17 @@ export class FrontendWebsocketClient {
|
|
|
86
112
|
last_close_code = $state.raw(null);
|
|
87
113
|
/** Reason string from the most recent close event (may be empty). */
|
|
88
114
|
last_close_reason = $state.raw(null);
|
|
115
|
+
/**
|
|
116
|
+
* Epoch ms of the most recent frame written to the socket — requests,
|
|
117
|
+
* notifications, heartbeats, and cancels alike. `null` until the first send.
|
|
118
|
+
*/
|
|
119
|
+
last_send_time = $state.raw(null);
|
|
120
|
+
/**
|
|
121
|
+
* Epoch ms of the most recent frame received — responses (including ones
|
|
122
|
+
* `request` consumes), notifications, and requests alike. `null` until the
|
|
123
|
+
* first receive.
|
|
124
|
+
*/
|
|
125
|
+
last_receive_time = $state.raw(null);
|
|
89
126
|
/**
|
|
90
127
|
* The error thrown by the most recent attempted `send()`, or `null` if the
|
|
91
128
|
* most recent attempt succeeded or none has been attempted yet. Populated
|
|
@@ -114,8 +151,7 @@ export class FrontendWebsocketClient {
|
|
|
114
151
|
this.#heartbeat_enabled = heartbeat !== false;
|
|
115
152
|
const heartbeat_config = typeof heartbeat === 'object' && heartbeat !== null ? heartbeat : {};
|
|
116
153
|
this.#heartbeat_interval = heartbeat_config.interval ?? DEFAULT_HEARTBEAT_INTERVAL;
|
|
117
|
-
this.#heartbeat_receive_timeout =
|
|
118
|
-
heartbeat_config.receive_timeout ?? DEFAULT_HEARTBEAT_RECEIVE_TIMEOUT;
|
|
154
|
+
this.#heartbeat_receive_timeout = resolve_heartbeat_receive_timeout(this.#heartbeat_interval, heartbeat_config.receive_timeout);
|
|
119
155
|
const queue = options.queue;
|
|
120
156
|
this.#queue_enabled = queue !== false;
|
|
121
157
|
const queue_config = typeof queue === 'object' ? queue : {};
|
|
@@ -199,7 +235,7 @@ export class FrontendWebsocketClient {
|
|
|
199
235
|
this.#heartbeat_enabled = heartbeat !== false;
|
|
200
236
|
const config = typeof heartbeat === 'object' && heartbeat !== null ? heartbeat : {};
|
|
201
237
|
this.#heartbeat_interval = config.interval ?? DEFAULT_HEARTBEAT_INTERVAL;
|
|
202
|
-
this.#heartbeat_receive_timeout = config.receive_timeout
|
|
238
|
+
this.#heartbeat_receive_timeout = resolve_heartbeat_receive_timeout(this.#heartbeat_interval, config.receive_timeout);
|
|
203
239
|
if (this.connected) {
|
|
204
240
|
this.#start_heartbeat();
|
|
205
241
|
}
|
|
@@ -305,7 +341,9 @@ export class FrontendWebsocketClient {
|
|
|
305
341
|
try {
|
|
306
342
|
this.ws.send(JSON.stringify(data));
|
|
307
343
|
this.last_send_error = null;
|
|
308
|
-
|
|
344
|
+
const now = Date.now();
|
|
345
|
+
this.#heartbeat_send_mark = now;
|
|
346
|
+
this.last_send_time = now;
|
|
309
347
|
return true;
|
|
310
348
|
}
|
|
311
349
|
catch (error) {
|
|
@@ -321,6 +359,10 @@ export class FrontendWebsocketClient {
|
|
|
321
359
|
* own peer-minted UUID), tracks the pending promise, and resolves when the
|
|
322
360
|
* server sends a matching response.
|
|
323
361
|
*
|
|
362
|
+
* An `undefined` `params` is omitted from the frame rather than sent as
|
|
363
|
+
* `{}` — the absent form is the parameterless call every server admits
|
|
364
|
+
* for a `z.void()` input, including servers that refuse `{}`.
|
|
365
|
+
*
|
|
324
366
|
* Callers supplying an explicit `options.id` are responsible for
|
|
325
367
|
* uniqueness — the pending map is keyed by id, and a duplicate silently
|
|
326
368
|
* overwrites the prior entry. Auto-minted ids are monotonic and never
|
|
@@ -341,7 +383,7 @@ export class FrontendWebsocketClient {
|
|
|
341
383
|
*
|
|
342
384
|
* @mutates this - inserts the new pending entry into `#pending` (or
|
|
343
385
|
* buffers into `#queue` when disconnected) and may bump
|
|
344
|
-
* `#next_request_id`,
|
|
386
|
+
* `#next_request_id`, `last_send_time`, `last_send_error`
|
|
345
387
|
* @throws ThrownJsonrpcError on the returned promise — never thrown
|
|
346
388
|
* synchronously. Rejection codes:
|
|
347
389
|
* - `unauthenticated` — session revoked (entry check or close code)
|
|
@@ -352,7 +394,7 @@ export class FrontendWebsocketClient {
|
|
|
352
394
|
* - `internal_error` — `ws.send` threw (serialization, buffer full)
|
|
353
395
|
* - server's wire code verbatim — JSON-RPC error frame from peer
|
|
354
396
|
*/
|
|
355
|
-
request(method, params
|
|
397
|
+
request(method, params, options = {}) {
|
|
356
398
|
return new Promise((resolve, reject) => {
|
|
357
399
|
const resolve_typed = resolve;
|
|
358
400
|
const reject_typed = reject;
|
|
@@ -366,7 +408,13 @@ export class FrontendWebsocketClient {
|
|
|
366
408
|
return;
|
|
367
409
|
}
|
|
368
410
|
const id = options.id ?? ++this.#next_request_id;
|
|
369
|
-
|
|
411
|
+
// an `undefined` `params` is omitted, never defaulted to `{}` — the
|
|
412
|
+
// absent form is JSON-RPC 2.0 §4.2's parameterless call and the one
|
|
413
|
+
// shape every server admits for a `z.void()` input, including
|
|
414
|
+
// servers that refuse `{}`
|
|
415
|
+
const frame = params === undefined
|
|
416
|
+
? { jsonrpc: JSONRPC_VERSION, id, method }
|
|
417
|
+
: { jsonrpc: JSONRPC_VERSION, id, method, params };
|
|
370
418
|
// Bind the signal listener up-front so `#detach_signal` can find it by
|
|
371
419
|
// reference regardless of which settlement path runs (inline send,
|
|
372
420
|
// queued flush, close-time reject).
|
|
@@ -520,8 +568,8 @@ export class FrontendWebsocketClient {
|
|
|
520
568
|
if (!this.#heartbeat_enabled)
|
|
521
569
|
return;
|
|
522
570
|
const now = Date.now();
|
|
523
|
-
this.#
|
|
524
|
-
this.#
|
|
571
|
+
this.#heartbeat_send_mark = now;
|
|
572
|
+
this.#heartbeat_receive_mark = now;
|
|
525
573
|
// Run the check at half the interval so any event-loop blockage pauses
|
|
526
574
|
// the timer itself; a dead-because-blocked socket is close enough to
|
|
527
575
|
// dead-because-unresponsive that closing is arguably correct.
|
|
@@ -538,7 +586,7 @@ export class FrontendWebsocketClient {
|
|
|
538
586
|
if (!this.connected || !this.ws)
|
|
539
587
|
return;
|
|
540
588
|
const now = Date.now();
|
|
541
|
-
const last_receive = this.#
|
|
589
|
+
const last_receive = this.#heartbeat_receive_mark ?? now;
|
|
542
590
|
if (now - last_receive >= this.#heartbeat_receive_timeout) {
|
|
543
591
|
this.#log?.info(`[socket] receive timeout (${now - last_receive}ms) — closing ${WS_CLOSE_CLIENT_HEARTBEAT_TIMEOUT}`);
|
|
544
592
|
try {
|
|
@@ -549,14 +597,25 @@ export class FrontendWebsocketClient {
|
|
|
549
597
|
}
|
|
550
598
|
return;
|
|
551
599
|
}
|
|
552
|
-
|
|
553
|
-
|
|
600
|
+
// Heartbeat when either direction has gone quiet: receive-only silence
|
|
601
|
+
// would trip this client's receive timeout, send-only silence the
|
|
602
|
+
// server's. Traffic both ways keeps both fed.
|
|
603
|
+
// One outstanding heartbeat at a time — its response feeds the receive
|
|
604
|
+
// side, and if it never comes the receive timeout above closes the socket.
|
|
605
|
+
const last_send = this.#heartbeat_send_mark ?? now;
|
|
606
|
+
if (!this.#heartbeat_in_flight &&
|
|
607
|
+
now - Math.min(last_send, last_receive) >= this.#heartbeat_interval) {
|
|
554
608
|
// Fire-and-forget the heartbeat. If it fails (network, serialization),
|
|
555
609
|
// receive-silence detection above will close the socket on the next
|
|
556
610
|
// tick. No queue — the heartbeat is the thing that tells us the
|
|
557
611
|
// queue needs flushing, it must not fight the queue for the slot.
|
|
558
|
-
|
|
612
|
+
this.#heartbeat_in_flight = true;
|
|
613
|
+
void this.request(heartbeat_action_spec.method, undefined, { queue: false })
|
|
614
|
+
.catch((error) => {
|
|
559
615
|
this.#log?.debug('[socket] heartbeat request failed:', error);
|
|
616
|
+
})
|
|
617
|
+
.finally(() => {
|
|
618
|
+
this.#heartbeat_in_flight = false;
|
|
560
619
|
});
|
|
561
620
|
}
|
|
562
621
|
}
|
|
@@ -664,7 +723,9 @@ export class FrontendWebsocketClient {
|
|
|
664
723
|
// Browsers fire `close` after error; reconnect logic lives there.
|
|
665
724
|
};
|
|
666
725
|
#handle_message = (event) => {
|
|
667
|
-
|
|
726
|
+
const now = Date.now();
|
|
727
|
+
this.#heartbeat_receive_mark = now;
|
|
728
|
+
this.last_receive_time = now;
|
|
668
729
|
// Intercept JSON-RPC responses for pending `request()` calls. Parse
|
|
669
730
|
// defensively — if the frame isn't valid JSON or isn't a response, fall
|
|
670
731
|
// through to the registered message handlers (which still see every
|
|
@@ -14,6 +14,30 @@ export declare const WS_CLOSE_SESSION_REVOKED = 4001;
|
|
|
14
14
|
export declare const WS_CLOSE_CLIENT_HEARTBEAT_TIMEOUT = 4002;
|
|
15
15
|
/** WebSocket close code — server timed out with no incoming activity. */
|
|
16
16
|
export declare const WS_CLOSE_SERVER_HEARTBEAT_TIMEOUT = 4003;
|
|
17
|
+
/**
|
|
18
|
+
* WebSocket close code — an inbound message exceeded the receiver's size cap
|
|
19
|
+
* (RFC 6455 §7.4.1 "Message Too Big").
|
|
20
|
+
*/
|
|
21
|
+
export declare const WS_CLOSE_MESSAGE_TOO_BIG = 1009;
|
|
22
|
+
/**
|
|
23
|
+
* Default cap on one WebSocket message, in UTF-8 bytes — equal to the default
|
|
24
|
+
* HTTP body cap (`DEFAULT_MAX_BODY_SIZE`), so one JSON-RPC request is bounded
|
|
25
|
+
* the same on both transports. The server closes the socket on a larger
|
|
26
|
+
* inbound message with `WS_CLOSE_MESSAGE_TOO_BIG` (there's no per-message
|
|
27
|
+
* error reply), and `FrontendWebsocketTransport` refuses to send one. The
|
|
28
|
+
* twin of the Rust spine's `DEFAULT_WS_MAX_MESSAGE_BYTES`.
|
|
29
|
+
*/
|
|
30
|
+
export declare const DEFAULT_WS_MAX_MESSAGE_BYTES: number;
|
|
31
|
+
/**
|
|
32
|
+
* The UTF-8 byte length of `text` when it exceeds `max_bytes`, else `null`.
|
|
33
|
+
* Skips encoding when the UTF-16 length alone rules it out — a code unit
|
|
34
|
+
* takes at most 3 UTF-8 bytes.
|
|
35
|
+
*
|
|
36
|
+
* @param text - the string to measure
|
|
37
|
+
* @param max_bytes - the byte cap
|
|
38
|
+
* @returns the UTF-8 byte length when it exceeds `max_bytes`, else `null`
|
|
39
|
+
*/
|
|
40
|
+
export declare const utf8_length_over: (text: string, max_bytes: number) => number | null;
|
|
17
41
|
export declare const TransportName: z.ZodString;
|
|
18
42
|
export type TransportName = z.infer<typeof TransportName>;
|
|
19
43
|
/**
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"transports.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/actions/transports.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB,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;
|
|
1
|
+
{"version":3,"file":"transports.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/actions/transports.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB,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;AACtD;;;GAGG;AACH,eAAO,MAAM,wBAAwB,OAAO,CAAC;AAE7C;;;;;;;GAOG;AACH,eAAO,MAAM,4BAA4B,QAAc,CAAC;AAIxD;;;;;;;;GAQG;AACH,eAAO,MAAM,gBAAgB,GAAI,MAAM,MAAM,EAAE,WAAW,MAAM,KAAG,MAAM,GAAG,IAI3E,CAAC;AAKF,eAAO,MAAM,aAAa,aAAa,CAAC;AACxC,MAAM,MAAM,aAAa,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,aAAa,CAAC,CAAC;AAE1D;;;;;GAKG;AACH,MAAM,WAAW,oBAAoB;IACpC;;;;;OAKG;IACH,MAAM,CAAC,EAAE,WAAW,CAAC;IACrB;;;;;;;;;;;OAWG;IACH,KAAK,CAAC,EAAE,OAAO,CAAC;CAChB;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;;;;;OAKG;IACH,kBAAkB,CAAC,SAAS,EAAE,SAAS,GAAG,IAAI;IAQ9C;;;;;OAKG;IACH,qBAAqB,CAAC,cAAc,EAAE,aAAa,GAAG,IAAI;IAM1D;;;;;;OAMG;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;CAwCtE"}
|
|
@@ -13,6 +13,36 @@ export const WS_CLOSE_SESSION_REVOKED = 4001;
|
|
|
13
13
|
export const WS_CLOSE_CLIENT_HEARTBEAT_TIMEOUT = 4002;
|
|
14
14
|
/** WebSocket close code — server timed out with no incoming activity. */
|
|
15
15
|
export const WS_CLOSE_SERVER_HEARTBEAT_TIMEOUT = 4003;
|
|
16
|
+
/**
|
|
17
|
+
* WebSocket close code — an inbound message exceeded the receiver's size cap
|
|
18
|
+
* (RFC 6455 §7.4.1 "Message Too Big").
|
|
19
|
+
*/
|
|
20
|
+
export const WS_CLOSE_MESSAGE_TOO_BIG = 1009;
|
|
21
|
+
/**
|
|
22
|
+
* Default cap on one WebSocket message, in UTF-8 bytes — equal to the default
|
|
23
|
+
* HTTP body cap (`DEFAULT_MAX_BODY_SIZE`), so one JSON-RPC request is bounded
|
|
24
|
+
* the same on both transports. The server closes the socket on a larger
|
|
25
|
+
* inbound message with `WS_CLOSE_MESSAGE_TOO_BIG` (there's no per-message
|
|
26
|
+
* error reply), and `FrontendWebsocketTransport` refuses to send one. The
|
|
27
|
+
* twin of the Rust spine's `DEFAULT_WS_MAX_MESSAGE_BYTES`.
|
|
28
|
+
*/
|
|
29
|
+
export const DEFAULT_WS_MAX_MESSAGE_BYTES = 1024 * 1024;
|
|
30
|
+
const text_encoder = new TextEncoder();
|
|
31
|
+
/**
|
|
32
|
+
* The UTF-8 byte length of `text` when it exceeds `max_bytes`, else `null`.
|
|
33
|
+
* Skips encoding when the UTF-16 length alone rules it out — a code unit
|
|
34
|
+
* takes at most 3 UTF-8 bytes.
|
|
35
|
+
*
|
|
36
|
+
* @param text - the string to measure
|
|
37
|
+
* @param max_bytes - the byte cap
|
|
38
|
+
* @returns the UTF-8 byte length when it exceeds `max_bytes`, else `null`
|
|
39
|
+
*/
|
|
40
|
+
export const utf8_length_over = (text, max_bytes) => {
|
|
41
|
+
if (text.length * 3 <= max_bytes)
|
|
42
|
+
return null;
|
|
43
|
+
const size = text_encoder.encode(text).byteLength;
|
|
44
|
+
return size > max_bytes ? size : null;
|
|
45
|
+
};
|
|
16
46
|
// TODO figure out the symmetry of frontend and backend transports (none/partial/full?) --
|
|
17
47
|
// we may also need orthogonal abstractions to clarify the transport role
|
|
18
48
|
export const TransportName = z.string(); // not branded for convenience, will just error at runtime, the schema is just for docs atm
|
|
@@ -3,14 +3,21 @@
|
|
|
3
3
|
*
|
|
4
4
|
* @module
|
|
5
5
|
*/
|
|
6
|
-
import type
|
|
6
|
+
import { JsonrpcErrorResponse, type JsonrpcNotification, type JsonrpcRequest, type JsonrpcResponseOrError } from '../http/jsonrpc.js';
|
|
7
7
|
import type { Transport, TransportSendOptions } from './transports.js';
|
|
8
8
|
/**
|
|
9
9
|
* Thin `fetch` adapter for the JSON-RPC endpoint. POST by default; GET when
|
|
10
10
|
* the optional `has_side_effects(method)` callback returns `false` for the
|
|
11
|
-
* method (matches `create_rpc_endpoint`'s GET convention).
|
|
12
|
-
*
|
|
13
|
-
*
|
|
11
|
+
* method (matches `create_rpc_endpoint`'s GET convention). Always reports ready.
|
|
12
|
+
*
|
|
13
|
+
* On non-OK HTTP responses, a body that is a JSON-RPC error response for this
|
|
14
|
+
* request (same `id`, or `null` when the server couldn't read one) is returned
|
|
15
|
+
* as-is, so the server's `code`, `message`, and `data` reach the caller — the
|
|
16
|
+
* status → code map is lossy (`queue_overflow` and `rate_limited` share 429).
|
|
17
|
+
* Any other body (HTML from a proxy, empty, malformed) gets a synthesized
|
|
18
|
+
* envelope whose code comes from `http_status_to_jsonrpc_error_code`.
|
|
19
|
+
* A 2xx whose body isn't a JSON-RPC response (an HTML fallback page, say)
|
|
20
|
+
* becomes an `internal_error` naming the status and content type.
|
|
14
21
|
*/
|
|
15
22
|
export declare class FrontendHttpTransport implements Transport {
|
|
16
23
|
#private;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"transports_http.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/actions/transports_http.ts"],"names":[],"mappings":"AAAA;;;;GAIG;
|
|
1
|
+
{"version":3,"file":"transports_http.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/actions/transports_http.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAiBH,OAAO,EACN,oBAAoB,EAGpB,KAAK,mBAAmB,EACxB,KAAK,cAAc,EAEnB,KAAK,sBAAsB,EAC3B,MAAM,oBAAoB,CAAC;AAC5B,OAAO,KAAK,EAAE,SAAS,EAAE,oBAAoB,EAAE,MAAM,iBAAiB,CAAC;AAEvE;;;;;;;;;;;;;GAaG;AACH,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;IA+FvC,QAAQ,IAAI,OAAO;CAGnB"}
|
|
@@ -4,14 +4,22 @@
|
|
|
4
4
|
* @module
|
|
5
5
|
*/
|
|
6
6
|
import { DEV } from 'esm-env';
|
|
7
|
-
import { ThrownJsonrpcError, jsonrpc_error_messages, http_status_to_jsonrpc_error_code, UNKNOWN_ERROR_MESSAGE } from '../http/jsonrpc_errors.js';
|
|
8
|
-
import { create_jsonrpc_error_response, to_jsonrpc_message_id, is_jsonrpc_error_response } from '../http/jsonrpc_helpers.js';
|
|
7
|
+
import { ThrownJsonrpcError, jsonrpc_error_messages, http_status_to_jsonrpc_error_code, jsonrpc_error_code_to_http_status, UNKNOWN_ERROR_MESSAGE } from '../http/jsonrpc_errors.js';
|
|
8
|
+
import { create_jsonrpc_error_response, to_jsonrpc_message_id, is_jsonrpc_error_response, is_jsonrpc_response } from '../http/jsonrpc_helpers.js';
|
|
9
|
+
import { JsonrpcErrorResponse } from '../http/jsonrpc.js';
|
|
9
10
|
/**
|
|
10
11
|
* Thin `fetch` adapter for the JSON-RPC endpoint. POST by default; GET when
|
|
11
12
|
* the optional `has_side_effects(method)` callback returns `false` for the
|
|
12
|
-
* method (matches `create_rpc_endpoint`'s GET convention).
|
|
13
|
-
*
|
|
14
|
-
*
|
|
13
|
+
* method (matches `create_rpc_endpoint`'s GET convention). Always reports ready.
|
|
14
|
+
*
|
|
15
|
+
* On non-OK HTTP responses, a body that is a JSON-RPC error response for this
|
|
16
|
+
* request (same `id`, or `null` when the server couldn't read one) is returned
|
|
17
|
+
* as-is, so the server's `code`, `message`, and `data` reach the caller — the
|
|
18
|
+
* status → code map is lossy (`queue_overflow` and `rate_limited` share 429).
|
|
19
|
+
* Any other body (HTML from a proxy, empty, malformed) gets a synthesized
|
|
20
|
+
* envelope whose code comes from `http_status_to_jsonrpc_error_code`.
|
|
21
|
+
* A 2xx whose body isn't a JSON-RPC response (an HTML fallback page, say)
|
|
22
|
+
* becomes an `internal_error` naming the status and content type.
|
|
15
23
|
*/
|
|
16
24
|
export class FrontendHttpTransport {
|
|
17
25
|
transport_name = 'frontend_http_rpc';
|
|
@@ -50,25 +58,38 @@ export class FrontendHttpTransport {
|
|
|
50
58
|
signal
|
|
51
59
|
});
|
|
52
60
|
}
|
|
53
|
-
const result = await response.json();
|
|
54
|
-
// For JSON-RPC, we always expect a 200 OK response.
|
|
55
|
-
// The actual error will be in the JSON-RPC error field.
|
|
56
61
|
if (!response.ok) {
|
|
57
|
-
|
|
62
|
+
const id = to_jsonrpc_message_id(message);
|
|
63
|
+
const body = to_error_response_for(await read_json_body(response), id);
|
|
64
|
+
if (body) {
|
|
65
|
+
// The code → status map is a function (the status → code direction is
|
|
66
|
+
// lossy), so drift is checked in that direction.
|
|
67
|
+
if (DEV) {
|
|
68
|
+
const expected_status = jsonrpc_error_code_to_http_status(body.error.code);
|
|
69
|
+
if (expected_status !== response.status) {
|
|
70
|
+
console.warn(`[http_transport] JSON-RPC error code ${body.error.code} maps to HTTP ${expected_status} but the response status is ${response.status}`, body);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return body;
|
|
74
|
+
}
|
|
75
|
+
return create_jsonrpc_error_response(id, {
|
|
58
76
|
code: http_status_to_jsonrpc_error_code(response.status),
|
|
59
|
-
message: `HTTP error: ${response.status} ${response.statusText}
|
|
77
|
+
message: `HTTP error: ${response.status} ${response.statusText}`.trimEnd()
|
|
60
78
|
});
|
|
61
79
|
}
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
if (actual_code !== expected_code) {
|
|
68
|
-
console.warn(`[http_transport] JSON-RPC error code mismatch: got ${actual_code} but ${response.status} should map to ${expected_code}`, result);
|
|
80
|
+
const body = await read_json_body(response);
|
|
81
|
+
if (is_jsonrpc_error_response(body)) {
|
|
82
|
+
// JSON-RPC errors should carry a non-2xx status (see `jsonrpc_error_code_to_http_status`).
|
|
83
|
+
if (DEV) {
|
|
84
|
+
console.warn(`[http_transport] JSON-RPC error response with HTTP ${response.status}`, body);
|
|
69
85
|
}
|
|
86
|
+
return body;
|
|
70
87
|
}
|
|
71
|
-
|
|
88
|
+
if (is_jsonrpc_response(body))
|
|
89
|
+
return body;
|
|
90
|
+
// a 2xx that isn't JSON-RPC — e.g. an HTML page from a static
|
|
91
|
+
// fallback or proxy answering the RPC path
|
|
92
|
+
return create_jsonrpc_error_response(to_jsonrpc_message_id(message), jsonrpc_error_messages.internal_error(`response is not JSON-RPC: HTTP ${response.status} ${response.headers.get('content-type') ?? 'without a content type'}`));
|
|
72
93
|
}
|
|
73
94
|
catch (error) {
|
|
74
95
|
if (error instanceof ThrownJsonrpcError) {
|
|
@@ -87,3 +108,27 @@ export class FrontendHttpTransport {
|
|
|
87
108
|
return true;
|
|
88
109
|
}
|
|
89
110
|
}
|
|
111
|
+
/** Read a response body as JSON, or `undefined` when it's empty or not JSON. */
|
|
112
|
+
const read_json_body = async (response) => {
|
|
113
|
+
try {
|
|
114
|
+
return JSON.parse(await response.text());
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
return undefined;
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
/**
|
|
121
|
+
* `body` as a well-formed JSON-RPC error response answering the request with
|
|
122
|
+
* `id` — echoing it, or `null` when the server couldn't read one — else `null`.
|
|
123
|
+
* Ids compare as strings: the GET convention sends the id as a query param,
|
|
124
|
+
* so a numeric id comes back as its string form.
|
|
125
|
+
*/
|
|
126
|
+
const to_error_response_for = (body, id) => {
|
|
127
|
+
const parsed = JsonrpcErrorResponse.safeParse(body);
|
|
128
|
+
if (!parsed.success)
|
|
129
|
+
return null;
|
|
130
|
+
const response = parsed.data;
|
|
131
|
+
if (response.id !== null && String(response.id) !== String(id))
|
|
132
|
+
return null;
|
|
133
|
+
return response;
|
|
134
|
+
};
|
|
@@ -16,8 +16,8 @@
|
|
|
16
16
|
*
|
|
17
17
|
* @module
|
|
18
18
|
*/
|
|
19
|
-
import type { JsonrpcNotification, JsonrpcRequest, JsonrpcRequestId, JsonrpcResponseOrError, JsonrpcErrorResponse } from '../http/jsonrpc.js';
|
|
20
|
-
import type
|
|
19
|
+
import type { JsonrpcNotification, JsonrpcRequest, JsonrpcRequestId, JsonrpcResponseOrError, JsonrpcErrorObject, JsonrpcErrorResponse } from '../http/jsonrpc.js';
|
|
20
|
+
import { type Transport, type TransportSendOptions } from './transports.js';
|
|
21
21
|
/**
|
|
22
22
|
* Minimal interface for a WebSocket connection, decoupled from the concrete Socket Cell.
|
|
23
23
|
*/
|
|
@@ -46,6 +46,28 @@ export interface WebsocketRpcConnection extends WebsocketConnection {
|
|
|
46
46
|
id?: JsonrpcRequestId;
|
|
47
47
|
}) => Promise<unknown>;
|
|
48
48
|
}
|
|
49
|
+
/** Options for `FrontendWebsocketTransport`. */
|
|
50
|
+
export interface FrontendWebsocketTransportOptions {
|
|
51
|
+
/**
|
|
52
|
+
* Largest outbound message, in UTF-8 bytes of its JSON encoding. A larger
|
|
53
|
+
* request or notification is refused with an `invalid_request` error
|
|
54
|
+
* (`data.reason` `payload_too_large`) without being sent — the server
|
|
55
|
+
* closes the socket on an oversized one (failing every request in flight
|
|
56
|
+
* on it) rather than replying with an error. Defaults to
|
|
57
|
+
* `DEFAULT_WS_MAX_MESSAGE_BYTES`, the server's default cap; a consumer
|
|
58
|
+
* that raises the server's `max_message_bytes` passes the same value
|
|
59
|
+
* here. `null` skips the measurement.
|
|
60
|
+
*
|
|
61
|
+
* @default DEFAULT_WS_MAX_MESSAGE_BYTES
|
|
62
|
+
*/
|
|
63
|
+
max_message_bytes?: number | null;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* The error `FrontendWebsocketTransport` answers an oversized message with —
|
|
67
|
+
* `invalid_request` (the whole message is refused) carrying
|
|
68
|
+
* `data.reason: 'payload_too_large'`, the REST 413's reason.
|
|
69
|
+
*/
|
|
70
|
+
export declare const create_message_too_large_error: (size: number, max_message_bytes: number) => JsonrpcErrorObject;
|
|
49
71
|
/**
|
|
50
72
|
* Thin adapter over `WebsocketRpcConnection` (canonical implementation:
|
|
51
73
|
* `FrontendWebsocketClient`). Routes inbound server-pushed requests and
|
|
@@ -53,11 +75,14 @@ export interface WebsocketRpcConnection extends WebsocketConnection {
|
|
|
53
75
|
* response back over the socket; an inbound `peer/ping` is answered by the
|
|
54
76
|
* built-in responder before `receive`. Responses to requests *we* sent are
|
|
55
77
|
* owned by the connection's own `request()` pending map and are ignored here.
|
|
78
|
+
* An oversized outbound message (`max_message_bytes`) fails with an error
|
|
79
|
+
* envelope instead of being sent (see
|
|
80
|
+
* `FrontendWebsocketTransportOptions`).
|
|
56
81
|
*/
|
|
57
82
|
export declare class FrontendWebsocketTransport implements Transport {
|
|
58
83
|
#private;
|
|
59
84
|
readonly transport_name: "frontend_websocket_rpc";
|
|
60
|
-
constructor(connection: WebsocketRpcConnection, receive: (data: unknown) => Promise<unknown
|
|
85
|
+
constructor(connection: WebsocketRpcConnection, receive: (data: unknown) => Promise<unknown>, options?: FrontendWebsocketTransportOptions);
|
|
61
86
|
send(message: JsonrpcRequest, options?: TransportSendOptions): Promise<JsonrpcResponseOrError>;
|
|
62
87
|
send(message: JsonrpcNotification, options?: TransportSendOptions): Promise<JsonrpcErrorResponse | null>;
|
|
63
88
|
is_ready(): boolean;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"transports_ws.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/actions/transports_ws.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;
|
|
1
|
+
{"version":3,"file":"transports_ws.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/actions/transports_ws.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAiBH,OAAO,KAAK,EAGX,mBAAmB,EACnB,cAAc,EACd,gBAAgB,EAChB,sBAAsB,EACtB,kBAAkB,EAClB,oBAAoB,EAEpB,MAAM,oBAAoB,CAAC;AAC5B,OAAO,EAGN,KAAK,SAAS,EACd,KAAK,oBAAoB,EACzB,MAAM,iBAAiB,CAAC;AAKzB;;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;QAAE,MAAM,CAAC,EAAE,WAAW,CAAC;QAAC,KAAK,CAAC,EAAE,OAAO,CAAC;QAAC,EAAE,CAAC,EAAE,gBAAgB,CAAA;KAAE,KACtE,OAAO,CAAC,OAAO,CAAC,CAAC;CACtB;AAED,gDAAgD;AAChD,MAAM,WAAW,iCAAiC;IACjD;;;;;;;;;;;OAWG;IACH,iBAAiB,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CAClC;AAED;;;;GAIG;AACH,eAAO,MAAM,8BAA8B,GAC1C,MAAM,MAAM,EACZ,mBAAmB,MAAM,KACvB,kBAID,CAAC;AAEH;;;;;;;;;;GAUG;AACH,qBAAa,0BAA2B,YAAW,SAAS;;IAC3D,QAAQ,CAAC,cAAc,EAAG,wBAAwB,CAAU;gBAS3D,UAAU,EAAE,sBAAsB,EAClC,OAAO,EAAE,CAAC,IAAI,EAAE,OAAO,KAAK,OAAO,CAAC,OAAO,CAAC,EAC5C,OAAO,CAAC,EAAE,iCAAiC;IAsDtC,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;IAuEvC,QAAQ,IAAI,OAAO;IAInB;;;;;;OAMG;IACH,OAAO,IAAI,IAAI;CAUf"}
|
|
@@ -17,9 +17,21 @@
|
|
|
17
17
|
* @module
|
|
18
18
|
*/
|
|
19
19
|
import { to_error_message } from '@fuzdev/fuz_util/error.ts';
|
|
20
|
-
import { ThrownJsonrpcError, jsonrpc_error_messages } from '../http/jsonrpc_errors.js';
|
|
21
|
-
import {
|
|
20
|
+
import { JSONRPC_ERROR_CODES, ThrownJsonrpcError, jsonrpc_error_messages } from '../http/jsonrpc_errors.js';
|
|
21
|
+
import { ERROR_PAYLOAD_TOO_LARGE } from '../http/error_schemas.js';
|
|
22
|
+
import { is_jsonrpc_notification, is_jsonrpc_request, to_jsonrpc_message_id, create_jsonrpc_response, create_jsonrpc_error_response } from '../http/jsonrpc_helpers.js';
|
|
23
|
+
import { DEFAULT_WS_MAX_MESSAGE_BYTES, utf8_length_over } from './transports.js';
|
|
22
24
|
import { PEER_PING_METHOD, peer_ping_responder } from './peer_ping.js';
|
|
25
|
+
/**
|
|
26
|
+
* The error `FrontendWebsocketTransport` answers an oversized message with —
|
|
27
|
+
* `invalid_request` (the whole message is refused) carrying
|
|
28
|
+
* `data.reason: 'payload_too_large'`, the REST 413's reason.
|
|
29
|
+
*/
|
|
30
|
+
export const create_message_too_large_error = (size, max_message_bytes) => ({
|
|
31
|
+
code: JSONRPC_ERROR_CODES.invalid_request,
|
|
32
|
+
message: `message too large: ${size} bytes exceeds the ${max_message_bytes}-byte limit`,
|
|
33
|
+
data: { reason: ERROR_PAYLOAD_TOO_LARGE }
|
|
34
|
+
});
|
|
23
35
|
/**
|
|
24
36
|
* Thin adapter over `WebsocketRpcConnection` (canonical implementation:
|
|
25
37
|
* `FrontendWebsocketClient`). Routes inbound server-pushed requests and
|
|
@@ -27,6 +39,9 @@ import { PEER_PING_METHOD, peer_ping_responder } from './peer_ping.js';
|
|
|
27
39
|
* response back over the socket; an inbound `peer/ping` is answered by the
|
|
28
40
|
* built-in responder before `receive`. Responses to requests *we* sent are
|
|
29
41
|
* owned by the connection's own `request()` pending map and are ignored here.
|
|
42
|
+
* An oversized outbound message (`max_message_bytes`) fails with an error
|
|
43
|
+
* envelope instead of being sent (see
|
|
44
|
+
* `FrontendWebsocketTransportOptions`).
|
|
30
45
|
*/
|
|
31
46
|
export class FrontendWebsocketTransport {
|
|
32
47
|
transport_name = 'frontend_websocket_rpc';
|
|
@@ -34,9 +49,13 @@ export class FrontendWebsocketTransport {
|
|
|
34
49
|
#receive;
|
|
35
50
|
#remove_message_handler;
|
|
36
51
|
#remove_error_handler;
|
|
37
|
-
|
|
52
|
+
#max_message_bytes;
|
|
53
|
+
constructor(connection, receive, options) {
|
|
38
54
|
this.#connection = connection;
|
|
39
55
|
this.#receive = receive;
|
|
56
|
+
const max_message_bytes = options?.max_message_bytes;
|
|
57
|
+
this.#max_message_bytes =
|
|
58
|
+
max_message_bytes === undefined ? DEFAULT_WS_MAX_MESSAGE_BYTES : max_message_bytes;
|
|
40
59
|
// Inbound dispatch — only server-pushed requests/notifications need
|
|
41
60
|
// routing here. Responses to requests we sent are correlated by the
|
|
42
61
|
// connection's own `request()` pending map.
|
|
@@ -91,6 +110,15 @@ export class FrontendWebsocketTransport {
|
|
|
91
110
|
// error frames), and the catch block below preserves that code
|
|
92
111
|
// verbatim in the error envelope. Queuing is routed via `queue`.
|
|
93
112
|
const queue = options?.queue ?? false;
|
|
113
|
+
if (this.#max_message_bytes !== null) {
|
|
114
|
+
// TODO measure where the frame is serialized (the connection's `send`)
|
|
115
|
+
// so a sent message isn't stringified twice — needs the size check on
|
|
116
|
+
// the `WebsocketRpcConnection` contract
|
|
117
|
+
const size = utf8_length_over(JSON.stringify(message), this.#max_message_bytes);
|
|
118
|
+
if (size !== null) {
|
|
119
|
+
return create_jsonrpc_error_response(to_jsonrpc_message_id(message), create_message_too_large_error(size, this.#max_message_bytes));
|
|
120
|
+
}
|
|
121
|
+
}
|
|
94
122
|
if (is_jsonrpc_notification(message) && !this.is_ready()) {
|
|
95
123
|
return create_jsonrpc_error_response(to_jsonrpc_message_id(message), jsonrpc_error_messages.service_unavailable('WebSocket not connected'));
|
|
96
124
|
}
|
|
@@ -101,7 +129,8 @@ export class FrontendWebsocketTransport {
|
|
|
101
129
|
signal: options?.signal,
|
|
102
130
|
queue
|
|
103
131
|
});
|
|
104
|
-
|
|
132
|
+
// any JSON value is a valid result — `null`, primitives, and arrays pass through
|
|
133
|
+
return create_jsonrpc_response(message.id, result);
|
|
105
134
|
}
|
|
106
135
|
catch (error) {
|
|
107
136
|
if (error instanceof ThrownJsonrpcError) {
|
|
@@ -60,6 +60,13 @@ export interface WsEndpointSpec {
|
|
|
60
60
|
heartbeat?: boolean | ServerHeartbeatOptions;
|
|
61
61
|
/** Optional per-message delay for testing loading states. */
|
|
62
62
|
artificial_delay?: number;
|
|
63
|
+
/**
|
|
64
|
+
* Cap on one inbound message, in bytes. See
|
|
65
|
+
* `RegisterActionWsOptions.max_message_bytes`.
|
|
66
|
+
*
|
|
67
|
+
* @default DEFAULT_WS_MAX_MESSAGE_BYTES
|
|
68
|
+
*/
|
|
69
|
+
max_message_bytes?: number;
|
|
63
70
|
/**
|
|
64
71
|
* Called once per socket after `transport.add_connection` but before
|
|
65
72
|
* the first message dispatches. See
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ws_endpoint_spec.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/actions/ws_endpoint_spec.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,mBAAmB,CAAC;AAChD,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,wBAAwB,CAAC;AACvD,OAAO,KAAK,EAAE,yBAAyB,EAAE,MAAM,4BAA4B,CAAC;AAC5E,OAAO,KAAK,EACX,sBAAsB,EACtB,kBAAkB,EAClB,iBAAiB,EACjB,MAAM,yBAAyB,CAAC;AACjC,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,+BAA+B,CAAC;AAEvE;;;;;;;;GAQG;AACH,MAAM,WAAW,cAAc;IAC9B,wCAAwC;IACxC,IAAI,EAAE,MAAM,CAAC;IACb;;;OAGG;IACH,eAAe,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC;IACvC;;;;;OAKG;IACH,OAAO,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC;IAC/B;;;;;OAKG;IACH,cAAc,CAAC,EAAE,aAAa,CAAC,QAAQ,CAAC,CAAC;IACzC;;;;OAIG;IACH,SAAS,CAAC,EAAE,yBAAyB,CAAC;IACtC;;;;OAIG;IACH,SAAS,CAAC,EAAE,OAAO,GAAG,sBAAsB,CAAC;IAC7C,6DAA6D;IAC7D,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B;;;;OAIG;IACH,cAAc,CAAC,EAAE,CAAC,GAAG,EAAE,iBAAiB,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAClE;;;OAGG;IACH,eAAe,CAAC,EAAE,CAAC,GAAG,EAAE,kBAAkB,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACpE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;OAgCG;IACH,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB;;;;;;;;OAQG;IACH,oBAAoB,CAAC,EAAE,aAAa,CAAC,iBAAiB,CAAC,CAAC;CACxD"}
|
|
1
|
+
{"version":3,"file":"ws_endpoint_spec.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/actions/ws_endpoint_spec.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,mBAAmB,CAAC;AAChD,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,wBAAwB,CAAC;AACvD,OAAO,KAAK,EAAE,yBAAyB,EAAE,MAAM,4BAA4B,CAAC;AAC5E,OAAO,KAAK,EACX,sBAAsB,EACtB,kBAAkB,EAClB,iBAAiB,EACjB,MAAM,yBAAyB,CAAC;AACjC,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,+BAA+B,CAAC;AAEvE;;;;;;;;GAQG;AACH,MAAM,WAAW,cAAc;IAC9B,wCAAwC;IACxC,IAAI,EAAE,MAAM,CAAC;IACb;;;OAGG;IACH,eAAe,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC;IACvC;;;;;OAKG;IACH,OAAO,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC;IAC/B;;;;;OAKG;IACH,cAAc,CAAC,EAAE,aAAa,CAAC,QAAQ,CAAC,CAAC;IACzC;;;;OAIG;IACH,SAAS,CAAC,EAAE,yBAAyB,CAAC;IACtC;;;;OAIG;IACH,SAAS,CAAC,EAAE,OAAO,GAAG,sBAAsB,CAAC;IAC7C,6DAA6D;IAC7D,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B;;;;;OAKG;IACH,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B;;;;OAIG;IACH,cAAc,CAAC,EAAE,CAAC,GAAG,EAAE,iBAAiB,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAClE;;;OAGG;IACH,eAAe,CAAC,EAAE,CAAC,GAAG,EAAE,kBAAkB,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACpE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;OAgCG;IACH,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB;;;;;;;;OAQG;IACH,oBAAoB,CAAC,EAAE,aAAa,CAAC,iBAAiB,CAAC,CAAC;CACxD"}
|