actions/ws_socket_session.ts

The per-socket half of the WebSocket endpoint — one upgraded socket, from admission to close.

register_action_ws (actions/register_action_ws.ts) mounts the upgrade route; per upgrade it reads the credential off the Hono context into a WsSocketUpgrade and hands that, with the endpoint-level WsSocketEndpoint, to create_ws_socket_events, whose WSEvents the runtime adapter drives. Nothing here reads the Hono context or imports the mount, so a socket is reachable with a fake WSContext and no app.

The WS-specific concerns — connection lifecycle, heartbeat, cancel-notification interception, socket-scoped notify — live here; the relation to create_rpc_endpoint and the shared perform_action core is in the register_action_ws module doc.

Admission: the revocation race

The auth middleware resolves the credential before the handshake, and the socket exists only once onOpen fires. Every revocation (session revoke, logout, password change, token revoke, account delete) closes the sockets *registered* under what it revoked, so one landing in that gap would close nothing, and the socket would then be admitted on the dead credential and keep it: per-message dispatch re-authorizes the *actor*, never the session or token — and its admission could evict a live socket of the same account past the cap.

onOpen closes the gap in three steps: register the connection pending (ConnectionRegistry.register_pending — closeable by every revocation, inert otherwise), re-read the credential, the acting actor, and the role (recheck_admission, which the audit stream shares), then ConnectionRegistry.admit. A revocation whose close ran after the registration found the pending entry, so admit refuses; one that committed before the re-read is seen by it. The cap is enforced only by admit, so a refused upgrade evicts nothing, and pending registrations never count toward it.

That argument needs every revocation to close only after its commit — otherwise a close could miss the registration while the re-read still sees the credential alive. Every one does: the revocation handlers queue their close behind the commit (queue_connection_close), and the audit emitter announces a success row to the listeners behind it too. So no interleaving admits a revoked credential; docs/security.md §Connection Admission says what that rests on.

The acting actor the upgrade resolved is re-read beside the credential on every endpoint, gated or not: an actor soft-deleted under its live account keeps the account's sessions and tokens and its own grant rows, so neither the credential nor the role re-read would refuse it. A tombstoned acting actor is refused like a dead credential.

An endpoint gated on required_roles re-reads them beside the credential: its upgrade passed the role gate before the socket registered, so a role revocation landing in between would close nothing (a role close target matches registrations), and the socket would be admitted without the role for its lifetime. The re-read reads the acting actor's grants afresh and admits on a global grant of any listed role, as the gate did.

A refused upgrade is closed with WS_CLOSE_SESSION_REVOKED (4001, the reason a revoked socket gets) for a dead credential or a deleted acting actor, WS_CLOSE_PERMISSION_REVOKED (4006) for a lost role, or WS_CLOSE_INTERNAL_ERROR (1011) when a re-read itself failed — fail closed, never admitted unchecked. The handshake has already answered 101, so a refusal can only be a close frame.

