@fuzdev/fuz_app 0.134.0 → 0.136.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/actions/CLAUDE.md +93 -11
- package/dist/actions/register_action_ws.d.ts +23 -3
- package/dist/actions/register_action_ws.d.ts.map +1 -1
- package/dist/actions/register_action_ws.js +57 -37
- package/dist/actions/transports.d.ts +23 -0
- package/dist/actions/transports.d.ts.map +1 -1
- package/dist/actions/transports.js +23 -0
- package/dist/actions/transports_ws_backend.d.ts +133 -10
- package/dist/actions/transports_ws_backend.d.ts.map +1 -1
- package/dist/actions/transports_ws_backend.js +237 -34
- package/dist/actions/ws_endpoint_spec.d.ts +9 -0
- package/dist/actions/ws_endpoint_spec.d.ts.map +1 -1
- package/dist/actions/ws_outbound_writer.d.ts +206 -0
- package/dist/actions/ws_outbound_writer.d.ts.map +1 -0
- package/dist/actions/ws_outbound_writer.js +431 -0
- package/dist/auth/CLAUDE.md +10 -4
- package/dist/auth/account_action_specs.d.ts +7 -0
- package/dist/auth/account_action_specs.d.ts.map +1 -1
- package/dist/auth/account_action_specs.js +11 -1
- package/dist/auth/audit_emitter.d.ts +4 -0
- package/dist/auth/audit_emitter.d.ts.map +1 -1
- package/dist/auth/audit_emitter.js +18 -2
- package/dist/auth/audit_log_routes.d.ts.map +1 -1
- package/dist/auth/audit_log_routes.js +3 -1
- package/dist/auth/cell_action_specs.d.ts +10 -0
- package/dist/auth/cell_action_specs.d.ts.map +1 -1
- package/dist/auth/cell_action_specs.js +17 -3
- package/dist/auth/cell_actions.js +1 -1
- package/dist/auth/role_grant_offer_action_specs.d.ts.map +1 -1
- package/dist/auth/role_grant_offer_action_specs.js +7 -2
- package/dist/auth/role_schema.d.ts +14 -3
- package/dist/auth/role_schema.d.ts.map +1 -1
- package/dist/auth/role_schema.js +14 -2
- package/dist/auth/scope_kind_schema.d.ts +10 -0
- package/dist/auth/scope_kind_schema.d.ts.map +1 -1
- package/dist/auth/scope_kind_schema.js +11 -0
- package/dist/db/migrate.d.ts +1 -1
- package/dist/db/migrate.js +2 -2
- package/dist/env/load.d.ts +1 -1
- package/dist/env/load.js +1 -1
- package/dist/http/CLAUDE.md +2 -1
- package/dist/http/body_limit.d.ts +74 -0
- package/dist/http/body_limit.d.ts.map +1 -0
- package/dist/http/body_limit.js +100 -0
- package/dist/http/db_routes.d.ts.map +1 -1
- package/dist/http/db_routes.js +5 -3
- package/dist/http/error_schemas.d.ts +2 -0
- package/dist/http/error_schemas.d.ts.map +1 -1
- package/dist/http/error_schemas.js +2 -0
- package/dist/realtime/outbound_budget.d.ts +91 -0
- package/dist/realtime/outbound_budget.d.ts.map +1 -0
- package/dist/realtime/outbound_budget.js +108 -0
- package/dist/realtime/sse.d.ts +36 -3
- package/dist/realtime/sse.d.ts.map +1 -1
- package/dist/realtime/sse.js +58 -6
- package/dist/realtime/sse_constants.d.ts +12 -0
- package/dist/realtime/sse_constants.d.ts.map +1 -1
- package/dist/realtime/sse_constants.js +12 -0
- package/dist/server/app_server.d.ts.map +1 -1
- package/dist/server/app_server.js +3 -6
- package/dist/testing/CLAUDE.md +96 -19
- package/dist/testing/admin_integration.js +1 -1
- package/dist/testing/audit_completeness.d.ts +1 -1
- package/dist/testing/audit_completeness.js +1 -1
- package/dist/testing/audit_drift_guard.d.ts +19 -6
- package/dist/testing/audit_drift_guard.d.ts.map +1 -1
- package/dist/testing/audit_drift_guard.js +25 -9
- package/dist/testing/cross_backend/body_size.d.ts +18 -5
- package/dist/testing/cross_backend/body_size.d.ts.map +1 -1
- package/dist/testing/cross_backend/body_size.js +233 -34
- package/dist/testing/cross_backend/body_size_smuggling.d.ts +0 -12
- package/dist/testing/cross_backend/body_size_smuggling.d.ts.map +1 -1
- package/dist/testing/cross_backend/body_size_smuggling.js +103 -108
- package/dist/testing/cross_backend/capabilities.d.ts +3 -21
- package/dist/testing/cross_backend/capabilities.d.ts.map +1 -1
- package/dist/testing/cross_backend/capabilities.js +1 -3
- package/dist/testing/cross_backend/cell_crud.d.ts.map +1 -1
- package/dist/testing/cross_backend/cell_crud.js +31 -2
- package/dist/testing/cross_backend/conformance_case.d.ts +3 -3
- package/dist/testing/cross_backend/credential_header_robustness.d.ts.map +1 -1
- package/dist/testing/cross_backend/credential_header_robustness.js +18 -54
- package/dist/testing/cross_backend/default_backend_configs.d.ts.map +1 -1
- package/dist/testing/cross_backend/default_backend_configs.js +2 -8
- package/dist/testing/cross_backend/role_grant_role_grammar.d.ts +6 -0
- package/dist/testing/cross_backend/role_grant_role_grammar.d.ts.map +1 -0
- package/dist/testing/cross_backend/role_grant_role_grammar.js +117 -0
- package/dist/testing/cross_backend/rust_spine_stub_backend_config.d.ts +2 -3
- package/dist/testing/cross_backend/rust_spine_stub_backend_config.d.ts.map +1 -1
- package/dist/testing/cross_backend/rust_spine_stub_backend_config.js +2 -3
- package/dist/testing/cross_backend/testing_backdoor.d.ts.map +1 -1
- package/dist/testing/cross_backend/testing_backdoor.js +10 -4
- package/dist/testing/cross_backend/testing_reset_actions.d.ts +87 -2
- package/dist/testing/cross_backend/testing_reset_actions.d.ts.map +1 -1
- package/dist/testing/cross_backend/testing_reset_actions.js +106 -2
- package/dist/testing/cross_backend/token_lifetime.d.ts.map +1 -1
- package/dist/testing/cross_backend/token_lifetime.js +34 -1
- package/dist/testing/cross_backend/ts_spine_backend_config.d.ts.map +1 -1
- package/dist/testing/cross_backend/ts_spine_backend_config.js +8 -13
- package/dist/testing/cross_backend/ws_outbound_overflow.d.ts +29 -0
- package/dist/testing/cross_backend/ws_outbound_overflow.d.ts.map +1 -0
- package/dist/testing/cross_backend/ws_outbound_overflow.js +276 -0
- package/dist/testing/integration.js +1 -1
- package/dist/testing/schema_introspect.d.ts +30 -2
- package/dist/testing/schema_introspect.d.ts.map +1 -1
- package/dist/testing/schema_introspect.js +38 -7
- package/dist/testing/transports/raw_http.d.ts +78 -0
- package/dist/testing/transports/raw_http.d.ts.map +1 -0
- package/dist/testing/transports/raw_http.js +141 -0
- package/dist/testing/transports/ws_transport.d.ts +4 -2
- package/dist/testing/transports/ws_transport.d.ts.map +1 -1
- package/dist/testing/transports/ws_transport.js +2 -1
- package/dist/testing/ws_round_trip.d.ts +32 -2
- package/dist/testing/ws_round_trip.d.ts.map +1 -1
- package/dist/testing/ws_round_trip.js +26 -4
- package/package.json +1 -1
- package/dist/env/resolve.d.ts +0 -163
- package/dist/env/resolve.d.ts.map +0 -1
- package/dist/env/resolve.js +0 -267
package/dist/actions/CLAUDE.md
CHANGED
|
@@ -159,13 +159,13 @@ if a consumer genuinely owns protocol actions in their typed API.
|
|
|
159
159
|
|
|
160
160
|
**Consumer tiers.** Single-source consumers (zzz) drop into the helpers
|
|
161
161
|
and accept the default `* as specs` namespace import. Multi-source
|
|
162
|
-
consumers (
|
|
162
|
+
consumers (fuz_forge, visiones — stitching local specs with
|
|
163
163
|
`all_admin_action_specs` / `all_role_grant_offer_action_specs` /
|
|
164
164
|
`all_account_action_specs` / `all_self_service_role_action_specs` from
|
|
165
165
|
fuz_app) call `create_namespace_qualifier` once, then pass the returned
|
|
166
166
|
`qualify_spec` callback to multi-source helpers.
|
|
167
167
|
|
|
168
|
-
**Tier 1** (HTTP-only
|
|
168
|
+
**Tier 1** (HTTP-only) emits a smaller surface — typically
|
|
169
169
|
`ActionMethod` + `FrontendActionsApi` + `ActionInputs` / `ActionOutputs`.
|
|
170
170
|
Never calls `generate_typed_action_event_alias` or
|
|
171
171
|
`generate_frontend_action_handlers`. **Tier 2** (`TypedActionEvent`-aware,
|
|
@@ -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-
|
|
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
|
|
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?`,
|
|
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) =>
|
|
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
|
|
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.
|
|
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`.
|
|
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
|
-
/**
|
|
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`
|
|
412
|
-
* `transport`,
|
|
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":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+FG;AAEH,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,MAAM,CAAC;AACjC,OAAO,KAAK,EAAE,gBAAgB,EAAE,SAAS,EAAE,MAAM,SAAS,CAAC;AAE3D,OAAO,EAAU,KAAK,MAAM,IAAI,UAAU,EAAE,MAAM,yBAAyB,CAAC;AAC5E,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,wBAAwB,CAAC;AAWnD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAC;AAmBtD,OAAO,KAAK,EAAE,EAAE,EAAE,MAAM,aAAa,CAAC;AACtC,OAAO,EAAE,KAAK,MAAM,EAAE,MAAM,mBAAmB,CAAC;AAEhD,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,wBAAwB,CAAC;AAa7D,OAAO,EAAE,yBAAyB,EAAE,KAAK,kBAAkB,EAAE,MAAM,4BAA4B,CAAC;AAIhG,0EAA0E;AAC1E,eAAO,MAAM,gCAAgC,QAAS,CAAC;AAEvD;;;;;;;;;;;;;;;;GAgBG;AACH,eAAO,MAAM,wBAAwB,MAAM,CAAC;AAE5C;;;;;;;;;;;;;;;GAeG;AACH,eAAO,MAAM,gCAAgC,IAAI,CAAC;AAElD;;;GAGG;AACH,eAAO,MAAM,sCAAsC,2CAA2C,CAAC;AAE/F;;;;;;;;;;;;;;;;;GAiBG;AACH,eAAO,MAAM,qCAAqC,MAAM,CAAC;AAEzD;;;GAGG;AACH,eAAO,MAAM,oCAAoC,oDACC,CAAC;AAEnD;;;;;;GAMG;AACH,eAAO,MAAM,0BAA0B,yBAAyB,CAAC;
|
|
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
|
-
/**
|
|
189
|
-
|
|
190
|
-
|
|
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`
|
|
260
|
-
* `transport`,
|
|
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
|
-
|
|
448
|
-
transport.
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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(
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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
|