@fuzdev/fuz_app 0.129.0 → 0.130.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 (33) hide show
  1. package/dist/actions/CLAUDE.md +23 -6
  2. package/dist/actions/connection_closer.d.ts +3 -1
  3. package/dist/actions/connection_closer.d.ts.map +1 -1
  4. package/dist/actions/register_action_ws.d.ts +46 -0
  5. package/dist/actions/register_action_ws.d.ts.map +1 -1
  6. package/dist/actions/register_action_ws.js +105 -14
  7. package/dist/actions/transports.d.ts +2 -1
  8. package/dist/actions/transports.d.ts.map +1 -1
  9. package/dist/actions/transports.js +2 -1
  10. package/dist/actions/transports_ws_backend.d.ts +37 -4
  11. package/dist/actions/transports_ws_backend.d.ts.map +1 -1
  12. package/dist/actions/transports_ws_backend.js +68 -4
  13. package/dist/auth/audit_log_routes.d.ts +7 -3
  14. package/dist/auth/audit_log_routes.d.ts.map +1 -1
  15. package/dist/auth/audit_log_routes.js +48 -26
  16. package/dist/http/CLAUDE.md +1 -1
  17. package/dist/http/jsonrpc_errors.d.ts.map +1 -1
  18. package/dist/http/jsonrpc_errors.js +7 -5
  19. package/dist/realtime/sse.d.ts +14 -1
  20. package/dist/realtime/sse.d.ts.map +1 -1
  21. package/dist/realtime/sse.js +77 -1
  22. package/dist/realtime/sse_auth_guard.js +1 -1
  23. package/dist/realtime/subscriber_registry.d.ts +44 -8
  24. package/dist/realtime/subscriber_registry.d.ts.map +1 -1
  25. package/dist/realtime/subscriber_registry.js +64 -10
  26. package/dist/server/app_server.d.ts +4 -1
  27. package/dist/server/app_server.d.ts.map +1 -1
  28. package/dist/testing/CLAUDE.md +16 -0
  29. package/dist/testing/cross_backend/peer_ping_ws.js +4 -4
  30. package/dist/testing/cross_backend/ws_inbound_dispatch.d.ts +20 -0
  31. package/dist/testing/cross_backend/ws_inbound_dispatch.d.ts.map +1 -0
  32. package/dist/testing/cross_backend/ws_inbound_dispatch.js +177 -0
  33. package/package.json +1 -1
@@ -362,7 +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_GOING_AWAY = 1001` — the server is shutting down (reason `WS_CLOSE_GOING_AWAY_REASON`, `Server shutting down`): `close_all_sockets`, which `AppServer.close` runs. Not a revocation: the client reconnects under its ordinary backoff.
365
+ - `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
366
  - `WS_CLOSE_MESSAGE_TOO_BIG = 1009` — server received a message over its `max_message_bytes` cap (RFC 6455 "Message Too Big").
367
367
  - `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.
368
368
  - `WS_CLOSE_INTERNAL_ERROR = 1011` — the admission credential re-check itself failed (reason `internal error`), `on_socket_open` threw (reason `socket bootstrap failed`), or the socket's connection was removed from the transport without a close (reason `connection unregistered`). Not a revocation: the client reconnects under its ordinary backoff. Twin of the Rust spine's constant.
@@ -487,7 +487,15 @@ each closed connection's controller):
487
487
 
488
488
  `close_all_sockets()` closes every connection, pending and admitted, with
489
489
  `WS_CLOSE_GOING_AWAY` instead — the shutdown close, removing and aborting each
490
- the same way.
490
+ the same way — and leaves the transport **closing** for good (`is_closing()`):
491
+ every later registration is **born closed**. `register_pending` and
492
+ `add_connection` then insert nothing, abort the caller's controller, and close
493
+ the socket with `WS_CLOSE_GOING_AWAY` before returning an id that names
494
+ nothing, so `admit` refuses it — an upgrade in flight when the server shuts
495
+ down is closed rather than outliving it. The flag is set in the same
496
+ synchronous step as the closes, so an admission that finds its entry gone
497
+ during shutdown reads the shutdown, never a revocation. Twin of the Rust
498
+ registry's closing flag.
491
499
 
492
500
  Fan-out, to admitted connections only: `send(notification)` broadcasts to every
493
501
  connection; `broadcast_filtered(message, predicate)` runs per-connection ACL
@@ -542,7 +550,10 @@ a `SubscriberRegistry` (`close_all_sockets` is `SubscriberRegistry.close_all`).
542
550
  Closing is idempotent and synchronous. `close_all_sockets` is the one method no
543
551
  revocation calls: `AppServer.close` runs it on `deps.connection_closer` to end
544
552
  every live connection of the backend at shutdown, and it has no
545
- `ConnectionCloseTarget` variant — nor a counterpart on the Rust
553
+ `ConnectionCloseTarget` variant. It also leaves each member closing — the WS
554
+ transport and `SubscriberRegistry` refuse every later registration (born
555
+ closed) — so a closed transport or registry hosts nothing more. Its Rust
556
+ counterpart is `ShutdownCloser::close_all_sockets`, a supertrait of
546
557
  `SocketRevoker`.
547
558
 
548
559
  **Every revocation closes after its commit.** A handler never calls a closer
@@ -727,6 +738,8 @@ HTTP RPC also calls. `register_action_ws` owns only WS-specific concerns:
727
738
 
728
739
  - **Wire envelope parsing** — JSON.parse → batch rejection → peer-response routing → envelope-version guard → notification interception (cancel, silent drop) → `JsonrpcRequest.safeParse` → per-message dispatch. The version guard is what makes `{method: 'x'}` and `{jsonrpc: '1.0', method: 'x'}` an `invalid_request` answer rather than a silently dropped notification, matching the first check the Rust `classify` runs. Peer-response routing is the twin's response shape exactly — no `method` key, a string/number `id`, exactly one of `result` / `error` — so a hybrid frame like `{method, id, result}` stays a dispatchable request instead of being demuxed and swallowed. The envelope schema is the same one the HTTP POST path runs, so `params: null`, an array `params`, or a non-string/number `id` answers `invalid_request` (-32600) on both transports rather than reaching the dispatcher as `invalid_params`. It runs **after** the notification/cancel block: notifications carry no id to echo and stay unvalidated, and `cancel` routing precedes it. The version guard echoes the id via `to_jsonrpc_envelope_id` (a scalar frame answers `id: null`), the later steps via `to_jsonrpc_message_id`.
729
740
  - **Cancel-notification interception** — `{request_id → AbortController}` map; aborts the matching pending controller before the cancel bubbles past the dispatcher
741
+ - **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
+ - **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`
730
743
  - **Socket-scoped notify** — `(method, params) => ws.send(notification)`, threaded into `perform_action` as `notify`