Once the server is shutting down (AppServer.close ran the registry's close_all_connections) a refusal is WS_CLOSE_GOING_AWAY instead, so the client reconnects rather than treating its credential as revoked: an upgrade pending at the close-all is closed by it — and its abort makes the refusal that follows the re-read send nothing more, even when the database closed under that re-read — and one that registers after it is born closed and skips the re-read (ConnectionRegistry module doc, "Shutdown").

Frames that arrive before admission are queued, not dispatched: nothing runs on a credential that has not been re-read. They dispatch in arrival order once the socket is admitted and on_socket_open has completed, and are dropped unread on a refusal. The queue holds at most MAX_PRE_ADMISSION_FRAMES frames and PRE_ADMISSION_QUEUE_BYTES_FACTOR × max_message_bytes bytes; past either the socket is closed.

Credential deadline

Expiry writes nothing a revocation listener could hear, so the socket watches its own credential's expiry (ResolvedAuth.credential_deadline, the earlier of a session's cookie and row, an API token's row, none for a daemon token or an eternal token). From admission, a timer closes it at the deadline with WS_CLOSE_SESSION_REVOKED (4001) and WS_CLOSE_CREDENTIAL_EXPIRED_REASON. The wait is a chain of timers of at most an hour (schedule_credential_deadline), so a session's 30 days — past setTimeout's ceiling — does not fire at once, and each wake-up checks the wall clock too, so a host suspend does not extend the deadline.

A server-closed socket dispatches nothing

A runtime adapter can keep delivering inbound frames after the server calls ws.close(…), until the close handshake completes (@hono/node-ws does — up to the ws close timeout when the client withholds its close frame). So every server-side close — revocation, cap eviction, heartbeat timeout, an oversized message, a refused admission — removes the connection from the registry and aborts the socket's signal *at close time*, and onMessage drops any frame on a socket that has ended. A connection the registry no longer holds though nothing closed its socket (a bare remove_connection) is ended on its next frame (ConnectionRegistry.is_registered), with WS_CLOSE_INTERNAL_ERROR — it must not linger as a socket that reads everything and answers nothing.

view source

Declarations
#

10 declarations

create_ws_socket_events
#

actions/ws_socket_session.ts view source

internal

(endpoint: WsSocketEndpoint, upgrade: WsSocketUpgrade): WSEvents<unknown> import {create_ws_socket_events} from '@fuzdev/fuz_app/actions/ws_socket_session.js';

Build the Hono WSEvents for one upgraded socket: admission, the receive watchdog, the credential deadline, frame dispatch, and close (module doc).

endpoint

the endpoint-level values, shared by all of its sockets

upgrade

what the upgrade request captured for this socket

returns

WSEvents<unknown>

the adapter's onOpen / onMessage / onClose for the socket

ERROR_DUPLICATE_REQUEST_ID
#

actions/ws_socket_session.ts view source

"duplicate_request_id" import {ERROR_DUPLICATE_REQUEST_ID} from '@fuzdev/fuz_app/actions/ws_socket_session.js';

The data.reason of the invalid_request a WebSocket request gets when its id names a request still running on the same socket. The duplicate is not dispatched; the running request keeps its id, so its reply, and a cancel naming that id, stay its own. An id is free again once its request has been answered.

MAX_INBOUND_DISPATCHES_PER_CONNECTION
#

actions/ws_socket_session.ts view source

128 import {MAX_INBOUND_DISPATCHES_PER_CONNECTION} from '@fuzdev/fuz_app/actions/ws_socket_session.js';

Max requests one socket may have dispatching at once. A request that arrives past it is answered queue_overflow with its id (WS_INBOUND_DISPATCH_OVERFLOW_MESSAGE) and not dispatched; the socket stays open, and the requests already running keep answering.

A runtime adapter can hand frames over without awaiting the previous one's dispatch (@hono/node-ws does not await onMessage), so without a ceiling one socket could hold any number of handlers — and their pooled database work — at once. Shedding rather than queueing keeps the socket reading, so a reply to a server-initiated request still reaches the handler awaiting it. Generous, so ordinary pipelining never meets it. Same value as the Rust spine's MAX_INBOUND_DISPATCHES_PER_CONNECTION.

Counted are the requests that passed envelope validation and are not yet answered — the entries of the socket's cancellation map. Notifications, replies to server-initiated requests, and refused frames never count.

MAX_PRE_ADMISSION_FRAMES
#

actions/ws_socket_session.ts view source

256 import {MAX_PRE_ADMISSION_FRAMES} from '@fuzdev/fuz_app/actions/ws_socket_session.js';

Max inbound frames one socket may have queued while it is still opening — between the handshake and the end of the credential re-check plus on_socket_open. One more closes the socket with WS_CLOSE_POLICY_VIOLATION.

Frames sent before the socket is open wait rather than dispatch (nothing runs on a credential that has not been re-read), and a runtime adapter hands them over with no backpressure, so the wait needs a bound. This is the count half; PRE_ADMISSION_QUEUE_BYTES_FACTOR is the byte half.

Sized so a client flushing its durable queue on reopen never meets it at the default DEFAULT_QUEUE_MAX_SIZE (actions/socket.svelte.ts). A consumer that raises the client's queue above this cap makes a full flush overflow it: the socket closes with WS_CLOSE_POLICY_VIOLATION, the flushed requests fail, and the client reconnects.

PRE_ADMISSION_QUEUE_BYTES_FACTOR
#

actions/ws_socket_session.ts view source

4 import {PRE_ADMISSION_QUEUE_BYTES_FACTOR} from '@fuzdev/fuz_app/actions/ws_socket_session.js';

The byte budget of one socket's pre-admission queue, as a multiple of the endpoint's max_message_bytes: the frames queued while the socket is still opening may total at most this many maximum-size messages. The frame that would pass it closes the socket with WS_CLOSE_POLICY_VIOLATION.

The count cap alone (MAX_PRE_ADMISSION_FRAMES) would let each pending socket hold that many full-size messages, and pending sockets are not capped per account — a registration that is never admitted must not be able to pin that much memory. Small requests, which is what a client flushes on reopen, meet the count cap long before this one.

A client whose queued requests total more than this budget and flushes them all on reopen overflows it: the socket closes, the flushed requests fail, and the client reconnects.

SocketCloseContext
#

actions/ws_socket_session.ts view source

SocketCloseContext import type {SocketCloseContext} from '@fuzdev/fuz_app/actions/ws_socket_session.js';

Context passed to the on_socket_close hook.

Fires after the registry has removed the connection — connection_id and identity are the values captured at open, so consumer cleanup reads them from here, not from the registry. Fires for both client-initiated closes (Hono onClose) and server-initiated closes — a revocation, the per-account connection cap's eviction, and a heartbeat timeout all call ws.close(), which triggers Hono's onClose. Fires only for a socket that was admitted: one refused at admission never ran on_socket_open, so it has no close hook either.

ws

The WebSocket context the close event carried. Not necessarily the object on_socket_open received — an adapter may build one per event — so key per-socket state on connection_id.

type WSContext<unknown>

connection_id

Connection id captured at open time.

type Uuid

identity

Auth identity captured at open time — the registry no longer holds it.

type Readonly<ConnectionIdentity>

SocketOpenContext
#

actions/ws_socket_session.ts view source

SocketOpenContext import type {SocketOpenContext} from '@fuzdev/fuz_app/actions/ws_socket_session.js';

Context passed to the on_socket_open hook.

Fires once the connection is admitted — registered in the registry and its credential re-checked (so connection_id is valid, and broadcasts reach it) — and before any client message dispatches. A socket refused at admission never reaches it. Consumers use this to bootstrap per-socket domain state — e.g. spawning a per-account unit and pushing an initial state snapshot.

ws

The raw WebSocket context — exposed for edge cases; prefer notify for sends. A frame sent on it directly bypasses the connection's outbound budget and its ordering with queued frames.

type WSContext<unknown>

connection_id

Connection id assigned by ConnectionRegistry.register_pending.

type Uuid

identity

Auth identity registered for this connection. The registry holds its own copy, so this record is not what close targets match on.

type Readonly<ConnectionIdentity>

notify

Send a JSON-RPC notification to just this socket. Mirrors ctx.notify on per-message handler contexts — same socket-scoped semantics.

type (method: string, params: unknown): void

method

type string

params

type unknown
returns void

signal

Fires when this socket ends — the client closed it, or the server did (revocation, cap eviction, heartbeat timeout), at the moment the server closes rather than when the close handshake lands. Threaded through to every handler's ctx.signal.

type AbortSignal

WS_INBOUND_DISPATCH_OVERFLOW_MESSAGE
#

WsSocketEndpoint
#

actions/ws_socket_session.ts view source

internal

WsSocketEndpoint import type {WsSocketEndpoint} from '@fuzdev/fuz_app/actions/ws_socket_session.js';

The endpoint-level values every socket of an endpoint reads — derived once by register_action_ws, shared by all of its sockets.

path

Mount path, reported in on_effect_error contexts.

type string

db

Pool-level DB: the admission re-read and per-message dispatch read through it.

type Db

log

Logger for the endpoint's sockets.

type Logger

registry

The registry the endpoint's connections register with.

type ConnectionRegistry

action_map

Per-method dispatch lookup — request_response specs with a handler.

type ReadonlyMap<string, RpcAction>

receive_timeout

Receive silence (ms) past which a socket is closed; null keeps silent sockets open.

type number | null

receive_tick_interval

The receive check's period (ms); null exactly when receive_timeout is.

type number | null

max_message_bytes

Cap on one inbound message, in bytes.

type number

pre_admission_queue_max_bytes

Byte budget of one socket's pre-admission queue.

type number

on_socket_open

Hook called once per socket after admission; undefined for none.

type ((ctx: SocketOpenContext) => void | Promise<void>) | undefined

on_socket_close

Hook called once per admitted socket after its connection is removed; undefined for none.

type ((ctx: SocketCloseContext) => void | Promise<void>) | undefined

action_ip_rate_limiter

Per-IP action rate limiter; null turns the IP check off.

type RateLimiter | null

action_account_rate_limiter

Per-account action rate limiter; null turns the account check off.

type RateLimiter | null

on_effect_error

Called when an eager pending effect of a frame's action rejects; undefined for none.

type ((error: unknown, context: EffectErrorContext) => void) | undefined

required_roles

Roles the endpoint's upgrade is gated on; empty for an ungated endpoint.

type readonly string[]

WsSocketUpgrade
#

actions/ws_socket_session.ts view source

internal

WsSocketUpgrade import type {WsSocketUpgrade} from '@fuzdev/fuz_app/actions/ws_socket_session.js';

What register_action_ws captures from the upgrade request for one socket. The socket reads these for its whole life; the Hono context itself is not kept.

client_ip

The client IP, keyed by the per-IP action rate limiter.

type string

upgrade_preset

The test-context preset, which skips the admission re-read and the live authorization phase.

type { request_context: RequestContext | null; } | undefined

resolved

The credential the auth middleware resolved — re-read at admission, passed to every message's dispatch, and the source of the connection's identity.

type ResolvedAuth

upgrade_context

The request context the upgrade's authorization phase built, on the credential's account.

type RequestContext

Depends on
#

Imported by
#