@fuzdev/fuz_app 0.133.0 → 0.135.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 (112) hide show
  1. package/dist/actions/CLAUDE.md +91 -9
  2. package/dist/actions/register_action_ws.d.ts +23 -3
  3. package/dist/actions/register_action_ws.d.ts.map +1 -1
  4. package/dist/actions/register_action_ws.js +57 -37
  5. package/dist/actions/transports.d.ts +23 -0
  6. package/dist/actions/transports.d.ts.map +1 -1
  7. package/dist/actions/transports.js +23 -0
  8. package/dist/actions/transports_ws_backend.d.ts +132 -9
  9. package/dist/actions/transports_ws_backend.d.ts.map +1 -1
  10. package/dist/actions/transports_ws_backend.js +236 -33
  11. package/dist/actions/ws_endpoint_spec.d.ts +9 -0
  12. package/dist/actions/ws_endpoint_spec.d.ts.map +1 -1
  13. package/dist/actions/ws_outbound_writer.d.ts +206 -0
  14. package/dist/actions/ws_outbound_writer.d.ts.map +1 -0
  15. package/dist/actions/ws_outbound_writer.js +431 -0
  16. package/dist/auth/CLAUDE.md +2 -2
  17. package/dist/auth/audit_log_routes.d.ts.map +1 -1
  18. package/dist/auth/audit_log_routes.js +3 -1
  19. package/dist/auth/cell_actions.js +1 -1
  20. package/dist/auth/role_schema.d.ts +14 -3
  21. package/dist/auth/role_schema.d.ts.map +1 -1
  22. package/dist/auth/role_schema.js +14 -2
  23. package/dist/db/CLAUDE.md +10 -7
  24. package/dist/db/create_db.d.ts +8 -6
  25. package/dist/db/create_db.d.ts.map +1 -1
  26. package/dist/db/create_db.js +11 -10
  27. package/dist/db/db.d.ts +9 -7
  28. package/dist/db/db.d.ts.map +1 -1
  29. package/dist/db/db.js +5 -4
  30. package/dist/db/db_pglet.d.ts +43 -0
  31. package/dist/db/db_pglet.d.ts.map +1 -0
  32. package/dist/db/db_pglet.js +55 -0
  33. package/dist/db/migrate.d.ts +1 -1
  34. package/dist/db/migrate.js +2 -2
  35. package/dist/http/CLAUDE.md +2 -1
  36. package/dist/http/body_limit.d.ts +61 -0
  37. package/dist/http/body_limit.d.ts.map +1 -0
  38. package/dist/http/body_limit.js +87 -0
  39. package/dist/http/db_routes.js +2 -2
  40. package/dist/http/error_schemas.d.ts +2 -0
  41. package/dist/http/error_schemas.d.ts.map +1 -1
  42. package/dist/http/error_schemas.js +2 -0
  43. package/dist/realtime/outbound_budget.d.ts +91 -0
  44. package/dist/realtime/outbound_budget.d.ts.map +1 -0
  45. package/dist/realtime/outbound_budget.js +108 -0
  46. package/dist/realtime/sse.d.ts +36 -3
  47. package/dist/realtime/sse.d.ts.map +1 -1
  48. package/dist/realtime/sse.js +58 -6
  49. package/dist/realtime/sse_constants.d.ts +12 -0
  50. package/dist/realtime/sse_constants.d.ts.map +1 -1
  51. package/dist/realtime/sse_constants.js +12 -0
  52. package/dist/server/app_server.d.ts.map +1 -1
  53. package/dist/server/app_server.js +3 -6
  54. package/dist/testing/CLAUDE.md +87 -28
  55. package/dist/testing/admin_integration.js +1 -1
  56. package/dist/testing/app_server.d.ts +5 -5
  57. package/dist/testing/app_server.d.ts.map +1 -1
  58. package/dist/testing/app_server.js +21 -20
  59. package/dist/testing/audit_completeness.d.ts +1 -1
  60. package/dist/testing/audit_completeness.js +1 -1
  61. package/dist/testing/audit_drift_guard.d.ts +1 -1
  62. package/dist/testing/audit_drift_guard.js +1 -1
  63. package/dist/testing/cross_backend/body_size_smuggling.d.ts +9 -9
  64. package/dist/testing/cross_backend/body_size_smuggling.d.ts.map +1 -1
  65. package/dist/testing/cross_backend/body_size_smuggling.js +110 -60
  66. package/dist/testing/cross_backend/capabilities.d.ts +18 -15
  67. package/dist/testing/cross_backend/capabilities.d.ts.map +1 -1
  68. package/dist/testing/cross_backend/capabilities.js +1 -0
  69. package/dist/testing/cross_backend/default_backend_configs.d.ts +5 -5
  70. package/dist/testing/cross_backend/default_backend_configs.d.ts.map +1 -1
  71. package/dist/testing/cross_backend/default_backend_configs.js +6 -6
  72. package/dist/testing/cross_backend/in_process_setup.d.ts +2 -2
  73. package/dist/testing/cross_backend/in_process_setup.js +3 -3
  74. package/dist/testing/cross_backend/role_grant_role_grammar.d.ts +6 -0
  75. package/dist/testing/cross_backend/role_grant_role_grammar.d.ts.map +1 -0
  76. package/dist/testing/cross_backend/role_grant_role_grammar.js +117 -0
  77. package/dist/testing/cross_backend/rust_spine_stub_backend_config.d.ts +2 -3
  78. package/dist/testing/cross_backend/rust_spine_stub_backend_config.d.ts.map +1 -1
  79. package/dist/testing/cross_backend/rust_spine_stub_backend_config.js +2 -3
  80. package/dist/testing/cross_backend/test_cell_gated_create_authorize.js +1 -1
  81. package/dist/testing/cross_backend/testing_backdoor.d.ts.map +1 -1
  82. package/dist/testing/cross_backend/testing_backdoor.js +8 -4
  83. package/dist/testing/cross_backend/testing_reset_actions.d.ts +84 -4
  84. package/dist/testing/cross_backend/testing_reset_actions.d.ts.map +1 -1
  85. package/dist/testing/cross_backend/testing_reset_actions.js +102 -3
  86. package/dist/testing/cross_backend/ts_spine_backend_config.d.ts +2 -2
  87. package/dist/testing/cross_backend/ts_spine_backend_config.d.ts.map +1 -1
  88. package/dist/testing/cross_backend/ts_spine_backend_config.js +9 -7
  89. package/dist/testing/cross_backend/ws_outbound_overflow.d.ts +29 -0
  90. package/dist/testing/cross_backend/ws_outbound_overflow.d.ts.map +1 -0
  91. package/dist/testing/cross_backend/ws_outbound_overflow.js +211 -0
  92. package/dist/testing/db.d.ts +49 -60
  93. package/dist/testing/db.d.ts.map +1 -1
  94. package/dist/testing/db.js +104 -101
  95. package/dist/testing/integration.js +1 -1
  96. package/dist/testing/rate_limiting.d.ts +2 -1
  97. package/dist/testing/rate_limiting.d.ts.map +1 -1
  98. package/dist/testing/rate_limiting.js +2 -2
  99. package/dist/testing/schema_introspect.d.ts +10 -2
  100. package/dist/testing/schema_introspect.d.ts.map +1 -1
  101. package/dist/testing/schema_introspect.js +15 -7
  102. package/dist/testing/sse_round_trip.d.ts +1 -1
  103. package/dist/testing/sse_round_trip.d.ts.map +1 -1
  104. package/dist/testing/sse_round_trip.js +2 -2
  105. package/dist/testing/stubs.js +1 -1
  106. package/dist/testing/transports/ws_transport.d.ts +4 -2
  107. package/dist/testing/transports/ws_transport.d.ts.map +1 -1
  108. package/dist/testing/transports/ws_transport.js +2 -1
  109. package/dist/testing/ws_round_trip.d.ts +32 -2
  110. package/dist/testing/ws_round_trip.d.ts.map +1 -1
  111. package/dist/testing/ws_round_trip.js +26 -4
  112. package/package.json +6 -1