731
744
  - **Composed abort signal** — `AbortSignal.any([socket_close, per_request_cancel])`, threaded as `signal`
732
745
  - **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
@@ -741,8 +754,12 @@ audit stream shares, read off the keys the auth middleware set), then
741
754
  meanwhile, closes the socket with `WS_CLOSE_SESSION_REVOKED` and the reason a
742
755
  revoked socket gets; a re-check that throws closes it with
743
756
  `WS_CLOSE_INTERNAL_ERROR` (`internal error`) — fail closed, never admitted
744
- unchecked. A refused socket gets nothing else written to it, evicts nothing,
745
- and runs neither lifecycle hook. An upgrade with no resolved credential on its
757
+ unchecked. During shutdown a refusal is the shutdown's instead: an upgrade
758
+ pending at the close-all was closed by it with `WS_CLOSE_GOING_AWAY` and its
759
+ controller aborted, so the refusal after the re-read — even one the closing
760
+ database failed — writes nothing more; an upgrade that registers after it is
761
+ born closed and skips the re-read. A refused socket gets nothing else written
762
+ to it, evicts nothing, and runs neither lifecycle hook. An upgrade with no resolved credential on its
746
763
  context, or one on another account than its request context, throws before the
747
764
  socket exists. The test-preset escape hatch
748
765
  (`TEST_CONTEXT_PRESET_KEY`) skips the re-read — a pre-baked credential has no
@@ -859,7 +876,7 @@ A timeout unregisters the connection and aborts its signal along with the
859
876
  Two abort signals composed via `AbortSignal.any`:
860
877
 
861
878
  - `socket_abort_controller` — per-socket, fires when the socket ends: at the server's close (revocation, cap eviction, heartbeat timeout, …) or the client's. Registered with the transport, which aborts it on the closes it makes. Drives every handler's `ctx.signal`.
862
- - `pending_controllers: Map<JsonrpcRequestId, AbortController>` — per-request. Registered before dispatch, cleared in `finally` so late cancels for a completed id (or a reused id) can't null-abort the wrong handler. Unknown cancels no-op.
879
+ - `pending_controllers: Map<JsonrpcRequestId, AbortController>` — per-request. Registered before dispatch (one entry per running request — a duplicate live id is refused — so its size is the in-flight count the ceiling bounds), cleared in `finally` so late cancels for a completed id (or a reused id) can't null-abort the wrong handler. Unknown cancels no-op.
863
880
 
864
881
  ## Protocol actions (`actions/protocol.ts`)
865
882
 
@@ -74,7 +74,9 @@ export interface ConnectionCloser {
74
74
  * Close every connection, whoever opened it — the shutdown close
75
75
  * `AppServer.close` runs. Not a revocation: a WebSocket gets
76
76
  * `WS_CLOSE_GOING_AWAY`, so its client reconnects rather than treating
77
- * its credential as revoked.
77
+ * its credential as revoked. The transport is left closing: a
78
+ * registration that arrives afterward is born closed, refused at once
79
+ * (`BackendWebsocketTransport` and `SubscriberRegistry` both do).
78
80
  */
79
81
  close_all_sockets: () => number;
80
82
  }
