@fuzdev/fuz_app 0.113.0 → 0.115.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. package/dist/actions/CLAUDE.md +46 -20
  2. package/dist/actions/action_dispatcher.d.ts +1 -1
  3. package/dist/actions/action_dispatcher.d.ts.map +1 -1
  4. package/dist/actions/action_dispatcher.js +22 -16
  5. package/dist/actions/action_event.d.ts +18 -2
  6. package/dist/actions/action_event.d.ts.map +1 -1
  7. package/dist/actions/action_event.js +63 -19
  8. package/dist/actions/action_event_types.d.ts +22 -0
  9. package/dist/actions/action_event_types.d.ts.map +1 -1
  10. package/dist/actions/action_event_types.js +19 -0
  11. package/dist/actions/compile_action_registry.d.ts +4 -2
  12. package/dist/actions/compile_action_registry.d.ts.map +1 -1
  13. package/dist/actions/compile_action_registry.js +8 -3
  14. package/dist/actions/frontend_rpc_client.d.ts +6 -0
  15. package/dist/actions/frontend_rpc_client.d.ts.map +1 -1
  16. package/dist/actions/frontend_rpc_client.js +2 -1
  17. package/dist/actions/heartbeat.d.ts +8 -4
  18. package/dist/actions/heartbeat.d.ts.map +1 -1
  19. package/dist/actions/heartbeat.js +8 -4
  20. package/dist/actions/peer_ping.d.ts.map +1 -1
  21. package/dist/actions/peer_ping.js +3 -1
  22. package/dist/actions/perform_action.d.ts +1 -1
  23. package/dist/actions/perform_action.d.ts.map +1 -1
  24. package/dist/actions/perform_action.js +6 -4
  25. package/dist/actions/register_action_ws.d.ts +13 -0
  26. package/dist/actions/register_action_ws.d.ts.map +1 -1
  27. package/dist/actions/register_action_ws.js +23 -2
  28. package/dist/actions/rpc_client.d.ts +1 -1
  29. package/dist/actions/rpc_client.d.ts.map +1 -1
  30. package/dist/actions/rpc_client.js +2 -1
  31. package/dist/actions/socket.svelte.d.ts +53 -10
  32. package/dist/actions/socket.svelte.d.ts.map +1 -1
  33. package/dist/actions/socket.svelte.js +84 -23
  34. package/dist/actions/transports.d.ts +24 -0
  35. package/dist/actions/transports.d.ts.map +1 -1
  36. package/dist/actions/transports.js +30 -0
  37. package/dist/actions/transports_http.d.ts +11 -4
  38. package/dist/actions/transports_http.d.ts.map +1 -1
  39. package/dist/actions/transports_http.js +63 -18
  40. package/dist/actions/transports_ws.d.ts +28 -3
  41. package/dist/actions/transports_ws.d.ts.map +1 -1
  42. package/dist/actions/transports_ws.js +33 -4
  43. package/dist/actions/ws_endpoint_spec.d.ts +7 -0
  44. package/dist/actions/ws_endpoint_spec.d.ts.map +1 -1
  45. package/dist/auth/CLAUDE.md +52 -8
  46. package/dist/auth/admin_actions.d.ts.map +1 -1
  47. package/dist/auth/admin_actions.js +4 -3
  48. package/dist/auth/audit_log_schema.d.ts +3 -1
  49. package/dist/auth/audit_log_schema.d.ts.map +1 -1
  50. package/dist/auth/audit_log_schema.js +13 -3
  51. package/dist/auth/cell_action_specs.js +1 -1
  52. package/dist/auth/cell_actions.d.ts.map +1 -1
  53. package/dist/auth/cell_actions.js +33 -4
  54. package/dist/auth/cell_authorize.d.ts +3 -0
  55. package/dist/auth/cell_authorize.d.ts.map +1 -1
  56. package/dist/auth/cell_authorize.js +7 -4
  57. package/dist/auth/request_context.d.ts +12 -3
  58. package/dist/auth/request_context.d.ts.map +1 -1
  59. package/dist/auth/request_context.js +12 -3
  60. package/dist/auth/role_grant_offer_action_specs.d.ts +30 -5
  61. package/dist/auth/role_grant_offer_action_specs.d.ts.map +1 -1
  62. package/dist/auth/role_grant_offer_action_specs.js +53 -17
  63. package/dist/auth/role_grant_offer_actions.d.ts +23 -12
  64. package/dist/auth/role_grant_offer_actions.d.ts.map +1 -1
  65. package/dist/auth/role_grant_offer_actions.js +132 -38
  66. package/dist/auth/role_grant_offer_queries.d.ts +36 -0
  67. package/dist/auth/role_grant_offer_queries.d.ts.map +1 -1
  68. package/dist/auth/role_grant_offer_queries.js +76 -0
  69. package/dist/auth/role_schema.d.ts +12 -0
  70. package/dist/auth/role_schema.d.ts.map +1 -1
  71. package/dist/auth/role_schema.js +12 -0
  72. package/dist/auth/self_service_role_actions.d.ts +14 -0
  73. package/dist/auth/self_service_role_actions.d.ts.map +1 -1
  74. package/dist/auth/self_service_role_actions.js +25 -1
  75. package/dist/http/CLAUDE.md +7 -3
  76. package/dist/http/jsonrpc_helpers.d.ts +0 -5
  77. package/dist/http/jsonrpc_helpers.d.ts.map +1 -1
  78. package/dist/http/jsonrpc_helpers.js +0 -13
  79. package/dist/http/schema_helpers.d.ts +56 -0
  80. package/dist/http/schema_helpers.d.ts.map +1 -1
  81. package/dist/http/schema_helpers.js +91 -0
  82. package/dist/server/app_server.d.ts.map +1 -1
  83. package/dist/server/app_server.js +1 -0
  84. package/dist/server/serve_fact_route.d.ts +3 -1
  85. package/dist/server/serve_fact_route.d.ts.map +1 -1
  86. package/dist/server/serve_fact_route.js +7 -4
  87. package/dist/testing/CLAUDE.md +29 -16
  88. package/dist/testing/cross_backend/cell_gated_create.d.ts.map +1 -1
  89. package/dist/testing/cross_backend/cell_gated_create.js +23 -1
  90. package/dist/testing/cross_backend/peer_ping_ws.d.ts.map +1 -1
  91. package/dist/testing/cross_backend/peer_ping_ws.js +18 -0
  92. package/dist/testing/cross_backend/role_grant_participation.d.ts.map +1 -1
  93. package/dist/testing/cross_backend/role_grant_participation.js +34 -0
  94. package/dist/testing/cross_backend/rust_spine_stub_backend_config.d.ts +12 -1
  95. package/dist/testing/cross_backend/rust_spine_stub_backend_config.d.ts.map +1 -1
  96. package/dist/testing/cross_backend/rust_spine_stub_backend_config.js +13 -1
  97. package/dist/testing/cross_backend/test_cell_gated_create_authorize.d.ts.map +1 -1
  98. package/dist/testing/cross_backend/test_cell_gated_create_authorize.js +9 -7
  99. package/dist/testing/cross_backend/ws_round_trip.d.ts +11 -3
  100. package/dist/testing/cross_backend/ws_round_trip.d.ts.map +1 -1
  101. package/dist/testing/cross_backend/ws_round_trip.js +74 -12
  102. package/dist/testing/entities.d.ts +6 -3
  103. package/dist/testing/entities.d.ts.map +1 -1
  104. package/dist/testing/entities.js +4 -1
  105. package/dist/testing/transports/ws_client.d.ts +6 -0
  106. package/dist/testing/transports/ws_client.d.ts.map +1 -1
  107. package/dist/testing/transports/ws_transport.d.ts.map +1 -1
  108. package/dist/testing/transports/ws_transport.js +6 -1
  109. package/dist/testing/ws_round_trip.d.ts.map +1 -1
  110. package/dist/testing/ws_round_trip.js +5 -0
  111. package/dist/ui/BootstrapForm.svelte +14 -10
  112. package/dist/ui/BootstrapForm.svelte.d.ts.map +1 -1
  113. package/dist/ui/CLAUDE.md +19 -7
  114. package/dist/ui/LoginForm.svelte +8 -4
  115. package/dist/ui/LoginForm.svelte.d.ts.map +1 -1
  116. package/dist/ui/RoleGrantOfferForm.svelte +29 -14
  117. package/dist/ui/RoleGrantOfferForm.svelte.d.ts +2 -3
  118. package/dist/ui/RoleGrantOfferForm.svelte.d.ts.map +1 -1
  119. package/dist/ui/SignupForm.svelte +14 -13
  120. package/dist/ui/form_state.svelte.d.ts +24 -11
  121. package/dist/ui/form_state.svelte.d.ts.map +1 -1
  122. package/dist/ui/form_state.svelte.js +38 -10
  123. package/dist/ui/role_grant_offers_state.svelte.d.ts +25 -15
  124. package/dist/ui/role_grant_offers_state.svelte.d.ts.map +1 -1
  125. package/dist/ui/role_grant_offers_state.svelte.js +7 -1
  126. package/package.json +2 -22
  127. package/dist/actions/request_tracker.svelte.d.ts +0 -77
  128. package/dist/actions/request_tracker.svelte.d.ts.map +0 -1
  129. 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** — idles fire a shared `heartbeat` request;