@@ -362,6 +362,7 @@ and `allow_fallback: boolean` (default `true`). Explicit
362
362
  - `WS_CLOSE_CLIENT_HEARTBEAT_TIMEOUT = 4002` — client observed receive-silence past its receive timeout (`resolve_heartbeat_receive_timeout`).
363
363
  - `WS_CLOSE_SERVER_HEARTBEAT_TIMEOUT = 4003` — server observed receive-silence past `DEFAULT_SERVER_HEARTBEAT_TIMEOUT` (60s).
364
364
  - `WS_CLOSE_CONNECTION_LIMIT = 4004` — server closed this socket to admit a newer one on the same account, past the per-account connection cap (reason `connection limit`). Not a revocation: the client stays `closed` with `superseded` set and no reconnect, until `connect()` is called. Twin of the Rust spine's constant.
365
+ - `WS_CLOSE_OUTBOUND_OVERFLOW = 4005` — the server had more waiting to be written to this socket than its outbound budget holds (reason `WS_CLOSE_OUTBOUND_OVERFLOW_REASON`, `outbound queue overflow`); what was waiting is dropped. Not a revocation, and nothing took the connection's place: the client reconnects under its ordinary backoff. Twin of the Rust spine's constant.
365
366
  - `WS_CLOSE_GOING_AWAY = 1001` — the server is shutting down (reason `WS_CLOSE_GOING_AWAY_REASON`, `Server shutting down`): `close_all_sockets`, which `AppServer.close` runs. Also the close for an upgrade the shutdown refuses — pending at the close-all, or born closed after it. Not a revocation: the client reconnects under its ordinary backoff.
366
367
  - `WS_CLOSE_MESSAGE_TOO_BIG = 1009` — server received a message over its `max_message_bytes` cap (RFC 6455 "Message Too Big").
367
368
  - `WS_CLOSE_POLICY_VIOLATION = 1008` — a socket sent too much while it was still opening (before admission and `on_socket_open` completed): past `MAX_PRE_ADMISSION_FRAMES` frames or the `PRE_ADMISSION_QUEUE_BYTES_FACTOR` byte budget.
@@ -371,7 +372,8 @@ and `allow_fallback: boolean` (default `true`). Explicit
371
372
 
372
373
  - `actions/transports_http.ts` — `frontend_http_rpc`; thin `fetch` adapter, POST default, GET on `has_side_effects(method) === false`.
373
374
  - `actions/transports_ws.ts` — `frontend_websocket_rpc`; thin adapter over `WebsocketRpcConnection` (default impl: `FrontendWebsocketClient`). Answers inbound server→client requests: a built-in `peer_ping_responder` for `peer/ping` (zero-wiring liveness), any other request routed through `peer.receive` with the response sent back over the socket (the frontend half of the ActionPeer receive loop).
374
- - `actions/transports_ws_backend.ts` — `backend_websocket_rpc`; server-side WS with session tracking, two-phase registration (`register_pending` / `admit`), and the per-account cap; satisfies `FilterableBroadcastTransport`. Server→client requests via `request_connection` (correlation registry in `actions/peer_request.ts`).
375
+ - `actions/transports_ws_backend.ts` — `backend_websocket_rpc`; server-side WS with session tracking, two-phase registration (`register_pending` / `admit`), the per-account cap, and the per-connection outbound budget; satisfies `FilterableBroadcastTransport`. Server→client requests via `request_connection` (correlation registry in `actions/peer_request.ts`).
376
+ - `actions/ws_outbound_writer.ts` — `WsOutboundWriter`, the outbound queue and write pump of one connection, which every frame the transport sends goes through; `realtime/outbound_budget.ts` is the accounting it shares with SSE.
375
377
 
376
378
  `FrontendHttpTransport` on non-OK HTTP returns the body as-is when it's a
377
379
  JSON-RPC error response for the request (same `id` compared as strings since GET sends it as a query param, or `null`) — the
@@ -406,7 +408,7 @@ client; `null` skips the client-side measurement.
406
408
 
407
409
  One map keyed by `connection_id` (branded `Uuid`), in registration order:
