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.