@@ -1 +1 @@
1
- {"version":3,"file":"connection_closer.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/actions/connection_closer.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2CG;AAEH,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,wBAAwB,CAAC;AAGnD,OAAO,EAAqB,KAAK,sBAAsB,EAAE,MAAM,4BAA4B,CAAC;AAE5F;;;;;;;;;;;;;;GAcG;AACH,MAAM,WAAW,gBAAgB;IAChC,qGAAqG;IACrG,yBAAyB,EAAE,CAAC,kBAAkB,EAAE,MAAM,KAAK,MAAM,CAAC;IAClE,4EAA4E;IAC5E,uBAAuB,EAAE,CAAC,YAAY,EAAE,MAAM,KAAK,MAAM,CAAC;IAC1D;;;;OAIG;IACH,yBAAyB,EAAE,CAAC,UAAU,EAAE,IAAI,KAAK,MAAM,CAAC;IACxD;;;;;OAKG;IACH,iBAAiB,EAAE,MAAM,MAAM,CAAC;CAChC;AAED;;;;GAIG;AACH,MAAM,MAAM,qBAAqB,GAC9B;IAAE,IAAI,EAAE,SAAS,CAAC;IAAC,kBAAkB,EAAE,MAAM,CAAA;CAAE,GAC/C;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,YAAY,EAAE,MAAM,CAAA;CAAE,GACvC;IAAE,IAAI,EAAE,SAAS,CAAC;IAAC,UAAU,EAAE,IAAI,CAAA;CAAE,CAAC;AAEzC;;;;;;;GAOG;AACH,eAAO,MAAM,iBAAiB,GAC7B,QAAQ,gBAAgB,EACxB,QAAQ,qBAAqB,KAC3B,MAWF,CAAC;AAEF;;;;;;;;;;;;;;;GAeG;AACH,eAAO,MAAM,sBAAsB,GAClC,KAAK,sBAAsB,EAC3B,QAAQ,gBAAgB,EACxB,QAAQ,qBAAqB,KAC3B,IAIF,CAAC;AAEF;;;GAGG;AACH,eAAO,MAAM,sBAAsB,EAAE,gBAKnC,CAAC;AAEH;;;;;;;;;;;;GAYG;AACH,MAAM,WAAW,cAAe,SAAQ,gBAAgB;IACvD;;;;;;;;;;;;;;;OAeG;IACH,GAAG,EAAE,CAAC,MAAM,EAAE,gBAAgB,KAAK,MAAM,IAAI,CAAC;IAC9C,2EAA2E;IAC3E,YAAY,EAAE,MAAM,MAAM,CAAC;CAC3B;AAED;;;;;;;;;GASG;AACH,eAAO,MAAM,sBAAsB,QAAO,cA4CzC,CAAC"}
1
+ {"version":3,"file":"connection_closer.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/actions/connection_closer.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2CG;AAEH,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,wBAAwB,CAAC;AAGnD,OAAO,EAAqB,KAAK,sBAAsB,EAAE,MAAM,4BAA4B,CAAC;AAE5F;;;;;;;;;;;;;;GAcG;AACH,MAAM,WAAW,gBAAgB;IAChC,qGAAqG;IACrG,yBAAyB,EAAE,CAAC,kBAAkB,EAAE,MAAM,KAAK,MAAM,CAAC;IAClE,4EAA4E;IAC5E,uBAAuB,EAAE,CAAC,YAAY,EAAE,MAAM,KAAK,MAAM,CAAC;IAC1D;;;;OAIG;IACH,yBAAyB,EAAE,CAAC,UAAU,EAAE,IAAI,KAAK,MAAM,CAAC;IACxD;;;;;;;OAOG;IACH,iBAAiB,EAAE,MAAM,MAAM,CAAC;CAChC;AAED;;;;GAIG;AACH,MAAM,MAAM,qBAAqB,GAC9B;IAAE,IAAI,EAAE,SAAS,CAAC;IAAC,kBAAkB,EAAE,MAAM,CAAA;CAAE,GAC/C;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,YAAY,EAAE,MAAM,CAAA;CAAE,GACvC;IAAE,IAAI,EAAE,SAAS,CAAC;IAAC,UAAU,EAAE,IAAI,CAAA;CAAE,CAAC;AAEzC;;;;;;;GAOG;AACH,eAAO,MAAM,iBAAiB,GAC7B,QAAQ,gBAAgB,EACxB,QAAQ,qBAAqB,KAC3B,MAWF,CAAC;AAEF;;;;;;;;;;;;;;;GAeG;AACH,eAAO,MAAM,sBAAsB,GAClC,KAAK,sBAAsB,EAC3B,QAAQ,gBAAgB,EACxB,QAAQ,qBAAqB,KAC3B,IAIF,CAAC;AAEF;;;GAGG;AACH,eAAO,MAAM,sBAAsB,EAAE,gBAKnC,CAAC;AAEH;;;;;;;;;;;;GAYG;AACH,MAAM,WAAW,cAAe,SAAQ,gBAAgB;IACvD;;;;;;;;;;;;;;;OAeG;IACH,GAAG,EAAE,CAAC,MAAM,EAAE,gBAAgB,KAAK,MAAM,IAAI,CAAC;IAC9C,2EAA2E;IAC3E,YAAY,EAAE,MAAM,MAAM,CAAC;CAC3B;AAED;;;;;;;;;GASG;AACH,eAAO,MAAM,sBAAsB,QAAO,cA4CzC,CAAC"}
@@ -59,6 +59,14 @@
59
59
  * re-read itself failed — fail closed, never admitted unchecked. The handshake
60
60
  * has already answered `101`, so a refusal can only be a close frame.
61
61
  *
62
+ * Once the server is shutting down (`AppServer.close` ran the transport's
63
+ * `close_all_sockets`) a refusal is `WS_CLOSE_GOING_AWAY` instead, so the
64
+ * client reconnects rather than treating its credential as revoked: an upgrade
65
+ * pending at the close-all is closed by it — and its abort makes the refusal
66
+ * that follows the re-read send nothing more, even when the database closed
67
+ * under that re-read — and one that registers after it is born closed and
68
+ * skips the re-read (`BackendWebsocketTransport` module doc, "Shutdown").
69
+ *
62
70
  * Frames that arrive before admission are queued, not dispatched: nothing runs
63
71
  * on a credential that has not been re-read. They dispatch in arrival order
64
72
  * once the socket is admitted and `on_socket_open` has completed, and are
@@ -137,6 +145,38 @@ export declare const PRE_ADMISSION_QUEUE_BYTES_FACTOR = 4;
137
145
  * overflows its pre-admission queue, by count or by bytes.
138
146
  */
139
147
  export declare const WS_CLOSE_PRE_ADMISSION_OVERFLOW_REASON = "too much sent before the socket opened";