408
410
  `#connections: Map<Uuid, ConnectionEntry>`, each entry `{ws, identity,
409
- admitted, abort_controller}`. `identity` is the `ConnectionIdentity`
411
+ admitted, abort_controller, writer}`. `identity` is the `ConnectionIdentity`
410
412
  `{token_hash, account_id, api_token_id}` (session sets `token_hash`, bearer
411
413
  sets `api_token_id`, daemon-token sets both null).
412
414
 
@@ -477,6 +479,74 @@ Rust `fuz_realtime` `ConnectionRegistry`'s cap — same default, same policy,
477
479
  same close code and reason — pinned on both by the
478
480
  `describe_ws_connection_cap_cross_tests` suite.
479
481
 
482
+ **Outbound bound.** Every frame the server sends a connection goes through
483
+ its `WsOutboundWriter` (`actions/ws_outbound_writer.ts`), reached by one path:
484
+ `send_to(connection_id, frame)` — which `register_action_ws` uses for every
485
+ response, error frame, and socket-scoped notification — and the fan-outs
486
+ (`send`, `broadcast_filtered`, `send_to_account`, `request_connection`). The
487
+ one way around it is a consumer sending on the raw `SocketOpenContext.ws`,
488
+ which bypasses the budget. A send never waits and never throws.
489
+
490
+ - **What waits.** A frame sent to an idle connection — nothing waiting,
491
+ nothing unwritten in the runtime's socket buffer, no other frame sent in the
492
+ same synchronous run — goes straight to the socket. Everything else waits in
493
+ the writer's queue: the rest of a burst sent in one synchronous run (a loop
494
+ with no `await` gives the pump no turn), and anything sent while the socket's
495
+ buffer is at or over `WS_WRITE_HIGH_WATER_BYTES`. The pump hands waiting
496
+ frames over in order, on the microtask after a send and as the buffer drains.
497
+ So a second send in one synchronous run reaches the socket a microtask later.
498
+ - **The budget.** What waits is held to `outbound_queue_bytes`
499
+ (`new BackendWebsocketTransport({outbound_queue_bytes})`, default
500
+ `DEFAULT_WS_OUTBOUND_QUEUE_BYTES`, 4 MiB — its own constant, not derived from
501
+ `max_message_bytes`; anything but a positive integer throws): each waiting
502
+ frame is charged its UTF-8 length plus `OUTBOUND_FRAME_OVERHEAD_BYTES` (64),
503
+ and a frame is always accepted when nothing is waiting. Nothing counts
504
+ frames (`OutboundBudget`, `realtime/outbound_budget.ts`).
505
+ - **Overflow.** A frame that does not fit closes the connection with
506
+ `WS_CLOSE_OUTBOUND_OVERFLOW`: the entry leaves the map in the same
507
+ synchronous step (the overflowing send returns `false` / is not counted,
508
+ later sends miss it, the count accessors and the cap stop seeing it), its
509
+ pending peer requests drain as `connection_gone`, its controller is aborted,
510
+ and what was waiting is discarded. Frames already handed to the socket may
511
+ still reach the client ahead of the close frame; nothing follows it.
512
+ - **Deadlines.** Bytes a socket still holds `WS_WRITE_STALL_TIMEOUT` (10 s)
513
+ after they were handed to it drop the connection with no close frame
514
+ (`terminate()` on Node's `ws` and Bun; the client sees 1006), removing it and
515
+ aborting its controller the same way. A close frame gets
516
+ `WS_CLOSE_WRITE_TIMEOUT` (1 s), after which a socket still holding unwritten
517
+ bytes is cut. Deno's standard `WebSocket` cannot be cut, so there a stalled
518
+ connection is closed with `WS_CLOSE_OUTBOUND_OVERFLOW` instead, the close
519
+ deadline cuts nothing, and the runtime keeps the socket until it or the
520
+ client ends it. The timers are unref'd and cleared when the socket's close
521
+ arrives (`remove_connection`, which is for a socket that has closed).
522
+ - **Other closes.** A revocation, a cap eviction, the shutdown, and
523
+ `close_connection` run the pump's rule once more: the frames still waiting
524
+ are handed to the socket ahead of the close frame while its buffer is below
525
+ `WS_WRITE_HIGH_WATER_BYTES` (64 KiB), and the rest are discarded. A reply
526
+ sent to an otherwise idle connection just before a self-revoking close still
527
+ arrives; one queued behind a backed-up socket does not.
528
+ - **After a close.** A closed or dropped connection is out of the map, so the
529
+ per-account cap does not count it while its socket is still closing. Where
530
+ the runtime reports its buffer, what it holds for that socket is less than
531
+ the high-water mark plus one frame, and the close frame — for the close deadline on Node and Bun, and on
532
+ Deno until the runtime or the client ends the socket.
533
+ - **Logging.** One `warn` per overflow (`ws: outbound queue overflow, closing
534
+ the connection`, the Rust spine's line) or stall (`ws: write stalled,
535
+ dropping the connection`), with `{connection_id, account_id, queued_bytes,
536
+ queued_frames, …}`. No audit row: a client can provoke either at will.
537
+
538
+ The twin of the Rust `fuz_realtime` outbound queue — same budget, charge,
539
+ rule, close code, reason, and deadlines, pinned on both by
540
+ `describe_ws_outbound_overflow_cross_tests`. Where they differ (the Rust spine
541
+ queues every frame and discards the whole backlog at every close) is listed in
542
+ ../../../docs/security.md §Outbound Bounds.
543
+
544
+ `close_connection(connection_id, code, reason)` closes one connection, pending
545
+ or admitted — removal, abort, then the close through the writer — and answers
546
+ `false` for an id that names nothing. `register_action_ws` makes its own closes
547
+ with it (heartbeat timeout, oversized message, failed `on_socket_open`, refused
548
+ admission).
549
+
480
550
  Targeted closure (all return socket count closed, use
481
551
  `WS_CLOSE_SESSION_REVOKED`, match pending and admitted entries alike, and abort
482
552
  each closed connection's controller):
@@ -525,7 +595,7 @@ no pending entry (unsolicited / cross-connection / late) is dropped + audited vi
525
595
  Rust `audit_unmatched_response`).
526
596
 
527
597
  Return values are bookkeeping, not delivery receipts — `0` means no live
528
- sockets, non-zero means `ws.send` did not throw. Durable delivery requires
598
+ sockets, non-zero means the connection's outbound queue accepted the frame. Durable delivery requires
529
599
  persistence + rehydration by the consumer.
530
600
 
531
601
  ## Connection closer (`actions/connection_closer.ts`)
@@ -678,7 +748,8 @@ parsed and every caller would be unauthenticated.
678
748
 
679
749
  `WsEndpointSpec` fields: `path`, `allowed_origins?` (default: the server's),
680
750
  `actions`,
681
- `required_roles?`, `transport?`, `max_connections_per_account?`, `heartbeat?`,
751
+ `required_roles?`, `transport?`, `max_connections_per_account?`,
752
+ `outbound_queue_bytes?`, `heartbeat?`,
682
753
  `artificial_delay?`, `max_message_bytes?`,
683
754
  `on_socket_open?`, `on_socket_close?`, `auth_guard?` (default `true`,
684
755
  deduped by reference identity via `WeakSet<BackendWebsocketTransport>`),
@@ -690,7 +761,10 @@ the audit listener, not of the handlers' closes.
690
761
  transport the mount creates (`RegisterActionWsOptions` carries the same field).
691
762
  It throws alongside `transport` — a supplied transport carries its own cap, set
692
763
  where it was constructed, and an option that silently did nothing would read as
693
- enforced.
764
+ enforced. `outbound_queue_bytes` sets the per-connection outbound budget on that
765
+ transport the same way (default `DEFAULT_WS_OUTBOUND_QUEUE_BYTES`; zero or a
766
+ non-positive value throws at assembly, as does passing it alongside
767
+ `transport`).
694
768
 
695
769
  Mounted transport reachable at `app_server.ws_endpoints[path]`
696
770
  (`Readonly<Record<string, BackendWebsocketTransport>>`). Duplicate paths
@@ -740,7 +814,8 @@ HTTP RPC also calls. `register_action_ws` owns only WS-specific concerns:
740
814
  - **Cancel-notification interception** — `{request_id → AbortController}` map; aborts the matching pending controller before the cancel bubbles past the dispatcher
741
815
  - **Duplicate live ids refused** — a request whose id names a request still running on the socket is answered `invalid_request` with `data.reason` `ERROR_DUPLICATE_REQUEST_ID` and not dispatched; the running request keeps its controller, its cancel entry, and its reply id. An id is free again once its request is answered. Same refusal on the Rust spine
742
816
  - **In-flight ceiling** — at most `MAX_INBOUND_DISPATCHES_PER_CONNECTION` (128) requests run at once per socket (`@hono/node-ws` does not await `onMessage`, so nothing else bounds them); one more is answered `queue_overflow` (`WS_INBOUND_DISPATCH_OVERFLOW_MESSAGE`) and not dispatched, and the socket stays open and keeps reading — a reply to a server-initiated request still gets through. The duplicate check runs first; neither refusal reaches `perform_action`, so neither charges the action rate limiters. Same cap, message, and order as the Rust spine's `MAX_INBOUND_DISPATCHES_PER_CONN`
743
- - **Socket-scoped notify** — `(method, params) => ws.send(notification)`, threaded into `perform_action` as `notify`
817
+ - **Socket-scoped notify** — `(method, params) => transport.send_to(connection_id, notification)`, threaded into `perform_action` as `notify`
818
+ - **Every send through the transport** — responses, error frames, and notifications all go through `transport.send_to`, so they are held to the connection's outbound budget; a handler that sends past it closes its own socket with `WS_CLOSE_OUTBOUND_OVERFLOW` and gets no response written
744
819
  - **Composed abort signal** — `AbortSignal.any([socket_close, per_request_cancel])`, threaded as `signal`
745
820
  - **Connection lifecycle** — admission (`transport.register_pending` → credential re-read → `transport.admit`, which enforces the per-account connection cap) / `remove_connection`, `on_socket_open` / `_close` hooks, server heartbeat
746
821
  - **The inbound gate** — frames queue until the socket is admitted and `on_socket_open` has completed; frames on a socket the server closed are dropped
@@ -805,7 +880,8 @@ completes — `@hono/node-ws` does, for up to the `ws` close timeout when the
805
880
  client withholds its close frame. So every server-side close removes the
806
881
  connection from the transport and aborts the socket's controller **at close
807
882
  time**: the transport does both for a revocation or a cap eviction, and
808
- `register_action_ws`'s `end_socket` does both for a heartbeat timeout, an
883
+ `register_action_ws`'s `end_socket` does both — through
884
+ `transport.close_connection` — for a heartbeat timeout, an
809
885
  oversized message, a failed `on_socket_open`, and a refused admission.
810
886
  `onMessage` then drops any frame whose socket has ended — no dispatch, no
811
887
  error reply. A connection the transport no longer holds though nothing closed
@@ -848,7 +924,11 @@ rolled-back message fires no post-commit effect). See `http/CLAUDE.md`
848
924
  handler that revokes the credential its own socket runs on queues a close of
849
925
  that socket on `post_commit_effects`, so the caller reads its reply and then the
850
926
  `WS_CLOSE_SESSION_REVOKED` close — flushing first would strand it without an
851
- answer. (The Rust spine does flush first, and drops the response: a divergence
927
+ answer. The close hands the frames still waiting in the connection's outbound
928
+ queue to the socket ahead of the close frame while the socket's buffer is below
929
+ `WS_WRITE_HIGH_WATER_BYTES`, so the reply arrives when it was queued behind
930
+ another frame of the same run on a socket that is keeping up, and is discarded
931
+ when the socket is backed up past that mark. (The Rust spine does flush first, and drops the response: a divergence
852
932
  the cross-backend WS suite records as `capabilities.ws_self_revocation_reply`.)
853
933
 
854
934
  **Lifecycle hooks.** `on_socket_open({ws, connection_id, identity, notify, signal})`
@@ -967,7 +1047,9 @@ recent frame each way — every frame, including heartbeats and the responses
967
1047
  `request` consumes — for diagnostics UI.
968
1048
 
969
1049
  Reconnect policy (exponential backoff): `delay = DEFAULT_RECONNECT_DELAY * DEFAULT_BACKOFF_FACTOR ** (attempts-1)`,
970
- capped at `DEFAULT_RECONNECT_DELAY_MAX`. Two close codes never reconnect:
1050
+ capped at `DEFAULT_RECONNECT_DELAY_MAX`. Every other close reconnects —
1051
+ `WS_CLOSE_OUTBOUND_OVERFLOW` included, the server's notice that this client
1052
+ fell behind and missed messages. Two close codes never reconnect:
971
1053
 
972
1054
  - `WS_CLOSE_SESSION_REVOKED` is **terminal** — sets `revoked`, no reconnect
973
1055
  loop on 401, and `connect()` becomes a no-op. Pending and queued requests
@@ -188,7 +188,11 @@ export declare const ERROR_DUPLICATE_REQUEST_ID = "duplicate_request_id";
188
188
  * snapshot.
189
189
  */
190
190
  export interface SocketOpenContext {
191
- /** The raw WebSocket context — exposed for edge cases; prefer `notify` for sends. */
191
+ /**
192
+ * The raw WebSocket context — exposed for edge cases; prefer `notify` for
193
+ * sends. A frame sent on it directly bypasses the connection's outbound
194
+ * budget and its ordering with queued frames.
195
+ */
192
196
  ws: WSContext;
193
197
  /** Connection id assigned by `BackendWebsocketTransport.register_pending`. */
194
198
  connection_id: Uuid;
@@ -301,6 +305,17 @@ export interface RegisterActionWsOptions {
301
305
  * @default DEFAULT_MAX_CONNECTIONS_PER_ACCOUNT
302
306
  */
303
307
  max_connections_per_account?: number | null;
308
+ /**
309
+ * Byte budget of the frames waiting to be written to one connection, for
310
+ * the transport this call creates — see
311
+ * `BackendWebsocketTransportOptions.outbound_queue_bytes` (a frame that does
312
+ * not fit closes the socket with `WS_CLOSE_OUTBOUND_OVERFLOW`). Not derived
313
+ * from `max_message_bytes`. Rejected alongside `transport`: a supplied
314
+ * transport carries its own budget, set where it was constructed.
315
+ *
316
+ * @default DEFAULT_WS_OUTBOUND_QUEUE_BYTES
317
+ */
318
+ outbound_queue_bytes?: number;
304
319
  /**
305
320
  * Server-side heartbeat policy. Default-on (receive-silence detection,
306
321
  * 60s timeout). `false` disables the timer entirely — only do this if
@@ -400,6 +415,10 @@ export interface RegisterActionWsResult {
400
415
  * `WS_CLOSE_INTERNAL_ERROR`. Frames sent before admission wait for it.
401
416
  * - A socket the server has closed dispatches nothing more, whether or not
402
417
  * its client answers the close.
418
+ * - Every frame sent — responses, error frames, notifications — goes through
419
+ * the transport's outbound queue. A connection with more waiting than its
420
+ * `outbound_queue_bytes` budget closes with `WS_CLOSE_OUTBOUND_OVERFLOW`, and
421
+ * the response of the request whose handler overflowed it is not sent.
403
422
  *
404
423
  * @returns the transport (supplied or freshly created) — retain it to wire
405
424
  * `create_ws_auth_guard` or broadcast on audit events.
@@ -408,8 +427,9 @@ export interface RegisterActionWsResult {
408
427
  * connection via `register_pending` / `admit` / `remove_connection`
409
428
  * @mutates options.connection_closer - adds the endpoint's transport, once the
410
429
  * mount has succeeded
411
- * @throws Error when `max_connections_per_account` is passed alongside
412
- * `transport`, or is neither `null` nor a positive integer, or when
430
+ * @throws Error when `max_connections_per_account` or `outbound_queue_bytes`
431
+ * is passed alongside `transport`, when the cap is neither `null` nor a
432
+ * positive integer, when the budget is not a positive integer, or when
413
433
  * `actions` fails to compile (`compile_action_registry`)
414
434
  */
415
435
  export declare const register_action_ws: (options: RegisterActionWsOptions) => RegisterActionWsResult;
@@ -1 +1 @@
1
- {"version":3,"file":"register_action_ws.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/actions/register_action_ws.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"register_action_ws.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/actions/register_action_ws.ts"],"names":[],"mappings":"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"}
@@ -185,9 +185,12 @@ export const WS_INBOUND_DISPATCH_OVERFLOW_MESSAGE = 'too many concurrent request
185
185
  * answered.
186
186
  */
187
187
  export const ERROR_DUPLICATE_REQUEST_ID = 'duplicate_request_id';
188
- /** Send one JSON-RPC error response frame on `ws`. */
189
- const send_error_response = (ws, id, error) => {
190
- ws.send(JSON.stringify(create_jsonrpc_error_response(id, error)));
188
+ /**
189
+ * Send one JSON-RPC error response frame to `connection_id` through the
190
+ * transport's outbound queue.
191
+ */
192
+ const send_error_response = (transport, connection_id, id, error) => {
193
+ transport.send_to(connection_id, JSON.stringify(create_jsonrpc_error_response(id, error)));
191
194
  };
192
195
  /**
193
196
  * Whether a frame is a reply to a server-initiated request, in the shape the
@@ -248,6 +251,10 @@ const is_peer_response = (json) => {
248
251
  * `WS_CLOSE_INTERNAL_ERROR`. Frames sent before admission wait for it.
249
252
  * - A socket the server has closed dispatches nothing more, whether or not
250
253
  * its client answers the close.
254
+ * - Every frame sent — responses, error frames, notifications — goes through
255
+ * the transport's outbound queue. A connection with more waiting than its
256
+ * `outbound_queue_bytes` budget closes with `WS_CLOSE_OUTBOUND_OVERFLOW`, and
257
+ * the response of the request whose handler overflowed it is not sent.
251
258
  *
252
259
  * @returns the transport (supplied or freshly created) — retain it to wire
253
260
  * `create_ws_auth_guard` or broadcast on audit events.
@@ -256,8 +263,9 @@ const is_peer_response = (json) => {
256
263
  * connection via `register_pending` / `admit` / `remove_connection`
257
264
  * @mutates options.connection_closer - adds the endpoint's transport, once the
258
265
  * mount has succeeded
259
- * @throws Error when `max_connections_per_account` is passed alongside
260
- * `transport`, or is neither `null` nor a positive integer, or when
266
+ * @throws Error when `max_connections_per_account` or `outbound_queue_bytes`
267
+ * is passed alongside `transport`, when the cap is neither `null` nor a
268
+ * positive integer, when the budget is not a positive integer, or when
261
269
  * `actions` fails to compile (`compile_action_registry`)
262
270
  */
263
271
  export const register_action_ws = (options) => {
@@ -266,10 +274,16 @@ export const register_action_ws = (options) => {
266
274
  throw new Error(`register_action_ws: max_connections_per_account configures the transport created for ${options.path}, ` +
267
275
  'but a transport was supplied — set the cap on that BackendWebsocketTransport instead');
268
276
  }
277
+ if (options.transport && options.outbound_queue_bytes !== undefined) {
278
+ // a budget that silently did nothing would read as enforced
279
+ throw new Error(`register_action_ws: outbound_queue_bytes configures the transport created for ${options.path}, ` +
280
+ 'but a transport was supplied — set the budget on that BackendWebsocketTransport instead');
281
+ }
269
282
  const { path, app, upgradeWebSocket, actions, db, heartbeat = true, artificial_delay = 0, max_message_bytes = DEFAULT_WS_MAX_MESSAGE_BYTES, log = new Logger('[ws]'), on_socket_open, on_socket_close, action_ip_rate_limiter, action_account_rate_limiter } = options;
270
283
  const transport = options.transport ??
271
284
  new BackendWebsocketTransport({
272
285
  max_connections_per_account: options.max_connections_per_account,
286
+ outbound_queue_bytes: options.outbound_queue_bytes,
273
287
  log
274
288
  });
275
289
  // Build the dispatcher's per-method lookup. Only request_response
@@ -442,12 +456,17 @@ export const register_action_ws = (options) => {
442
456
  // End the socket from the server side: unregister it and abort its
443
457
  // signal *now* — so nothing more dispatches and in-flight handlers
444
458
  // see `signal.aborted` while the close handshake is still in flight —
445
- // then send the close frame.
459
+ // then send the close frame. The transport does all three for a
460
+ // connection it still holds, sending the close behind the waiting
461
+ // frames the socket has room for and under the close deadline; a socket
462
+ // whose connection is already gone has nothing waiting and is closed
463
+ // directly.
446
464
  const end_socket = (ws, code, reason) => {
447
- if (captured_connection_id !== undefined) {
448
- transport.remove_connection(captured_connection_id);
449
- }
465
+ const closed = captured_connection_id !== undefined &&
466
+ transport.close_connection(captured_connection_id, code, reason);
450
467
  socket_abort_controller.abort();
468
+ if (closed)
469
+ return;
451
470
  try {
452
471
  ws.close(code, reason);
453
472
  }
@@ -459,20 +478,22 @@ export const register_action_ws = (options) => {
459
478
  // close frame: a revocation closed its pending registration (the
460
479
  // transport sent the 4001), the shutdown did (the 1001 — so a refusal
461
480
  // during shutdown never sends a revocation's or a failure's code), or
462
- // the client went away.
481
+ // the client went away. Its registration is gone too — the transport
482
+ // removed it with its close, `onClose` with the client's — and it is
483
+ // not removed again here: `remove_connection` is for a socket that has
484
+ // closed, and would drop the close deadline of one that has not.
463
485
  const refuse_upgrade = (ws, code, reason) => {
464
- if (captured_connection_id !== undefined) {
465
- transport.remove_connection(captured_connection_id);
466
- }
467
486
  if (!socket_abort_controller.signal.aborted)
468
487
  end_socket(ws, code, reason);
469
488
  };
470
489
  // Socket-scoped notification helper — routes to this socket only,
471
490
  // matches the `ctx.notify` semantics exposed to per-message handlers.
472
- const notify_socket = (ws) => (notify_method, notify_params) => {
491
+ // Through the transport's outbound queue, like every other frame: a
492
+ // handler that notifies past the connection's budget closes it.
493
+ const notify_socket = (connection_id) => (notify_method, notify_params) => {
473
494
  try {
474
495
  const notification = create_jsonrpc_notification(notify_method, to_jsonrpc_params(notify_params));
475
- ws.send(JSON.stringify(notification));
496
+ transport.send_to(connection_id, JSON.stringify(notification));
476
497
  }
477
498
  catch (error) {
478
499
  log.error('notify send failed:', notify_method, error);
@@ -564,7 +585,7 @@ export const register_action_ws = (options) => {
564
585
  ws,
565
586
  connection_id,
566
587
  identity,
567
- notify: notify_socket(ws),
588
+ notify: notify_socket(connection_id),
568
589
  signal: socket_abort_controller.signal
569
590
  });
570
591
  }
@@ -573,12 +594,7 @@ export const register_action_ws = (options) => {
573
594
  // an ended socket has no one to tell
574
595
  if (phase_is('ended'))
575
596
  return;
576
- try {
577
- send_error_response(ws, null, jsonrpc_error_messages.internal_error());
578
- }
579
- catch {
580
- // ignore — socket may already be dead
581
- }
597
+ send_error_response(transport, connection_id, null, jsonrpc_error_messages.internal_error());
582
598
  end_socket(ws, WS_CLOSE_INTERNAL_ERROR, 'socket bootstrap failed');
583
599
  return;
584
600
  }
@@ -592,7 +608,7 @@ export const register_action_ws = (options) => {
592
608
  phase = 'open';
593
609
  for (const frame of queued_frames.splice(0)) {
594
610
  if (transport.is_registered(connection_id)) {
595
- handle_frame(frame.data, frame.ws, connection_id).then(frame.resolve, frame.reject);
611
+ handle_frame(frame.data, connection_id).then(frame.resolve, frame.reject);
596
612
  }
597
613
  else {
598
614
  frame.resolve();
@@ -601,19 +617,20 @@ export const register_action_ws = (options) => {
601
617
  };
602
618
  // Dispatch one inbound frame on an admitted, still-registered
603
619
  // connection.
604
- const handle_frame = async (data, ws, connection_id) => {
620
+ const handle_frame = async (data, connection_id) => {
621
+ const send_error = (id, error) => send_error_response(transport, connection_id, id, error);
605
622
  let json;
606
623
  try {
607
624
  json = JSON.parse(String(data));
608
625
  }
609
626
  catch (error) {
610
627
  log.error('JSON parse error:', error);
611
- send_error_response(ws, null, jsonrpc_error_messages.parse_error());
628
+ send_error(null, jsonrpc_error_messages.parse_error());
612
629
  return;
613
630
  }
614
631
  // Batch JSON-RPC is not supported on the WebSocket path.
615
632
  if (Array.isArray(json)) {
616
- send_error_response(ws, null, jsonrpc_error_messages.invalid_request('batch JSON-RPC requests are not supported on WebSocket'));
633
+ send_error(null, jsonrpc_error_messages.invalid_request('batch JSON-RPC requests are not supported on WebSocket'));
617
634
  return;
618
635
  }
619
636
  // Inbound responses to server-initiated requests (`peer/ping`,
@@ -642,7 +659,7 @@ export const register_action_ws = (options) => {
642
659
  // falls through here on purpose, so it is answered rather
643
660
  // than swallowed.
644
661
  if (!is_jsonrpc_object(json)) {
645
- send_error_response(ws, to_jsonrpc_envelope_id(json), jsonrpc_error_messages.invalid_request());
662
+ send_error(to_jsonrpc_envelope_id(json), jsonrpc_error_messages.invalid_request());
646
663
  return;
647
664
  }
648
665
  // Notifications (string method + no id) — `cancel` is
@@ -671,7 +688,7 @@ export const register_action_ws = (options) => {
671
688
  }
672
689
  return;
673
690
  }
674
- send_error_response(ws, to_jsonrpc_message_id(json), jsonrpc_error_messages.invalid_request());
691
+ send_error(to_jsonrpc_message_id(json), jsonrpc_error_messages.invalid_request());
675
692
  return;
676
693
  }
677
694
  // Envelope validation — the same `JsonrpcRequest.safeParse` the
@@ -684,7 +701,7 @@ export const register_action_ws = (options) => {
684
701
  // precedes it.
685
702
  const envelope = JsonrpcRequest.safeParse(json);
686
703
  if (!envelope.success) {
687
- send_error_response(ws, to_jsonrpc_message_id(json), jsonrpc_error_messages.invalid_request(dev_only({ issues: envelope.error.issues })));
704
+ send_error(to_jsonrpc_message_id(json), jsonrpc_error_messages.invalid_request(dev_only({ issues: envelope.error.issues })));
688
705
  return;
689
706
  }
690
707
  const { method, id, params } = envelope.data;
@@ -697,13 +714,13 @@ export const register_action_ws = (options) => {
697
714
  // `perform_action`, so neither charges the action rate limiters —
698
715
  // the same on the Rust spine.
699
716
  if (pending_controllers.has(id)) {
700
- send_error_response(ws, id, jsonrpc_error_messages.invalid_request({ reason: ERROR_DUPLICATE_REQUEST_ID }));
717
+ send_error(id, jsonrpc_error_messages.invalid_request({ reason: ERROR_DUPLICATE_REQUEST_ID }));
701
718
  return;
702
719
  }
703
720
  // The in-flight ceiling: shed, don't queue — see
704
721
  // `MAX_INBOUND_DISPATCHES_PER_CONNECTION`.
705
722
  if (pending_controllers.size >= MAX_INBOUND_DISPATCHES_PER_CONNECTION) {
706
- send_error_response(ws, id, jsonrpc_error_messages.queue_overflow(WS_INBOUND_DISPATCH_OVERFLOW_MESSAGE));
723
+ send_error(id, jsonrpc_error_messages.queue_overflow(WS_INBOUND_DISPATCH_OVERFLOW_MESSAGE));
707
724
  return;
708
725
  }
709
726
  // Per-action method lookup — return method_not_found before
@@ -712,7 +729,7 @@ export const register_action_ws = (options) => {
712
729
  // surface as method_not_found just like unknown methods.
713
730
  const action = action_map.get(method);
714
731
  if (!action) {
715
- send_error_response(ws, id, jsonrpc_error_messages.method_not_found(method));
732
+ send_error(id, jsonrpc_error_messages.method_not_found(method));
716
733
  return;
717
734
  }
718
735
  // Per-request controller — fires on explicit `cancel` or when the
@@ -745,14 +762,14 @@ export const register_action_ws = (options) => {
745
762
  // bearer) queues a close of this very socket on
746
763
  // `post_commit_effects` (`queue_connection_close`), and
747
764
  // the audit listeners repeat it from the same queue. The
748
- // `ws.send` on the success path runs before the `finally`
765
+ // send on the success path runs before the `finally`
749
766
  // flushes that queue, so the caller reads its reply and
750
767
  // then the 4001 close. Inverting the order — flushing the
751
768
  // queues before the send — would strand the caller without
752
769
  // a reply.
753
770
  const pending_effects = [];
754
771
  const post_commit_effects = [];
755
- const notify = notify_socket(ws);
772
+ const notify = notify_socket(connection_id);
756
773
  // Server→client request seam (ActionPeer) — closes over this
757
774
  // socket's connection_id so a handler can initiate a request to
758
775
  // the originating client and await the reply.
@@ -796,7 +813,10 @@ export const register_action_ws = (options) => {
796
813
  log.error(`action result is not serializable: ${method}`, error);
797
814
  frame = JSON.stringify(create_jsonrpc_error_response(id, jsonrpc_error_messages.internal_error(dev_only(error instanceof Error ? error.message : undefined))));
798
815
  }
799
- ws.send(frame);
816
+ // Refused when the connection is gone — closed while the handler
817
+ // ran, or closed by the handler's own notifications overflowing
818
+ // its outbound budget — so nothing follows a close frame.
819
+ transport.send_to(connection_id, frame);
800
820
  }
801
821
  finally {
802
822
  pending_controllers.delete(id);
@@ -843,7 +863,7 @@ export const register_action_ws = (options) => {
843
863
  queued_bytes += bytes;
844
864
  last_receive_time = Date.now();
845
865
  return new Promise((resolve, reject) => {
846
- queued_frames.push({ data: event.data, ws, resolve, reject });
866
+ queued_frames.push({ data: event.data, resolve, reject });
847
867
  });
848
868
  }
849
869
  // The transport is the authority on whether the connection is
@@ -860,7 +880,7 @@ export const register_action_ws = (options) => {
860
880
  }
861
881
  // only a frame that is accepted counts as activity
862
882
  last_receive_time = Date.now();
863
- return handle_frame(event.data, ws, connection_id);
883
+ return handle_frame(event.data, connection_id);
864
884
  },
865
885
  onClose: async (event, ws) => {
866
886
  // belt and braces: the abort listener stops the timer and the
@@ -39,6 +39,29 @@ export declare const WS_CLOSE_SERVER_HEARTBEAT_TIMEOUT = 4003;
39
39
  * spine's `WS_CLOSE_CONNECTION_LIMIT`.
40
40
  */
41
41
  export declare const WS_CLOSE_CONNECTION_LIMIT = 4004;
42
+ /**
43
+ * WebSocket close code — the server had more waiting to be written to this
44
+ * socket than the connection's outbound budget holds
45
+ * (`BackendWebsocketTransport`'s `outbound_queue_bytes`), because the client
46
+ * is reading slower than the server is sending to it, or not at all. The
47
+ * frame that did not fit and everything still waiting are dropped, so the
48
+ * close is the client's notice that it missed messages.
49
+ *
50
+ * Distinct from `WS_CLOSE_SESSION_REVOKED`: the credential is still good, so a
51
+ * client must not enter its `revoked` state. Unlike
52
+ * `WS_CLOSE_CONNECTION_LIMIT`, nothing took the connection's place:
53
+ * `FrontendWebsocketClient` reconnects under its ordinary backoff, and the
54
+ * consumer re-reads whatever state it tracks. A client that reads nothing at
55
+ * all may never see the frame — a socket whose write makes no progress is
56
+ * dropped without one, which a browser reports as 1006. The twin of the Rust
57
+ * spine's `WS_CLOSE_OUTBOUND_OVERFLOW`.
58
+ */
59
+ export declare const WS_CLOSE_OUTBOUND_OVERFLOW = 4005;
60
+ /**
61
+ * The close reason sent with `WS_CLOSE_OUTBOUND_OVERFLOW`. The Rust spine
62
+ * sends the same text.
63
+ */
64
+ export declare const WS_CLOSE_OUTBOUND_OVERFLOW_REASON = "outbound queue overflow";
42
65
  /**
43
66
  * WebSocket close code — the server is shutting down (RFC 6455 §7.4.1 "Going
44
67
  * Away"). Sent to every live socket by `close_all_sockets`, which
@@ -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;;;;;;GAMG;AACH,eAAO,MAAM,wBAAwB,OAAO,CAAC;AAC7C;;;;GAIG;AACH,eAAO,MAAM,+BAA+B,oBAAoB,CAAC;AACjE,sEAAsE;AACtE,eAAO,MAAM,iCAAiC,OAAO,CAAC;AACtD,yEAAyE;AACzE,eAAO,MAAM,iCAAiC,OAAO,CAAC;AACtD;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,yBAAyB,OAAO,CAAC;AAC9C;;;;;;GAMG;AACH,eAAO,MAAM,mBAAmB,OAAO,CAAC;AACxC,wDAAwD;AACxD,eAAO,MAAM,0BAA0B,yBAAyB,CAAC;AACjE;;;GAGG;AACH,eAAO,MAAM,wBAAwB,OAAO,CAAC;AAC7C;;;;;;GAMG;AACH,eAAO,MAAM,yBAAyB,OAAO,CAAC;AAC9C;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,uBAAuB,OAAO,CAAC;AAE5C;;;;;;;GAOG;AACH,eAAO,MAAM,4BAA4B,QAAc,CAAC;AAIxD;;;;;;;;GAQG;AACH,eAAO,MAAM,gBAAgB,GAAI,MAAM,MAAM,EAAE,WAAW,MAAM,KAAG,MAAM,GAAG,IAI3E,CAAC;AAEF;;;;;GAKG;AACH,eAAO,MAAM,WAAW,GAAI,MAAM,MAAM,KAAG,MAA8C,CAAC;AAK1F,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":"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"}
@@ -38,6 +38,29 @@ export const WS_CLOSE_SERVER_HEARTBEAT_TIMEOUT = 4003;
38
38
  * spine's `WS_CLOSE_CONNECTION_LIMIT`.
39
39
  */
40
40
  export const WS_CLOSE_CONNECTION_LIMIT = 4004;
41
+ /**
42
+ * WebSocket close code — the server had more waiting to be written to this
43
+ * socket than the connection's outbound budget holds
44
+ * (`BackendWebsocketTransport`'s `outbound_queue_bytes`), because the client
45
+ * is reading slower than the server is sending to it, or not at all. The
46
+ * frame that did not fit and everything still waiting are dropped, so the
47
+ * close is the client's notice that it missed messages.
48
+ *
49
+ * Distinct from `WS_CLOSE_SESSION_REVOKED`: the credential is still good, so a
50
+ * client must not enter its `revoked` state. Unlike
51
+ * `WS_CLOSE_CONNECTION_LIMIT`, nothing took the connection's place:
52
+ * `FrontendWebsocketClient` reconnects under its ordinary backoff, and the
53
+ * consumer re-reads whatever state it tracks. A client that reads nothing at
54
+ * all may never see the frame — a socket whose write makes no progress is
55
+ * dropped without one, which a browser reports as 1006. The twin of the Rust
56
+ * spine's `WS_CLOSE_OUTBOUND_OVERFLOW`.
57
+ */
58
+ export const WS_CLOSE_OUTBOUND_OVERFLOW = 4005;
59
+ /**
60
+ * The close reason sent with `WS_CLOSE_OUTBOUND_OVERFLOW`. The Rust spine
61
+ * sends the same text.
62
+ */
63
+ export const WS_CLOSE_OUTBOUND_OVERFLOW_REASON = 'outbound queue overflow';
41
64
  /**
42
65
  * WebSocket close code — the server is shutting down (RFC 6455 §7.4.1 "Going
43
66
  * Away"). Sent to every live socket by `close_all_sockets`, which