actions/connection_registry.ts

Backend WebSocket connection registry — manages server-side WebSocket connections with session tracking, revocation support, a per-account connection cap, and two-phase registration.

Two-phase registration

An upgrade resolves its credential before the socket exists, and every revocation closes the connections *registered* under the revoked credential. A one-step registration therefore has a window: a revocation landing between the credential check and the insert closes nothing, and the upgrade is admitted on a dead credential — for the socket's lifetime, since per-message dispatch never re-reads the session or token.

Registration is split to close it:

  1. ConnectionRegistry.register_pending inserts the entry un-admitted. It is closeable from that instant — close_connections matches pending entries deliberately — and is otherwise inert: it evicts nothing, the cap does not count it, no broadcast / send_to_account / request_connection reaches it, and the count accessors and is_registered do not see it (pending_connection_count does).
  2. The caller re-checks the credential against the database (revalidate_resolved_auth in auth/resolved_auth.ts).
  3. ConnectionRegistry.admit flips the entry to admitted — or refuses when a revocation closed it in the meantime, evicting nothing. The cap's eviction happens here, so a connection that is never admitted never closes a live one, and any number of pending registrations on one account leave its admitted connections alone.

A revocation whose close ran after step 1 finds the entry, pending or admitted, and closes it; one that committed before step 2 is seen by the re-read, which refuses. Nothing is delivered to a pending entry rather than queued for it, so a refusal never has to promise that a queue a revoked credential would have read is dropped unread, and a broadcast's returned count names only connections that can receive it. The cost is a slightly wider blind spot at connect: a server-initiated message sent between the handshake and admission — one credential re-check later — is not delivered, just as one sent before the handshake is not.

add_connection stays as the one-step form (register and admit together) for a connection with no revocable credential behind it — tests and harnesses that register a socket directly. An upgrade that authenticated a session or token uses the two-phase form. The twin of the Rust fuz_realtime ConnectionRegistry.

Outbound bound

Every frame the server sends a connection goes through one path — send_to, which responses, error frames and socket-scoped notifications use, and the fan-outs (broadcast, broadcast_filtered, send_to_account, request_connection) — into the connection's WsOutboundWriter (actions/ws_outbound_writer.ts). A send never waits and never throws: one recipient that stopped reading must not slow a producer or another recipient.

What waits for a socket is held to a byte budget, outbound_queue_bytes (DEFAULT_WS_OUTBOUND_QUEUE_BYTES): each waiting frame is charged its UTF-8 length plus OUTBOUND_FRAME_OVERHEAD_BYTES, and a frame is always accepted when nothing is waiting. Nothing counts frames. A frame sent to an idle connection goes straight to the socket; the rest of a burst sent in one synchronous run waits, as does anything sent while the socket's own buffer is backed up.

A frame that does not fit closes the connection rather than being dropped alone: the connection is removed in the same synchronous step — the overflowing send does not count it, later sends miss it, the count accessors and the cap no longer see it — its controller is aborted, what was waiting is discarded, and the socket is closed with WS_CLOSE_OUTBOUND_OVERFLOW. Frames already handed to the socket may still reach the client ahead of the close frame; nothing follows it, the response of the request whose handler overflowed the connection included. One warn line names the connection, its account, and what was waiting; no audit row is written — a client can provoke an overflow at will, and a write per overflow would amplify it.

A socket that holds unwritten bytes past WS_WRITE_STALL_TIMEOUT is dropped with no close frame, and removed the same way, with one warn line. Every other close the registry makes (a revocation, the cap, the shutdown, close_connection) hands the frames still waiting to the socket ahead of its close frame while the socket's buffer is below WS_WRITE_HIGH_WATER_BYTES, discards the rest, and cuts the socket if the close frame is still unwritten after WS_CLOSE_WRITE_TIMEOUT. A reply sent to an otherwise idle connection just before such a close reaches the client; one queued behind a backed-up socket does not.

A connection the registry closed or dropped is out of its map, so the per-account cap does not count it while its socket is still closing. What the runtime holds for such a socket is bounded by the writer (actions/ws_outbound_writer.ts, "Closing" and "Runtimes").