148
+ /**
149
+ * Max requests one socket may have dispatching at once. A request that
150
+ * arrives past it is answered `queue_overflow` with its id
151
+ * (`WS_INBOUND_DISPATCH_OVERFLOW_MESSAGE`) and not dispatched; the socket
152
+ * stays open, and the requests already running keep answering.
153
+ *
154
+ * A runtime adapter can hand frames over without awaiting the previous one's
155
+ * dispatch (`@hono/node-ws` does not await `onMessage`), so without a ceiling
156
+ * one socket could hold any number of handlers — and their pooled database
157
+ * work — at once. Shedding rather than queueing keeps the socket reading, so a
158
+ * reply to a server-initiated request still reaches the handler awaiting it.
159
+ * Generous, so ordinary pipelining never meets it. Same value as the Rust
160
+ * spine's `MAX_INBOUND_DISPATCHES_PER_CONN`.
161
+ *
162
+ * Counted are the requests that passed envelope validation and are not yet
163
+ * answered — the entries of the socket's cancellation map. Notifications,
164
+ * replies to server-initiated requests, and refused frames never count.
165
+ */
166
+ export declare const MAX_INBOUND_DISPATCHES_PER_CONNECTION = 128;
167
+ /**
168
+ * The `queue_overflow` message a request gets past
169
+ * `MAX_INBOUND_DISPATCHES_PER_CONNECTION`. Same text as the Rust spine's.
170
+ */
171
+ export declare const WS_INBOUND_DISPATCH_OVERFLOW_MESSAGE = "too many concurrent requests on this connection";
172
+ /**
173
+ * The `data.reason` of the `invalid_request` a WebSocket request gets when its
174
+ * id names a request still running on the same socket. The duplicate is not
175
+ * dispatched; the running request keeps its id, so its reply, and a `cancel`
176
+ * naming that id, stay its own. An id is free again once its request has been
177
+ * answered.
178
+ */
179
+ export declare const ERROR_DUPLICATE_REQUEST_ID = "duplicate_request_id";
140
180
  /**
141
181
  * Context passed to the `on_socket_open` hook.
142
182
  *
@@ -339,6 +379,12 @@ export interface RegisterActionWsResult {
339
379
  * - Notifications (method + no id) are silently dropped per JSON-RPC spec.
340
380
  * Exception: `cancel` notifications abort the matching pending request's
341
381
  * `ctx.signal` before bubbling out.
382
+ * - A request whose id names a request still running on the socket is
383
+ * answered `invalid_request` with `data.reason` `ERROR_DUPLICATE_REQUEST_ID`
384
+ * and not dispatched.
385
+ * - At most `MAX_INBOUND_DISPATCHES_PER_CONNECTION` requests run at once per
386
+ * socket; one more is answered `queue_overflow` and not dispatched, and the
387
+ * socket stays open.
342
388
  * - Per-message dispatch goes through `perform_action`: pre-authorization
343
389
  * auth (401) → authorization phase → post-authorization auth (403) →
344
390
  * rate limit (429) → input validation (400) → handler (with transaction
@@ -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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"}
@@ -59,6 +59,14 @@
59
59
  * re-read itself failed — fail closed, never admitted unchecked. The handshake
60
60
  * has already answered `101`, so a refusal can only be a close frame.
61
61
  *
62
+ * Once the server is shutting down (`AppServer.close` ran the transport's
63
+ * `close_all_sockets`) a refusal is `WS_CLOSE_GOING_AWAY` instead, so the
64
+ * client reconnects rather than treating its credential as revoked: an upgrade
65
+ * pending at the close-all is closed by it — and its abort makes the refusal
66
+ * that follows the re-read send nothing more, even when the database closed
67
+ * under that re-read — and one that registers after it is born closed and
68
+ * skips the re-read (`BackendWebsocketTransport` module doc, "Shutdown").
69
+ *
62
70
  * Frames that arrive before admission are queued, not dispatched: nothing runs
63
71
  * on a credential that has not been re-read. They dispatch in arrival order
64
72
  * once the socket is admitted and `on_socket_open` has completed, and are
@@ -145,6 +153,38 @@ export const PRE_ADMISSION_QUEUE_BYTES_FACTOR = 4;
145
153
  * overflows its pre-admission queue, by count or by bytes.
146
154
  */
147
155
  export const WS_CLOSE_PRE_ADMISSION_OVERFLOW_REASON = 'too much sent before the socket opened';
156
+ /**
157
+ * Max requests one socket may have dispatching at once. A request that
158
+ * arrives past it is answered `queue_overflow` with its id
159
+ * (`WS_INBOUND_DISPATCH_OVERFLOW_MESSAGE`) and not dispatched; the socket
160
+ * stays open, and the requests already running keep answering.
161
+ *
162
+ * A runtime adapter can hand frames over without awaiting the previous one's
163
+ * dispatch (`@hono/node-ws` does not await `onMessage`), so without a ceiling
164
+ * one socket could hold any number of handlers — and their pooled database
165
+ * work — at once. Shedding rather than queueing keeps the socket reading, so a
166
+ * reply to a server-initiated request still reaches the handler awaiting it.
167
+ * Generous, so ordinary pipelining never meets it. Same value as the Rust
168
+ * spine's `MAX_INBOUND_DISPATCHES_PER_CONN`.
169
+ *
170
+ * Counted are the requests that passed envelope validation and are not yet
171
+ * answered — the entries of the socket's cancellation map. Notifications,
172
+ * replies to server-initiated requests, and refused frames never count.
173
+ */
174
+ export const MAX_INBOUND_DISPATCHES_PER_CONNECTION = 128;
175
+ /**
176
+ * The `queue_overflow` message a request gets past
177
+ * `MAX_INBOUND_DISPATCHES_PER_CONNECTION`. Same text as the Rust spine's.
178
+ */
179
+ export const WS_INBOUND_DISPATCH_OVERFLOW_MESSAGE = 'too many concurrent requests on this connection';
180
+ /**
181
+ * The `data.reason` of the `invalid_request` a WebSocket request gets when its
182
+ * id names a request still running on the same socket. The duplicate is not
183
+ * dispatched; the running request keeps its id, so its reply, and a `cancel`
184
+ * naming that id, stay its own. An id is free again once its request has been
185
+ * answered.
186
+ */
187
+ export const ERROR_DUPLICATE_REQUEST_ID = 'duplicate_request_id';
148
188
  /** Send one JSON-RPC error response frame on `ws`. */