20
- * receive-silence past `DEFAULT_HEARTBEAT_RECEIVE_TIMEOUT` closes
21
- * with `WS_CLOSE_CLIENT_HEARTBEAT_TIMEOUT` and lets auto-reconnect
22
- * pick back up.
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
- /** Max receive silence before closing with `WS_CLOSE_CLIENT_HEARTBEAT_TIMEOUT` (ms). */
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
- /** Epoch ms of the last outgoing send — used by the heartbeat activity check. */
74
- #last_send_time = null;
75
- /** Epoch ms of the last incoming message — used by the heartbeat activity check. */
76
- #last_receive_time = null;
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 ?? DEFAULT_HEARTBEAT_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
- this.#last_send_time = Date.now();
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`, `#last_send_time`, `last_send_error`
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 = {}, options = {}) {
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
- const frame = { jsonrpc: JSONRPC_VERSION, id, method, params };
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.#last_send_time = now;
524
- this.#last_receive_time = now;
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.#last_receive_time ?? now;
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
- const last_activity = Math.max(this.#last_send_time ?? 0, last_receive);
553
- if (now - last_activity >= this.#heartbeat_interval) {
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
- void this.request(heartbeat_action_spec.method, {}, { queue: false }).catch((error) => {
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
- this.#last_receive_time = Date.now();
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;AAKtD,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"}
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 { JsonrpcNotification, JsonrpcRequest, JsonrpcResponseOrError, JsonrpcErrorResponse } from '../http/jsonrpc.js';
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). On non-OK HTTP
12
- * responses, synthesizes a JSON-RPC error envelope via
13
- * `http_status_to_jsonrpc_error_code`. Always reports ready.
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;AAeH,OAAO,KAAK,EAGX,mBAAmB,EACnB,cAAc,EACd,sBAAsB,EACtB,oBAAoB,EACpB,MAAM,oBAAoB,CAAC;AAC5B,OAAO,KAAK,EAAE,SAAS,EAAE,oBAAoB,EAAE,MAAM,iBAAiB,CAAC;AAEvE;;;;;;GAMG;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;IA2EvC,QAAQ,IAAI,OAAO;CAGnB"}
1
+ {"version":3,"file":"transports_http.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/actions/transports_http.ts"],"names":[],"mappings":"AAAA;;;;GAIG;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). On non-OK HTTP
13
- * responses, synthesizes a JSON-RPC error envelope via
14
- * `http_status_to_jsonrpc_error_code`. Always reports ready.
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
- return create_jsonrpc_error_response(to_jsonrpc_message_id(message), {
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
- // In development, check if we got a JSON-RPC error with HTTP 200
63
- // and verify the error code matches the expected HTTP status.
64
- if (DEV && is_jsonrpc_error_response(result)) {
65
- const expected_code = http_status_to_jsonrpc_error_code(response.status);
66
- const actual_code = result.error.code;
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
- return result;
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 { Transport, TransportSendOptions } from './transports.js';
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;AAaH,OAAO,KAAK,EAGX,mBAAmB,EACnB,cAAc,EACd,gBAAgB,EAChB,sBAAsB,EACtB,oBAAoB,EACpB,MAAM,oBAAoB,CAAC;AAC5B,OAAO,KAAK,EAAE,SAAS,EAAE,oBAAoB,EAAE,MAAM,iBAAiB,CAAC;AAKvE;;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;;;;;;;GAOG;AACH,qBAAa,0BAA2B,YAAW,SAAS;;IAC3D,QAAQ,CAAC,cAAc,EAAG,wBAAwB,CAAU;gBAOhD,UAAU,EAAE,sBAAsB,EAAE,OAAO,EAAE,CAAC,IAAI,EAAE,OAAO,KAAK,OAAO,CAAC,OAAO,CAAC;IAkDtF,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;IA0DvC,QAAQ,IAAI,OAAO;IAInB;;;;;;OAMG;IACH,OAAO,IAAI,IAAI;CAUf"}
1
+ {"version":3,"file":"transports_ws.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/actions/transports_ws.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;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 { is_jsonrpc_notification, is_jsonrpc_request, to_jsonrpc_message_id, to_jsonrpc_result, create_jsonrpc_response, create_jsonrpc_error_response } from '../http/jsonrpc_helpers.js';
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
- constructor(connection, receive) {
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
- return create_jsonrpc_response(message.id, to_jsonrpc_result(result));
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"}