The twin of the Rust fuz_realtime outbound queue — same budget, charge, close code and deadlines. Where they differ: the Rust spine discards every frame queued before any close, and queues every frame, where this registry writes the first frame of a run straight through and, at a close that is not an overflow, the waiting frames its socket has room for.

Shutdown

close_all_connections — the close AppServer.close runs — closes every connection, pending or admitted, with WS_CLOSE_GOING_AWAY, and leaves the registry closing: from then on every registration is born closed. A register_pending or add_connection inserts nothing — it aborts the caller's controller and closes the socket with WS_CLOSE_GOING_AWAY at once — so an upgrade still in flight when the server shuts down is closed rather than outliving it. An upgrade registered before the close-all is closed by it; one registered after is born closed; none is admitted. The flag is set in the same synchronous step that closes the live connections, so an admit that finds its entry gone and reads is_closing() sees the shutdown — a shutdown is never read as a revocation (a revocation the shutdown overtook reads as the shutdown). Closing is permanent: a closed registry hosts no further connections.

view source

Declarations
#

6 declarations

connection_identity_matches
#

actions/connection_registry.ts view source

(identity: ConnectionIdentity, target: ConnectionCloseTarget): boolean import {connection_identity_matches} from '@fuzdev/fuz_app/actions/connection_registry.js';

Whether the connection identity belongs to is one target names — the predicate ConnectionRegistry.close_connections runs per connection. Twin of the Rust spine's ConnectionIdentity::matches. The SSE closer (create_sse_connection_closer) does not run it: it closes by the ids its registry holds, with one gating role for the whole registry.

  • session — identity.session_hash equals target.session_hash
  • token — identity.api_token_id equals target.api_token_id
  • account — identity.account_id equals target.account_id
  • role — the account matches and role is one of the endpoint's required_roles (an ungated connection never matches)

identity

the connection's identity

target

the connections to close

returns

boolean

whether target names this connection

ConnectionIdentity
#

actions/connection_registry.ts view source

ConnectionIdentity import type {ConnectionIdentity} from '@fuzdev/fuz_app/actions/connection_registry.js';

Auth identity attached to a single WebSocket connection.

One record per connection. session_hash is set for cookie-session connections, api_token_id for bearer (api_token) connections, and both are null for daemon-token connections (reachable only via an account close target). required_roles is what the connection's endpoint gated the upgrade on, kept per registration because endpoints with different gates can share one registry.

session_hash

Blake3 session token hash, or null for non-session credentials.

type string | null

account_id

Authenticated account id. Always set.

type Uuid

api_token_id

api_token.id for bearer-authenticated connections, else null.

type string | null

required_roles

The roles the endpoint required at upgrade (any-of), or empty for an ungated endpoint — what a role close target matches on.

type readonly string[]

ConnectionRegistry
#

actions/connection_registry.ts view source

import {ConnectionRegistry} from '@fuzdev/fuz_app/actions/connection_registry.js';

constructor

type new (options?: ConnectionRegistryOptions | undefined): ConnectionRegistry

options?

optional

throws

  • Error - when `max_connections_per_account` is neither `null` nor a positive integer, or `outbound_queue_bytes` is not a positive integer

register_pending

Register a connection un-admitted — phase one of the two-phase registration an upgrade runs (module doc).

The entry is closeable at once: a close_connections whose target matches session_hash / account_id / api_token_id removes it, aborts abort_controller, and closes ws with WS_CLOSE_SESSION_REVOKED (a role target, matching required_roles, with WS_CLOSE_PERMISSION_REVOKED); the later admit then refuses. Until admitted it evicts nothing, is not counted toward the per-account cap, and receives no message.

The caller owns the entry until it is admitted: a refusal closes it with close_connection, and remove_connection drops it once its socket has closed.

Once the registry is closing (close_all_connections) the registration is born closed: nothing is inserted, abort_controller is aborted, and ws is closed with WS_CLOSE_GOING_AWAY before this returns. The id names nothing, so admit refuses it (module doc, "Shutdown").

Twin of the Rust ConnectionRegistry::register_pending, which takes the same ConnectionIdentity.