149
189
  const send_error_response = (ws, id, error) => {
150
190
  ws.send(JSON.stringify(create_jsonrpc_error_response(id, error)));
@@ -187,6 +227,12 @@ const is_peer_response = (json) => {
187
227
  * - Notifications (method + no id) are silently dropped per JSON-RPC spec.
188
228
  * Exception: `cancel` notifications abort the matching pending request's
189
229
  * `ctx.signal` before bubbling out.
230
+ * - A request whose id names a request still running on the socket is
231
+ * answered `invalid_request` with `data.reason` `ERROR_DUPLICATE_REQUEST_ID`
232
+ * and not dispatched.
233
+ * - At most `MAX_INBOUND_DISPATCHES_PER_CONNECTION` requests run at once per
234
+ * socket; one more is answered `queue_overflow` and not dispatched, and the
235
+ * socket stays open.
190
236
  * - Per-message dispatch goes through `perform_action`: pre-authorization
191
237
  * auth (401) → authorization phase → post-authorization auth (403) →
192
238
  * rate limit (429) → input validation (400) → handler (with transaction
@@ -333,7 +379,10 @@ export const register_action_ws = (options) => {
333
379
  // Populated on request dispatch, cleared in the handler's `finally`
334
380
  // so a late-arriving cancel for a completed id (or a reused id)
335
381
  // can't null-abort a freshly-arrived request. Idempotent: cancel
336
- // for unknown ids no-ops.
382
+ // for unknown ids no-ops. One entry per running request — a
383
+ // request reusing a live id is refused before it gets one — so its
384
+ // size is the in-flight count `MAX_INBOUND_DISPATCHES_PER_CONNECTION`
385
+ // bounds.
337
386
  const pending_controllers = new Map();
338
387
  // Identity is assembled at upgrade time so `on_socket_close` can
339
388
  // read it — by the time the hook runs the transport record is gone,
@@ -407,8 +456,10 @@ export const register_action_ws = (options) => {
407
456
  }
408
457
  };
409
458
  // Refuse an upgrade at admission. A socket that already ended needs no
410
- // close frame: either a revocation closed its pending registration (the
411
- // transport sent the 4001) or the client went away.
459
+ // close frame: a revocation closed its pending registration (the
460
+ // transport sent the 4001), the shutdown did (the 1001 — so a refusal
461
+ // during shutdown never sends a revocation's or a failure's code), or
462
+ // the client went away.
412
463
  const refuse_upgrade = (ws, code, reason) => {
413
464
  if (captured_connection_id !== undefined) {
414
465
  transport.remove_connection(captured_connection_id);
@@ -435,6 +486,17 @@ export const register_action_ws = (options) => {
435
486
  // Pending first, synchronously: from here every revocation finds it.
436
487
  const connection_id = transport.register_pending(ws, token_hash, account_id, api_token_id, socket_abort_controller);
437
488
  captured_connection_id = connection_id;
489
+ // Born closed — the server is shutting down, and the transport has
490
+ // already closed the socket with `WS_CLOSE_GOING_AWAY` — or ended
491
+ // otherwise before the re-read: nothing to re-read for. The removal
492
+ // is a no-op for a born-closed registration, which was never inserted.
493
+ if (socket_abort_controller.signal.aborted) {
494
+ transport.remove_connection(connection_id);
495
+ log.info(transport.is_closing()
496
+ ? 'ws upgrade admission: refused, the server is shutting down'
497
+ : 'ws upgrade admission: socket ended before the re-check', connection_id, account_id);
498
+ return null;
499
+ }
438
500
  if (!upgrade_preset) {
439
501
  let credential_is_live;
440
502
  try {
@@ -443,8 +505,15 @@ export const register_action_ws = (options) => {
443
505
  credential_is_live = await revalidate_resolved_auth({ db }, resolved);
444
506
  }
445
507
  catch (error) {
446
- // fail closed — never admitted unchecked
447
- log.error('ws upgrade admission: credential re-check failed', error);
508
+ // fail closed — never admitted unchecked. During shutdown the
509
+ // database may close under the re-read; that is the shutdown's
510
+ // refusal, not a failure.
511
+ if (transport.is_closing()) {
512
+ log.info('ws upgrade admission: re-check ended by the shutdown', error);
513
+ }
514
+ else {
515
+ log.error('ws upgrade admission: credential re-check failed', error);
516
+ }
448
517
  refuse_upgrade(ws, WS_CLOSE_INTERNAL_ERROR, 'internal error');
449
518
  return null;
450
519
  }
@@ -457,7 +526,9 @@ export const register_action_ws = (options) => {
457
526
  // The cap's eviction happens here, after every gate and the re-read,
458
527
  // so a refused upgrade never closes someone else's socket.
459
528
  if (!transport.admit(connection_id)) {
460
- log.info('ws upgrade admission: closed by a revocation while pending', connection_id, account_id);
529
+ log.info(transport.is_closing()
530
+ ? 'ws upgrade admission: closed by the shutdown while pending'
531
+ : 'ws upgrade admission: closed by a revocation while pending', connection_id, account_id);
461
532
  refuse_upgrade(ws, WS_CLOSE_SESSION_REVOKED, WS_CLOSE_SESSION_REVOKED_REASON);
462
533
  return null;
463
534
  }
@@ -614,6 +685,24 @@ export const register_action_ws = (options) => {
614
685
  return;
615
686
  }
616
687
  const { method, id, params } = envelope.data;
688
+ // A request is live from here until its reply is sent. One reusing
689
+ // a live id is refused rather than dispatched: two requests under
690
+ // one id would share a cancel entry and a reply id, and the first to
691
+ // finish would clear the other's. Checked before the ceiling — a
692
+ // duplicate is the client's error whatever the load, and retrying it
693
+ // cannot succeed while the first runs. Neither refusal reaches
694
+ // `perform_action`, so neither charges the action rate limiters —
695
+ // the same on the Rust spine.
696
+ if (pending_controllers.has(id)) {
697
+ send_error_response(ws, id, jsonrpc_error_messages.invalid_request({ reason: ERROR_DUPLICATE_REQUEST_ID }));
698
+ return;
699
+ }
700
+ // The in-flight ceiling: shed, don't queue — see
701
+ // `MAX_INBOUND_DISPATCHES_PER_CONNECTION`.
702
+ if (pending_controllers.size >= MAX_INBOUND_DISPATCHES_PER_CONNECTION) {
703
+ send_error_response(ws, id, jsonrpc_error_messages.queue_overflow(WS_INBOUND_DISPATCH_OVERFLOW_MESSAGE));
704
+ return;
705
+ }
617
706
  // Per-action method lookup — return method_not_found before
618
707
  // we engage the dispatch machinery. Specs without a handler
619
708
  // (client-only / dispatcher-handled) miss action_map and
@@ -623,16 +712,14 @@ export const register_action_ws = (options) => {
623
712
  send_error_response(ws, id, jsonrpc_error_messages.method_not_found(method));
624
713
  return;
625
714
  }
626
- if (artificial_delay > 0) {
627
- log.debug(`throttling ${artificial_delay}ms`);
628
- await wait(artificial_delay);
629
- }
630
715
  // Per-request controller — fires on explicit `cancel` or when the
631
716
  // socket ends (via the socket_abort_controller chain below).
632
- // Registered before dispatch so a cancel arriving mid-handler
633
- // finds it; cleared in `finally` so late cancels for a
634
- // completed id (or a future request that reuses the id) can't
635
- // null-abort the wrong handler.
717
+ // Registered before dispatch — ahead of `artificial_delay` too, so
718
+ // the request is live (counted, and its id taken) from the moment
719
+ // it is accepted — so a cancel arriving mid-handler finds it;
720
+ // cleared in `finally` so late cancels for a completed id (or a
721
+ // future request that reuses the id) can't null-abort the wrong
722
+ // handler.
636
723
  const request_controller = new AbortController();
637
724
  pending_controllers.set(id, request_controller);
638
725
  // Per-message side-effect queues. `pending_effects` collects
@@ -669,6 +756,10 @@ export const register_action_ws = (options) => {
669
756
  const request_client = (request_method, request_params, request_options) => transport.request_connection(connection_id, request_method, request_params, request_options);
670
757
  const signal = AbortSignal.any([socket_abort_controller.signal, request_controller.signal]);
671
758
  try {
759
+ if (artificial_delay > 0) {
760
+ log.debug(`throttling ${artificial_delay}ms`);
761
+ await wait(artificial_delay);
762
+ }
672
763
  const result = await perform_action({
673
764
  action,
674
765
  raw_params: params,
@@ -42,7 +42,8 @@ export declare const WS_CLOSE_CONNECTION_LIMIT = 4004;
42
42
  /**
43
43
  * WebSocket close code — the server is shutting down (RFC 6455 §7.4.1 "Going
44
44
  * Away"). Sent to every live socket by `close_all_sockets`, which
45
- * `AppServer.close` runs. Not a revocation: the credential is still good, so a
45
+ * `AppServer.close` runs, and to an upgrade the shutdown refuses — one
46
+ * pending at the close-all, or born closed after it. Not a revocation: the credential is still good, so a
46
47
  * client reconnects under its ordinary backoff once a server is back.
47
48
  */
48
49
  export declare const WS_CLOSE_GOING_AWAY = 1001;
@@ -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;;;;;GAKG;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":"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"}
@@ -41,7 +41,8 @@ export const WS_CLOSE_CONNECTION_LIMIT = 4004;
41
41
  /**
42
42
  * WebSocket close code — the server is shutting down (RFC 6455 §7.4.1 "Going
43
43
  * Away"). Sent to every live socket by `close_all_sockets`, which
44
- * `AppServer.close` runs. Not a revocation: the credential is still good, so a
44
+ * `AppServer.close` runs, and to an upgrade the shutdown refuses — one
45
+ * pending at the close-all, or born closed after it. Not a revocation: the credential is still good, so a
45
46
  * client reconnects under its ordinary backoff once a server is back.
46
47
  */
47
48
  export const WS_CLOSE_GOING_AWAY = 1001;
@@ -44,6 +44,21 @@
44
44
  * uses the two-phase form. The twin of the Rust `fuz_realtime`
45
45
  * `ConnectionRegistry`.
46
46
  *
47
+ * ## Shutdown
48
+ *
49
+ * `close_all_sockets` — the close `AppServer.close` runs — closes every
50
+ * connection, pending or admitted, with `WS_CLOSE_GOING_AWAY`, and leaves the
51
+ * transport **closing**: from then on every registration is born closed. A
52
+ * `register_pending` or `add_connection` inserts nothing — it aborts the
53
+ * caller's controller and closes the socket with `WS_CLOSE_GOING_AWAY` at once
54
+ * — so an upgrade still in flight when the server shuts down is closed rather
55
+ * than outliving it. An upgrade registered before the close-all is closed by
56
+ * it; one registered after is born closed; none is admitted. The flag is set
57
+ * in the same synchronous step that closes the live connections, so an `admit`
58
+ * that finds its entry gone and reads `is_closing()` sees the shutdown, never
59
+ * a revocation. Closing is permanent: a closed transport hosts no further
60
+ * connections.
61
+ *
47
62
  * @module
48
63
  */
49
64
  import type { WSContext } from 'hono/ws';
@@ -142,6 +157,11 @@ export declare class BackendWebsocketTransport implements FilterableBroadcastTra
142
157
  * The caller owns the entry until it is admitted: every refusal path calls
143
158
  * `remove_connection` with the returned id.
144
159
  *
160
+ * Once the transport is closing (`close_all_sockets`) the registration is
161
+ * born closed: nothing is inserted, `abort_controller` is aborted, and `ws`
162
+ * is closed with `WS_CLOSE_GOING_AWAY` before this returns. The id names
163
+ * nothing, so `admit` refuses it (module doc, "Shutdown").
164
+ *
145
165
  * @param ws - the socket to close on revocation
146
166
  * @param token_hash - blake3 session token hash, or `null` for non-session credentials
147
167
  * @param account_id - the authenticated account
@@ -149,7 +169,8 @@ export declare class BackendWebsocketTransport implements FilterableBroadcastTra
149
169
  * @param abort_controller - the socket-scoped controller to abort when the
150
170
  * transport closes this connection (revocation or cap eviction)
151
171
  * @returns the freshly assigned `connection_id` (branded `Uuid`)
152
- * @mutates this - inserts an un-admitted entry into `#connections`
172
+ * @mutates this - inserts an un-admitted entry into `#connections`, unless
173
+ * the transport is closing
153
174
  */
154
175
  register_pending(ws: WSContext, token_hash: string | null, account_id: Uuid, api_token_id?: string | null, abort_controller?: AbortController | null): Uuid;
155
176
  /**
@@ -192,7 +213,8 @@ export declare class BackendWebsocketTransport implements FilterableBroadcastTra
192
213
  * inserted. A removed connection gets no further broadcast or peer request,
193
214
  * stops counting toward the cap at once, and has its `abort_controller`
194
215
  * aborted; its socket is closed when the close handshake completes. The new
195
- * connection is always admitted.
216
+ * connection is always admitted — unless the transport is closing: then it
217
+ * is born closed, as in `register_pending`, and evicts nothing.
196
218
  *
197
219
  * @returns the freshly assigned `connection_id` (branded `Uuid`)
198
220
  * @mutates this - inserts an admitted entry into `#connections`; past the
@@ -261,12 +283,23 @@ export declare class BackendWebsocketTransport implements FilterableBroadcastTra
261
283
  * revocation does, so its heartbeat timer stops and nothing more dispatches
262
284
  * on it. A socket whose `close` throws does not stop the loop.
263
285
  *
286
+ * Leaves the transport closing, for good: every later registration is born
287
+ * closed (module doc, "Shutdown"). A second call closes nothing.
288
+ *
264
289
  * @returns the number of sockets closed
265
- * @mutates this - removes every connection, aborts its registered
266
- * controller, and closes its underlying `WSContext` with `WS_CLOSE_GOING_AWAY`
290
+ * @mutates this - sets the transport closing, removes every connection,
291
+ * aborts its registered controller, and closes its underlying `WSContext`
292
+ * with `WS_CLOSE_GOING_AWAY`
267
293
  * @throws the first error a socket's `close` threw, after every connection was attempted
268
294
  */
269
295
  close_all_sockets(): number;
296
+ /**
297
+ * Whether `close_all_sockets` has run — the transport is shutting down and
298
+ * every registration from now on is born closed (module doc, "Shutdown").
299
+ * For telemetry and logs: `register_action_ws` reads it to say why an
300
+ * upgrade was refused.
301
+ */
302
+ is_closing(): boolean;
270
303
  send(message: JsonrpcRequest, options?: TransportSendOptions): Promise<JsonrpcResponseOrError>;
271
304
  send(message: JsonrpcNotification, options?: TransportSendOptions): Promise<JsonrpcErrorResponse | null>;
272
305
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"transports_ws_backend.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/actions/transports_ws_backend.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"transports_ws_backend.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/actions/transports_ws_backend.ts"],"names":[],"mappings":"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"}
@@ -44,6 +44,21 @@
44
44
  * uses the two-phase form. The twin of the Rust `fuz_realtime`
45
45
  * `ConnectionRegistry`.
46
46
  *
47
+ * ## Shutdown
48
+ *
49
+ * `close_all_sockets` — the close `AppServer.close` runs — closes every
50
+ * connection, pending or admitted, with `WS_CLOSE_GOING_AWAY`, and leaves the
51
+ * transport **closing**: from then on every registration is born closed. A
52
+ * `register_pending` or `add_connection` inserts nothing — it aborts the
53
+ * caller's controller and closes the socket with `WS_CLOSE_GOING_AWAY` at once
54
+ * — so an upgrade still in flight when the server shuts down is closed rather
55
+ * than outliving it. An upgrade registered before the close-all is closed by
56
+ * it; one registered after is born closed; none is admitted. The flag is set
57
+ * in the same synchronous step that closes the live connections, so an `admit`
58
+ * that finds its entry gone and reads `is_closing()` sees the shutdown, never
59
+ * a revocation. Closing is permanent: a closed transport hosts no further
60
+ * connections.
61
+ *
47
62
  * @module
48
63
  */
49
64
  import { to_error_message } from '@fuzdev/fuz_util/error.ts';
@@ -78,6 +93,9 @@ export class BackendWebsocketTransport {
78
93
  // "oldest"). Adding a new identity scope (e.g. `device_id`) means adding a
79
94
  // field to `ConnectionIdentity`, not a parallel map.
80
95
  #connections = new Map();
96
+ // set once by `close_all_sockets`, never cleared — every later
97
+ // registration is born closed (module doc, "Shutdown")
98
+ #closing = false;
81
99
  // Server→client request correlation (ActionPeer). The transport owns the
82
100
  // sockets + the send; the registry owns the pending map, id allocation,
83
101
  // deadlines, and the per-connection in-flight cap (see `peer_request.ts`).
@@ -109,6 +127,11 @@ export class BackendWebsocketTransport {
109
127
  * The caller owns the entry until it is admitted: every refusal path calls
110
128
  * `remove_connection` with the returned id.
111
129
  *
130
+ * Once the transport is closing (`close_all_sockets`) the registration is
131
+ * born closed: nothing is inserted, `abort_controller` is aborted, and `ws`
132
+ * is closed with `WS_CLOSE_GOING_AWAY` before this returns. The id names
133
+ * nothing, so `admit` refuses it (module doc, "Shutdown").
134
+ *
112
135
  * @param ws - the socket to close on revocation
113
136
  * @param token_hash - blake3 session token hash, or `null` for non-session credentials
114
137
  * @param account_id - the authenticated account
@@ -116,9 +139,12 @@ export class BackendWebsocketTransport {
116
139
  * @param abort_controller - the socket-scoped controller to abort when the
117
140
  * transport closes this connection (revocation or cap eviction)
118
141
  * @returns the freshly assigned `connection_id` (branded `Uuid`)
119
- * @mutates this - inserts an un-admitted entry into `#connections`
142
+ * @mutates this - inserts an un-admitted entry into `#connections`, unless
143
+ * the transport is closing
120
144
  */
121
145
  register_pending(ws, token_hash, account_id, api_token_id = null, abort_controller = null) {
146
+ if (this.#closing)
147
+ return this.#close_born_closed(ws, abort_controller);
122
148
  return this.#insert(ws, token_hash, account_id, api_token_id, abort_controller, false);
123
149
  }
124
150
  /**
@@ -174,7 +200,8 @@ export class BackendWebsocketTransport {
174
200
  * inserted. A removed connection gets no further broadcast or peer request,
175
201
  * stops counting toward the cap at once, and has its `abort_controller`
176
202
  * aborted; its socket is closed when the close handshake completes. The new
177
- * connection is always admitted.
203
+ * connection is always admitted — unless the transport is closing: then it
204
+ * is born closed, as in `register_pending`, and evicts nothing.
178
205
  *
179
206
  * @returns the freshly assigned `connection_id` (branded `Uuid`)
180
207
  * @mutates this - inserts an admitted entry into `#connections`; past the
@@ -182,6 +209,8 @@ export class BackendWebsocketTransport {
182
209
  * their `WSContext`
183
210
  */
184
211
  add_connection(ws, token_hash, account_id, api_token_id = null, abort_controller = null) {
212
+ if (this.#closing)
213
+ return this.#close_born_closed(ws, abort_controller);
185
214
  if (this.#max_connections_per_account !== null) {
186
215
  this.#evict_oldest_for_account(account_id, this.#max_connections_per_account);
187
216
  }
@@ -305,14 +334,49 @@ export class BackendWebsocketTransport {
305
334
  * revocation does, so its heartbeat timer stops and nothing more dispatches
306
335
  * on it. A socket whose `close` throws does not stop the loop.
307
336
  *
337
+ * Leaves the transport closing, for good: every later registration is born
338
+ * closed (module doc, "Shutdown"). A second call closes nothing.
339
+ *
308
340
  * @returns the number of sockets closed
309
- * @mutates this - removes every connection, aborts its registered
310
- * controller, and closes its underlying `WSContext` with `WS_CLOSE_GOING_AWAY`
341
+ * @mutates this - sets the transport closing, removes every connection,
342
+ * aborts its registered controller, and closes its underlying `WSContext`
343
+ * with `WS_CLOSE_GOING_AWAY`
311
344
  * @throws the first error a socket's `close` threw, after every connection was attempted
312
345
  */
313
346
  close_all_sockets() {
347
+ // before any close, in the same synchronous step: an `admit` that finds
348
+ // its entry gone reads the shutdown, never a revocation — and set even
349
+ // when a socket's close throws
350
+ this.#closing = true;
314
351
  return this.#close_where(() => true, WS_CLOSE_GOING_AWAY, WS_CLOSE_GOING_AWAY_REASON);
315
352
  }
353
+ /**
354
+ * Whether `close_all_sockets` has run — the transport is shutting down and
355
+ * every registration from now on is born closed (module doc, "Shutdown").
356
+ * For telemetry and logs: `register_action_ws` reads it to say why an
357
+ * upgrade was refused.
358
+ */
359
+ is_closing() {
360
+ return this.#closing;
361
+ }
362
+ /**
363
+ * Close a registration that arrived after `close_all_sockets`, without
364
+ * inserting it. A socket whose `close` throws is logged, not thrown: the
365
+ * registration is refused either way, and its caller sees the aborted
366
+ * controller.
367
+ *
368
+ * @returns a fresh id that names no entry
369
+ */
370
+ #close_born_closed(ws, abort_controller) {
371
+ abort_controller?.abort();
372
+ try {
373
+ ws.close(WS_CLOSE_GOING_AWAY, WS_CLOSE_GOING_AWAY_REASON);
374
+ }
375
+ catch (error) {
376
+ this.#log?.error('error closing a socket registered during shutdown:', error);
377
+ }
378
+ return create_uuid();
379
+ }
316
380
  #cleanup_connection(connection_id) {
317
381
  this.#connections.delete(connection_id);
318
382
  // Wake any handler still awaiting a reply on this socket — the peer is