type (ws: WSContext<unknown>, identity: ConnectionIdentity, abort_controller?: AbortController | null): string & $brand<"Uuid">

ws

the socket to close on revocation

type WSContext<unknown>

identity

who the connection is — what every close target and the per-account cap match on; the registry keeps a copy

abort_controller?

the socket-scoped controller to abort when the registry closes this connection (revocation or cap eviction)

type AbortController | null
optional default null
returns Uuid

the freshly assigned connection_id (branded Uuid)

mutates

  • this — inserts an un-admitted entry into `#connections`, unless the registry is closing

take_pending_close_code

The close code the registry closed a still-pending registration with — WS_CLOSE_SESSION_REVOKED for a credential revocation, WS_CLOSE_PERMISSION_REVOKED for a role close, WS_CLOSE_GOING_AWAY for the shutdown — read once: the record is dropped by this call (and by remove_connection). null when the registry did not close it while pending. For a caller whose admit was refused, to say why; the twin of the reason the Rust ConnectionRegistry::admit refuses with.

type (connection_id: string & $brand<"Uuid">): number | null

connection_id

the id register_pending returned

type Uuid
returns number | null

the close code, or null

mutates

  • this — drops the recorded code

admit

Admit a pending connection — phase two (module doc).

If the entry is gone — a revocation closed it while it was pending, or it was removed — nothing is evicted and the connection is refused. Otherwise the per-account cap is enforced exactly as in add_connection, counting only admitted connections, and the entry becomes admitted: it now receives messages, is counted, and can itself be superseded. Admitting an already-admitted connection is a no-op that answers true.

type (connection_id: string & $brand<"Uuid">): boolean

connection_id

the id register_pending returned

type Uuid
returns boolean

false when the connection is no longer registered — the caller closes the socket as revoked

mutates

  • this — marks the entry admitted; past the cap, first removes the account's oldest admitted connections and closes their `WSContext`

add_connection

Register a connection, admitted at once — the one-step form of register_pending + admit.

Not for an upgrade that authenticated a revocable credential: a revocation landing between that upgrade's credential check and this call closes nothing, and the connection would be admitted on a dead credential (module doc, "Two-phase registration"). This is for a connection with nothing to revoke behind it.

A session connection's identity carries its session hash for targeted revocation. A bearer token connection (api_token) carries the api_token.id so the socket can be closed when that specific token is revoked without tearing down the account's other sockets. A daemon-token connection carries null for both — it's only reachable via an account close target.

Enforces the per-account cap (max_connections_per_account): when account_id already holds the maximum, its oldest admitted connections are removed and sent a WS_CLOSE_CONNECTION_LIMIT close before the new one is inserted. A removed connection gets no further broadcast or peer request, stops counting toward the cap at once, and has its abort_controller aborted; its socket is closed when the close handshake completes. The new connection is always admitted — unless the registry is closing: then it is born closed, as in register_pending, and evicts nothing.

type (ws: WSContext<unknown>, identity: ConnectionIdentity, abort_controller?: AbortController | null): string & $brand<"Uuid">

ws

the socket to close on revocation

type WSContext<unknown>

identity

who the connection is — what every close target and the per-account cap match on; the registry keeps a copy

abort_controller?

the socket-scoped controller to abort when the registry closes this connection (revocation or cap eviction)

type AbortController | null
optional default null
returns Uuid

the freshly assigned connection_id (branded Uuid)

mutates

  • this — inserts an admitted entry into `#connections`; past the cap, first removes the account's oldest admitted connections and closes their `WSContext`

remove_connection

Remove a connection and its auth tracking data, pending or admitted, by the id register_pending / add_connection returned. Idempotent — safe to call after revocation or a cap eviction has already cleaned up, and a no-op for an unknown id.

Keyed by id rather than by WSContext because a runtime adapter may hand each socket event its own context object (@hono/bun does), so the one a close event carries can't identify the connection.

Removal only: the socket is not closed and the registered abort_controller is not aborted — the caller that ends a socket does both. What was waiting to be written to it is dropped.

For a socket that has closed (an adapter's close event): on a connection the registry closed it also drops the close deadline, so calling it while that socket is still closing leaves a client that is not reading uncut.

type (connection_id: string & $brand<"Uuid">): void

connection_id

type Uuid
returns void

mutates

  • this — deletes the connection's entry from `#connections`, drops its waiting frames and write deadlines, and settles its pending peer requests as `connection_gone`

close_connection

Close one connection, pending or admitted, with code and reason — the close register_action_ws makes for a heartbeat timeout, an oversized message, a failed on_socket_open, and a refused admission. Not to be confused with close_connections, which closes every connection a ConnectionCloseTarget names.

The connection is removed and its controller aborted at once; frames still waiting for the socket are handed to it ahead of the close frame while its buffer has room and discarded otherwise, and the close frame is held to WS_CLOSE_WRITE_TIMEOUT (module doc, "Outbound bound"). A socket whose close throws is logged, not thrown.

type (connection_id: string & $brand<"Uuid">, code: number, reason: string): boolean

connection_id

the id register_pending / add_connection returned

type Uuid

code

the close code

type number

reason

the close reason

type string
returns boolean

false when the connection is not registered — nothing was closed

mutates

  • this — removes the connection, aborts its registered controller, and closes its `WSContext`

close_connections

Close every connection target names, including a registration still pending admission (connection_identity_matches). Not to be confused with close_connection, which closes one connection by id.

A credential target — session, token, account — closes with WS_CLOSE_SESSION_REVOKED. A token close takes just that token's sockets, so revoking one token — or evicting one past the token cap — leaves the account's session sockets and other tokens' sockets open.

A role target — a revoked global grant of a role the upgrade gate admitted on — closes with WS_CLOSE_PERMISSION_REVOKED, not a revocation's code: the credential is still good. It over-closes an any-of gate: an endpoint gated on several roles admits an account holding any one of them, but this closes on the loss of any listed role, without checking whether the account still holds another. The client can reconnect when it still qualifies. The account's sockets on endpoints that did not list role, and on ungated ones, stay open.

type (target: ConnectionCloseTarget): number

target

returns number

the number of sockets closed

throws

  • the - first error a socket's `close` threw, after every match was attempted

mutates

  • this — removes matching connections, aborts their registered controllers, and closes their underlying `WSContext`

close_all_connections

Close every connection, pending or admitted, with WS_CLOSE_GOING_AWAY — the shutdown close AppServer.close runs. Not a revocation: the client reconnects under its ordinary backoff.

Each closed connection is removed and its controller aborted, as a revocation does, so its heartbeat timer stops and nothing more dispatches on it. A socket whose close throws does not stop the loop.

Leaves the registry closing, for good: every later registration is born closed (module doc, "Shutdown"). A second call closes nothing.

type (): number

returns number

the number of sockets closed

throws

  • the - first error a socket's `close` threw, after every connection was attempted

mutates

  • this — sets the registry closing, removes every connection, aborts its registered controller, and closes its underlying `WSContext` with `WS_CLOSE_GOING_AWAY`

is_closing

Whether close_all_connections has run — the registry is shutting down and every registration from now on is born closed (module doc, "Shutdown"). For telemetry and logs: register_action_ws reads it to say why an upgrade was refused.

type (): boolean

returns boolean

broadcast

Broadcast a notification to every admitted connection — a registration pending admission is not a connection yet. A server→client *request* has no single target here; that is request_connection.

A connection the message overflows is closed and not counted (module doc, "Outbound bound").

type (message: { [x: string]: unknown; jsonrpc: "2.0"; method: string; params?: { [x: string]: unknown; } | undefined; }): number

message

returns number

the number of connections that accepted the message

broadcast_filtered

Broadcast to admitted connections whose identity satisfies a predicate. A registration pending admission is skipped before the predicate runs.

Used by the broadcast API when a consumer supplies a subscription ACL hook (e.g. an event that only reaches the account that owns the resource it reports). When no ACL is needed, callers should prefer broadcast to skip the per-connection predicate overhead.

A connection the message overflows is closed and not counted (module doc, "Outbound bound").

type (message: { [x: string]: unknown; jsonrpc: "2.0"; id: string | number; result: JSONType; } | { [x: string]: unknown; jsonrpc: "2.0"; id: string | number | null; error: { [x: string]: unknown; code: -32700 | -32600 | -32601 | -32602 | -32603 | (number & $brand<...>); message: string; data?: unknown; }; } | { ...; }, predicate: (identity: Readonly<...>) => boolean): number

message

predicate

type (identity: Readonly<ConnectionIdentity>) => boolean
returns number

the number of connections that accepted the message

send_to_account

Send a message to every admitted socket bound to a specific account.

Targeted per-account fan-out for any flow where the delivery target is a single known account. Prefer this over broadcast_filtered when the filter is exactly "this account_id"; reach for broadcast_filtered when the ACL is an arbitrary predicate over ConnectionIdentity.

Mirrors an account close target on the send side: every connection for the account (session, bearer, and daemon-token) receives the message.

type (account_id: string & $brand<"Uuid">, message: { [x: string]: unknown; jsonrpc: "2.0"; id: string | number; result: JSONType; } | { [x: string]: unknown; jsonrpc: "2.0"; id: string | number | null; error: { ...; }; } | { ...; }): number

account_id

type Uuid

message

returns number

the number of connections that accepted the message

send_to

Send one serialized frame to a single admitted connection — the path register_action_ws sends every response, error frame, and socket-scoped notification through.

Never waits and never throws. A frame the connection's outbound budget does not fit closes the connection with WS_CLOSE_OUTBOUND_OVERFLOW before this returns (module doc, "Outbound bound").

type (connection_id: string & $brand<"Uuid">, frame: string): boolean

connection_id

the id register_pending / add_connection returned

type Uuid

frame

the frame's text, already serialized

type string
returns boolean

false when the frame was not accepted: the connection is not registered, is still pending admission, or was just closed for the overflow this frame caused

mutates

  • this — queues the frame on the connection's writer; on an overflow removes the connection, aborts its controller, and closes its `WSContext`

request_connection

Initiate a JSON-RPC request to a single connected client and await its reply — the server→client request/response direction (ActionPeer).

Sends {jsonrpc, method, params, id} to exactly the connection_id socket (never a broadcast) and registers a pending entry scoped to that connection. Resolves when the client's matching reply arrives (routed in via resolve_peer_response), the deadline elapses (timeout), the per-connection cap is hit (too_many_in_flight), or the socket closes (connection_gone). A connection still pending admission answers connection_gone like an unknown one. Never throws — every failure is a PeerRequestError.

Delegates correlation to #pending (id allocation, deadline, cap, drain); this method owns only the socket lookup + the send. Server-issued ids are s-prefixed so a malicious client echoing a non-s id (or an id it chose for its own request) matches nothing.

type (connection_id: string & $brand<"Uuid">, method: string, params: { [x: string]: unknown; } | undefined, options?: PeerRequestOptions | undefined): Promise<...>

connection_id

type Uuid

method

type string

params

type JsonrpcRequestParams | undefined

options?

optional
returns Promise<PeerRequestOutcome>

the client's success result, or a PeerRequestError

mutates

  • this — registers then clears an entry in `#pending`

resolve_peer_response

Route an inbound client reply to the matching pending server→client request on connection_id (delegates to #pending.resolve).

Returns false when no entry matches — an unsolicited, cross-connection, or already-settled reply — so the caller drops it. Per-connection scoping means a reply arriving on the wrong socket resolves nothing.

type (connection_id: string & $brand<"Uuid">, response: { [x: string]: unknown; jsonrpc: "2.0"; id: string | number; result: JSONType; } | { [x: string]: unknown; jsonrpc: "2.0"; id: string | number | null; error: { ...; }; }): boolean

connection_id

type Uuid

response

returns boolean

whether a pending request was resolved

mutates

  • this — clears the matched entry from `#pending`

is_registered

Whether a connection is still registered — and admitted: a registration pending admission answers false, like every other reader of the registry.

register_action_ws checks it before dispatching each inbound message, so a socket the server has closed — revoked, superseded by the cap, or refused at admission — dispatches nothing more while its close handshake is still in flight. The twin of the Rust ConnectionRegistry::is_registered.

type (connection_id: string & $brand<"Uuid">): boolean

connection_id

type Uuid
returns boolean

connection_count

Number of currently admitted WebSocket connections.

Read-only counter intended for telemetry, logging, and tests. A registration still pending admission is not a connection yet and is counted by pending_connection_count instead. Includes connections that have been closed by the peer but not yet removed by the WS adapter's onClose callback.

type (): number

returns number

pending_connection_count

Number of registrations awaiting admission — between register_pending and admit (or their refusal). Each lives for one credential re-check, so a count that stays high means upgrades are stalling there. For telemetry and tests.

type (): number

returns number

ConnectionRegistryOptions
#

actions/connection_registry.ts view source

ConnectionRegistryOptions import type {ConnectionRegistryOptions} from '@fuzdev/fuz_app/actions/connection_registry.js';

Options for ConnectionRegistry.

max_connections_per_account?

Max concurrent connections per account_id, across every credential type (session, API token, daemon token — none exempt). The policy is evict-oldest: a connection past the cap is always admitted, and the account's oldest connections are closed with WS_CLOSE_CONNECTION_LIMIT to make room. Refusing the new one instead would let half-open sockets lock a user out with their own dead connections, and would let a stolen credential fill the slots ahead of the real user — revocation is the fix for a stolen credential, and evict-oldest never stands in its way.

The cap is per registry: endpoints sharing one registry share one count per account. null disables it.

type number | null

default DEFAULT_MAX_CONNECTIONS_PER_ACCOUNT

outbound_queue_bytes?

Byte budget of the frames waiting to be written to one connection. Each waiting frame is charged its UTF-8 length plus OUTBOUND_FRAME_OVERHEAD_BYTES; a frame that does not fit closes the connection with WS_CLOSE_OUTBOUND_OVERFLOW, and a frame is always accepted when nothing is waiting, so one message larger than the whole budget is still deliverable (module doc, "Outbound bound"). Not derived from the inbound message cap.

type number

default DEFAULT_WS_OUTBOUND_QUEUE_BYTES

log?

Logger for cap evictions, outbound overflows and stalls, and failed sends or closes. An overflow or a stall logs one warn line — the connection, its account, and what was waiting. A cap eviction logs at info — the account, how many connections were closed, and the cap — so an operator can see a client that keeps meeting it. Unset falls back to a [ws] logger; null silences it. register_action_ws passes its own logger to the registry it creates.

type Logger | null

DEFAULT_MAX_CONNECTIONS_PER_ACCOUNT
#

actions/connection_registry.ts view source

50 import {DEFAULT_MAX_CONNECTIONS_PER_ACCOUNT} from '@fuzdev/fuz_app/actions/connection_registry.js';

Default per-account WebSocket connection cap, applied by ConnectionRegistry unless overridden via ConnectionRegistryOptions.max_connections_per_account.

Matches the account-wide ceiling the audit SSE cap implies (the default session cap, DEFAULT_MAX_SESSIONS, times AUDIT_LOG_SSE_MAX_PER_SCOPE streams per session) — generous enough that a user's tabs and tools never meet it; only an abusive or leaking client does. The twin of the Rust spine's DEFAULT_MAX_CONNECTIONS_PER_ACCOUNT.

DEFAULT_WS_OUTBOUND_QUEUE_BYTES
#

actions/connection_registry.ts view source

number import {DEFAULT_WS_OUTBOUND_QUEUE_BYTES} from '@fuzdev/fuz_app/actions/connection_registry.js';

Default byte budget of the frames waiting to be written to one connection, applied by ConnectionRegistry unless overridden via ConnectionRegistryOptions.outbound_queue_bytes.

4 MiB — four messages at DEFAULT_WS_MAX_MESSAGE_BYTES as it stands, and a value of its own: it does not move with the inbound message cap. A consumer whose server pushes more than this at a client in one burst — large frames, or long runs of them — sets its own. Same value as the Rust spine's DEFAULT_WS_OUTBOUND_QUEUE_BYTES.

Depends on
#

Imported by
#