actions

32 modules

  • actions/action_bridge.ts

    Bridge functions to derive RouteSpec and EventSpec from ActionSpec.

    Action specs define the contract (method, input/output, auth, side effects). Bridge functions produce transport-specific specs from them. HTTP-specific concerns (path, handler) come from options.

  • actions/action_codegen.ts

  • actions/action_dispatcher.ts

    ActionDispatcher — symmetric send/receive for JSON-RPC actions.

    Wraps a Transports registry and ActionEventEnvironment to provide bidirectional action dispatch via JSON-RPC 2.0.

  • actions/action_event_data.ts

    Action event data types — discriminated union for state machine data.

    39-variant discriminated union across 3 action kinds (request_response, remote_notification, local_call). Each variant narrows the data shape by kind, phase, and step.

  • actions/action_event_helpers.ts

    Action event helper functions — type guards, validators, and data creation.

  • actions/action_event_types.ts

    Action event type definitions — state machine constants and environment interface.

  • actions/action_event.ts

    ActionEvent — state machine for action lifecycle management.

    Manages an action through its phases (send_request → receive_response, etc.) and steps (initial → parsed → handling → handled/failed).

  • actions/action_registry.ts

    ActionRegistry — query and filter utility over ActionSpecUnion[].

    Vocabulary (see the docs/ directory):

    • *_handled_* — request_response specs the named side receives (so the named side owns the handler). Used by codegen to emit typed handler maps.
    • *_relevant_to_* — the loose "everything this side might encounter" set, used by the typed-Proxy method enums (FrontendActionMethod, BackendActionMethod).
    • broadcast_* — kind-narrow remote_notification set with the streams-target exclusion. Today this matches what the broadcast API exposes.
    • backend_initiated_* — forward-looking kind-agnostic version of the broadcast set. Same content today; will diverge when local_calls or backend request_response join the backend's typed surface.

    Cache discipline: spec_by_method (Map) and the internal streams-target set lazy-memoize because the Map is consulted per-RPC dispatch (actions/frontend_rpc_client.ts wires it into lookup_action_spec) and the streams set is rebuilt by two public getters. Array-returning getters recompute on each call so callers can mutate the result freely (.sort(), .push(injected) on a copy, etc.) without affecting the registry — codegen is a build-time path where the extra .filter / .map work is negligible.

  • actions/action_rpc.ts

    Single JSON-RPC 2.0 endpoint from action specs.

    create_rpc_endpoint produces RouteSpec[] (GET + POST on one path) with an internal dispatcher. Method name lives in the JSON-RPC envelope (POST body or GET query string), not the URL. Auth is checked per-action inside the dispatcher.

    Handler signature: (input: TInput, ctx: ActionContext) => TOutput where ActionContext provides auth identity, DB, and framework context.

  • actions/action_spec.ts

    Action spec types — the canonical source of truth for action contracts.

    Extracted from zzz's action system. Action specs define method, kind, auth, side effects, and input/output schemas. Bridge functions in actions/action_bridge.ts derive RouteSpec and EventSpec from them.

    @see actions/action_rpc.ts for the JSON-RPC dispatcher @see actions/register_action_ws.ts for the WebSocket dispatcher

  • actions/action_types.ts

    Shared type surface for the action system — Action (the composable {spec, handler?} tuple) and re-exports of the canonical ActionContext + ActionHandler shapes.

    Sits above actions/action_spec.ts (pure Zod schemas) and below the dispatchers (actions/register_action_ws.ts, actions/action_rpc.ts, actions/perform_action.ts). Extracted so the shared protocol actions (e.g. heartbeat_action) can name them without pulling in server-only modules.

    HTTP RPC and WebSocket dispatchers both call into perform_action, and both pass the same ActionContext to the handler. Consumers inject domain deps via factory closures the same way HTTP RPC factories do (see auth/standard_rpc_actions.ts).

  • actions/broadcast_api.ts

    Backend-initiated broadcast notification plumbing — generic across consumers.

    Builds a typed {method_name: (input) => Promise<void>} object from a list of action specs. Each call validates input against the spec, wraps it in a JSON-RPC notification, and either broadcasts to every connection or fans out with a per-connection ACL predicate.

    Counterpart to register_action_ws: that handles request-scoped dispatch (frontend-initiated), this handles broadcast (backend-initiated). Together they cover the two primitives fuz_app consumers share. Request-scoped streaming (completion_progress, zap_apply events) stays on ctx.notify inside a handler — it's socket-scoped, not broadcast.

    Extracted from zzz's backend_actions_api.ts to stop the pattern from drifting across consumers.

  • actions/cancel.ts

    Shared cancel action — a fuz_app protocol action validating the spec+handler tuple pattern on a notification-kind action.

    Semantics: the client sends `{jsonrpc, method: 'cancel', params: {request_id}}` to abort an in-flight request on the same socket. register_action_ws intercepts this notification and aborts the matching pending request's ctx.signal. Unknown ids are no-ops by design — races between response arrival and cancel delivery are safe without extra coordination.

    The handler field is an empty stub: cancel semantics are dispatcher-owned (the dispatcher has the {request_id → AbortController} map, not the handler). The handler exists for symmetry with other protocol actions like heartbeat_action; the dispatcher never calls it. Consumers spread cancel_action (or the protocol_actions bundle from actions/protocol.ts) into their server's actions array so spec_by_method knows about it (enabling input validation on incoming cancels) and so create_rpc_client codegen produces app.api.cancel() when desired — though FrontendWebsocketClient.request({signal}) sends the cancel on abort without needing the typed API.

    Wire format is snake_case cancel with {request_id}, not MCP's $/cancelRequest with {requestId} — fuz_app's WS transport isn't MCP, and adopting MCP's convention would leak protocol-specific framing into the base transport. If an MCP adapter is ever built, the translation layer at the adapter is the right seam.

  • actions/compile_action_registry.ts

    Shared registration loop for action-dispatcher endpoints.

    create_rpc_endpoint (HTTP RPC) and register_action_ws (WebSocket) both build a Map<method, RpcAction> from a list of action specs and gate the build on the same checks, in this order (named rather than numbered — "registry-time invariant N" elsewhere in the codebase refers to the RouteAuth cross-axis set in http/auth_shape.ts, a different list):

    • Auth-shape biconditional — per assert_route_auth_acting_biconditional in http/auth_shape.ts: `auth.actor !== 'none' ⟺ input declares acting?: ActingActor` (that set's invariant 2). Fires for every spec with non-null auth.
    • No auth.required_scope on an action — ActionSpec.auth is the same RouteAuth a route spec carries, so the field parses here, but only apply_route_specs mounts a guard from it and perform_action never reads it. Declaring it on an action would be a security control that silently does nothing, which is the exact shape token scoping was built to refuse. Rejected loudly instead; actions are governed by the per-method scope gate inside perform_action, which derives the same rpc:<method> capability from spec.method rather than trusting a hand-written one.
    • Rate-limit / account axis — rate_limit: 'account' | 'both' requires auth.account === 'required'; without an account on the request there is no key for the per-account bucket.
    • JSON-RPC §4.2 wire validity — request_response specs whose handler will reach the dispatch map must not use z.null() for input (the wire format forbids "params": null; use z.void() for parameterless methods), nor z.void() for output (a response always carries a result, which clients validate against spec.output; use z.null() for methods with nothing to return).
    • Duplicate method names — JSON-RPC keys on method, so every spec in the array must declare a unique method regardless of kind / handler presence.

    Pre-consolidation each dispatcher inlined these checks; the comment in actions/register_action_ws.ts literally said "mirrors the HTTP RPC registration check" but nothing kept them mirrored. Centralizing the loop closes the most likely future drift surface.

  • actions/connection_closer.ts

    Closing live connections — WebSocket and SSE — when the credential behind them is revoked.

    Why this exists. A WebSocket and an SSE stream are authorized once, at open. Per-message WebSocket dispatch (actions/perform_action.ts) reloads role grants, never the session or token, and a one-way stream re-reads nothing. So a revocation takes effect on an open connection only when something closes it. ConnectionCloser is that capability, and every revocation handler holds one: the session and token revokes, the revoke-alls, account delete and purge, /logout, /password, and the session and token caps for what they evict. A role revocation is the exception: it closes only the connections an endpoint gated on that role, and only through the audit listeners (create_ws_auth_guard, create_sse_auth_guard) — no handler holds a closer for it. The auth cleanup holds one too (AuthCleanupDeps.connection_closer): its session sweep closes the connections of each expired session it deletes, after that delete commits.

    Closing after commit

    A revocation runs inside a request transaction, and it must not close a connection until that transaction commits. queue_connection_close defers the close onto the request's post-commit queue (emit_after_commit): it runs after the commit and is dropped on rollback, so a revocation that never happened closes nothing.

    Two things rest on that order. A client told "revoked" rechecks its session straight away, and before the commit the row it probes is still there. And connection admission registers a connection and then re-reads its credential (revalidate_resolved_auth): a close that missed the registration must belong to a revocation the re-read can see, which holds only if the close came after the commit.

    Why the handlers close directly

    The audit listeners (create_ws_auth_guard, create_sse_auth_guard) close connections too, but only once the audit row is written, and that write is pool-routed and fail-open. The handler's own close is the one that runs whatever happens to the audit write, so the closer it holds must reach every transport whose connections outlive their credential. RealtimeCloser is that fan-out, and create_app_backend puts one on AppDeps.

    Twin of the Rust spine's SocketRevoker, SocketCloseTarget, queue_socket_close, and RealtimeRevoker.

  • actions/frontend_rpc_client.ts

    Frontend-only typed RPC client factory.

    Bundles the `ActionRegistry + ActionEventEnvironment + Transports + ActionDispatcher + create_rpc_client + create_throwing_api` boilerplate every consumer repeats. lookup_action_handler defaults to () => undefined (HTTP-only frontends rarely need handlers); pass options.lookup_action_handler to wire WS-pushed remote_notification dispatch or a receive_error / local_call hook.

    Returns both Proxy shapes from one factory call:

    • api — typed throwing Proxy. await api.foo(input) returns the unwrapped value or throws an Error carrying {code, data} from the JSON-RPC error. Use at hot-path call sites.
    • api_result — typed Result-shaped Proxy. await api_result.foo(input) returns Result<{value}, {error: JsonrpcErrorObject}>. Use when call sites want to inspect error.data.reason without try/catch — and anywhere allocating an Error per {ok: false} is wasteful (e.g. reconnect-storm service_unavailable paths). Result is the protocol primitive; the throwing form is a wrapper over it. Both share the same underlying transport — pick per call site, no construction cost.

    Generic TApi is the consumer's typed Proxy interface. The `as unknown as TApi` double cast happens inside the helper so call sites get a typed return value without the cast hostility. api's type is ThrowingApi<TApi> — the mapped type strips the Result wrapper.

    const {api, api_result} = create_frontend_rpc_client<MyActionsApi>({ specs: all_specs, }); // hot path: await api.account_verify() // rare branch: const r = await api_result.account_verify(); if (!r.ok) { … }

    Returns the underlying peer and environment alongside the two api shapes so advanced consumers (zzz-style frontends needing extra transports / WS notification handlers / action-history wiring) can extend without recreating the bundle.

    local_call specs in specs no-op unless lookup_action_handler resolves a handler for the 'execute' phase. Frontend-side local_call is uncommon; the factory targets wire-dispatched actions by default.

  • actions/heartbeat.ts

    Shared heartbeat action — a fuz_app protocol action carrying both a spec and a handler in one tuple. Consumers spread heartbeat_action (or the protocol_actions bundle from actions/protocol.ts) into the server's actions array so disconnect detection works identically across every repo without per-consumer ping plumbing.

    The client's activity-aware heartbeat timer (in FrontendWebsocketClient) issues a heartbeat request whenever the connection has been idle for its configured interval; server-side the dispatcher tracks receive time, so incoming heartbeats keep the socket alive without any handler-level state.

    Parameterless: the input is z.void(), so absent, null, and {} params are all the no-arg call (to_void_params) and anything else is refused — the same shapes the Rust spine's heartbeat admits via require_void_params. The output is an empty object. `{client_ts, server_ts}` fields can be added later if clock-skew telemetry ever matters — the Action container is open for additions without churning consumer call sites.

  • actions/peer_ping.ts

    Shared peer/ping action — the protocol action that exercises the server→client request/response direction of ActionPeer.

    peer/ping is initiator: 'both': the client→server direction overlaps with heartbeat, but the new direction this action drives is the reverse — a connected client invokes peer/ping over its socket, and the handler turns around and *initiates* a peer/ping request back to that same socket (ctx.request_client), awaits the client's echo, validates it against PingResponse, and returns the validated shape. So one client RPC drives the whole round-trip and every outcome surfaces as that RPC's wire response.

    Public auth. A liveness echo is non-sensitive; the WebSocket upgrade itself still authenticates, so only admitted sockets reach the handler.

    No return transport (HTTP RPC). Over HTTP there is no socket to ping — ctx.request_client is absent — so the handler refuses with peer_no_transport rather than method_not_found. That requires the action to be mounted on the HTTP RPC endpoint as well as the WS endpoint (it is, via the spine's full mount); the manifest excludes it as a protocol action so the cross-impl diff stays apples-to-apples.

    The data.reason discriminators and the PingResponse shape are the cross-impl wire contract — twins of the Rust spine's REASON_PEER_* constants and PingResponse (parity by test, not codegen).

    Frontend-safe. Like heartbeat.ts / cancel.ts, this module names the dispatcher types (ActionContext, RpcAction) type-only and builds peer_ping_action as a plain literal — it never imports the runtime action_rpc.ts (which would drag the HTTP dispatch core + Hono into any frontend that pulls protocol_action_specs). The frontend responder (peer_ping_responder) lives here too so a real frontend can answer the server's probe.

  • actions/peer_request.ts

    Server→client peer requests (ActionPeer) — the request/response direction a backend initiates to a connected WebSocket client and awaits a typed reply.

    PendingPeerRequests is the correlation registry: it owns the in-flight map (nested connection_id → request id — the nesting is the per-connection isolation guarantee), id allocation (s{n}-namespaced so a client's own request ids can't collide), the per-request deadline, and the per-connection in-flight cap. It deliberately does not touch the socket — the caller (BackendWebsocketTransport.request_connection) does the send and threads the reply back via resolve. This mirrors the Rust fuz_realtime::peer split (the PendingPeerRequests registry vs the transport that composes it), and keeps the registry pure data so it unit-tests without a live socket.

  • actions/perform_action.ts

    Transport-agnostic dispatch core shared by HTTP RPC and WebSocket action dispatchers.

    perform_action runs the post-parse pipeline that every action-spec handler must traverse:

    1. Pre-authorization auth (401) — short-circuits unauthenticated callers on 'required' axes.
    2. Authorization phase — when auth.actor !== 'none' (or auth.account !== 'none' && actor === 'none'), resolves the actor via apply_authorization_phase against the supplied account_id plus the request's acting selector, read off the raw params by read_acting. Failures fold into a JSON-RPC error envelope. The test-harness escape hatch lives in the caller — pass preset.request_context to skip the live phase and use a pre-baked context instead.
    3. Post-authorization auth (403) — gates auth.credential_types, the credential's token_scope, and auth.roles against the resolved context.
    4. Rate limit (429) — per-action IP / account throttling, throttle- requests semantics (every invocation records, regardless of outcome). Ahead of validation so the budget is charged for malformed params too — a flood of garbage bodies must cost the sender its quota, not slip past the limiter on a cheap 400 — and so a throttled caller never pays for schema work. The account key is the credential's own account (request_context.account.id), settled by authentication, so the gate sits behind the authority gates by choice rather than necessity: a caller those gates refuse is answered 403, not 429.
    5. Validate params (400) — spec.input.safeParse(raw_params) with z.void() / ?? {} rules (to_input_params). Runs after the authority gates: a 400 describing the action's input shape to a caller those gates refuse would confirm the method exists and describe how to call it, to a channel that should have learned nothing. Coarse authority facts before fine ones, and no shape disclosure before authority is settled — the same argument that puts the credential gate ahead of the scope gate. The cost: a malformed request from an *authenticated* caller pays for the authorization phase's actor + role_grant reads before being rejected, rather than being turned away by validation first. Not a new DoS lever — the same caller reaches that work anyway by sending well-formed params — but there is no cheap-rejection path for buggy clients.
    6. Dispatch + DEV-only output validation + error normalization — spec.side_effects picks transaction (deps.db.transaction) vs pool. Handler throws roll back the transaction; the catch sits outside the transaction boundary. Handler outputs are validated against spec.output under DEV (logs an error on mismatch, never throws, never mutates the result).

    The function is pure data — it never touches a Hono context, so HTTP RPC, REST bridge (when on the action surface), and WS dispatch all call into it the same way and bind the discriminated PerformActionResult to their wire shape.

  • actions/protocol.ts

    Canonical bundles of fuz_app's protocol actions — heartbeat, cancel, and peer/ping. Spread these into consumer registrations on both sides of the wire so the registries stay symmetric without per-consumer plumbing.

    Protocol actions are wire-protocol concerns (liveness, abort) shipped by fuz_app, not consumer domain logic. The split is intentional: the server needs {spec, handler} tuples to drive dispatch; the frontend ActionRegistry only stores specs. The codegen include_protocol_actions: false default (in actions/action_codegen.ts) is the third leg of this contract — protocol actions are excluded from generated typed surfaces because consumers spread them in at registration time.

    Adding a future protocol action (e.g. clock-skew probe, reconnect-resume token) means appending to these arrays in one place; no consumer migration required.

  • actions/register_action_ws.ts

    WebSocket JSON-RPC dispatch — the low-level WS transport binding.

    Consumers declare WS endpoints on create_app_server's ws_endpoints, which mounts each through register_ws_endpoint (actions/register_ws_endpoint.ts) — this function wrapped in the standard upgrade stack (origin check + auth + token scope + actor resolution + optional role). This module stays exported as the lower-level entry point for tests that drive the dispatcher directly via create_ws_test_harness.

    Symmetric to create_rpc_endpoint (from actions/action_rpc.ts): both transports parse their wire envelope, then call the shared perform_action core (actions/perform_action.ts) for the post-parse pipeline. WS-specific concerns — connection lifecycle, heartbeat, cancel-notification interception, socket-scoped notify — stay in this module; everything else (auth gates, input validation, authorization phase, rate limiting, transactional dispatch, DEV output validation, thrown-error normalization) is shared.

    Auth expectations

    The consumer is responsible for rejecting unauthenticated upgrades *before* routing to this handler (fuz_app's require_auth middleware, or register_ws_endpoint which wires it for you). Per-action auth runs inside perform_action on every message via the same gates HTTP RPC uses.

    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 (BackendWebsocketTransport.register_pending — closeable by every revocation, inert otherwise), re-read the credential (revalidate_resolved_auth), then BackendWebsocketTransport.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.

    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 (close_sockets_for_role 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, WS_CLOSE_PERMISSION_REVOKED (4006) for a lost role, or WS_CLOSE_INTERNAL_ERROR (1011) when either 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 transport's close_all_sockets) 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 (BackendWebsocketTransport 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 transport and aborts the socket's signal *at close time*, and onMessage drops any frame on a socket that has ended. A connection the transport no longer holds though nothing closed its socket (a bare remove_connection) is ended on its next frame (BackendWebsocketTransport.is_registered), with WS_CLOSE_INTERNAL_ERROR — it must not linger as a socket that reads everything and answers nothing.

  • actions/register_ws_endpoint.ts

    Composed WebSocket endpoint registration — the primitive create_app_server mounts each ws_endpoints entry with. An app assembled by create_app_server declares its endpoints there rather than calling this, and gets the shared action rate limiters, the origin default, the surface entry, and the audit guard with them.

    Wraps the standard upgrade stack:

    1. verify_request_source(allowed_origins) — reject disallowed origins before the upgrade handshake runs.
    2. require_auth — reject unauthenticated upgrades.
    3. require_token_scope('surface:ws_upgrade') — rule 3: a narrowed token cannot hold a socket. Mounted here, ahead of the role guard, so a narrowed token is told about its scope rather than about a role it also lacks. register_action_ws repeats the check on its own route chain, which is what covers consumers mounting it directly; the earlier answer wins when both are wired.
    4. Authorization phase — resolve the acting actor against the authenticated account plus an optional ?acting=<uuid> query string, and build the RequestContext that per-message dispatch reads. Multi-actor accounts must supply ?acting to pick a persona; single-actor accounts work without it.
    5. Optional require_role(required_roles) — for endpoints gated to a non-empty any-of set of roles. register_action_ws records the roles on each registration and re-reads them at admission, so the gate holds for the socket's life (a revoked role closes it with WS_CLOSE_PERMISSION_REVOKED).

    Then delegates to register_action_ws for per-message JSON-RPC dispatch.

  • actions/rpc_client.ts

    Typed RPC client — creates a Proxy-based API from action specs.

    Two tiers of usage:

    • Tier 1 (simple, for tx): transport send/receive, Result return. No environment.
    • Tier 2 (full, for zzz): ActionEvent lifecycle with environment.

    Pass the consumer's generated ActionsApi interface as <TApi> to flow full type safety through without an explicit cast at the call site.

  • actions/socket.svelte.ts

    Frontend WebSocket client — portable, Svelte-reactive, implements WebsocketConnection.

    Plain class with $state runes (no Cell inheritance, no app coupling). Drop into any SvelteKit frontend as the underlying connection for FrontendWebsocketTransport. Handles auto-reconnect with exponential backoff, respects WS_CLOSE_SESSION_REVOKED (no reconnect loop after the server revokes auth), WS_CLOSE_CONNECTION_LIMIT (no reconnect after the server closes this socket to admit a newer one — closed until the user acts), and WS_CLOSE_PERMISSION_REVOKED (no reconnect after the account lost the role the endpoint requires — closed until the user acts), exposes reactive status for UI indicators, and ships three correctness primitives default-on:

    • FrontendWebsocketClient.request — promise-based JSON-RPC with auto-assigned ids and a pending-id map. Intercepts responses on the message path so request/response correlation is transport-level rather than re-invented per consumer.
    • Durable queue — request() calls made while disconnected buffer up to DEFAULT_QUEUE_MAX_SIZE requests and flush on reopen. Overflow rejects with queue_overflow. Raw FrontendWebsocketClient.send is drop-on-disconnect (fire-and-forget notifications want that).
    • Activity-aware heartbeat — a shared heartbeat request fires when either direction has been idle for the interval; receive-silence past the receive timeout (DEFAULT_HEARTBEAT_RECEIVE_TIMEOUT, never less than HEARTBEAT_RECEIVE_TIMEOUT_MIN_FACTOR × the interval) closes with WS_CLOSE_CLIENT_HEARTBEAT_TIMEOUT and lets auto-reconnect pick back up.
  • actions/transports_http.ts

    HTTP transport — sends JSON-RPC messages via HTTP POST (or GET for reads).

  • actions/transports_ws_auth_guard.ts

    WebSocket auth guard — the audit-event listener that closes live connections when a row reports a revocation.

    Why this exists. register_action_ws captures account_id and credential_type at upgrade time and reuses them for every message. perform_action's per-message authorization phase reloads role_grants from the DB, but session and token VALIDITY are not re-queried per message — they are re-read once, at admission (revalidate_resolved_auth) — a trade-off that keeps chatty WS connections fast. The cost: nothing in the dispatch path notices when a session is revoked or a token is rotated, so a revocation reaches an open connection only when something closes it.

    Two things do. The revocation handlers close directly, after their transaction commits (queue_connection_close in actions/connection_closer.ts) — that close does not depend on the audit write. This guard repeats it when the audit row is announced, which covers a revocation emitted by code that holds no closer. Closing is idempotent, so the overlap is a repeat, not a conflict. A success row is announced only after its request's transaction commits (auth/audit_emitter.ts), so the guard's close is post-commit too.

    Which rows close

    The guard dispatches on the RevocationScope each audit event declares in audit_event_revocation_scopes (auth/audit_log_schema.ts), the table the SSE guard (realtime/sse_auth_guard.ts) reads too — one declaration, so the two cannot drift into closing a revoked token's socket while leaving its stream open.

    role closes the target account's sockets whose endpoint gated the upgrade on the revoked role (close_sockets_for_role), with WS_CLOSE_PERMISSION_REVOKED, when the revoked grant was global (to_global_role_revocation — a role gate admits on global grants only). perform_action re-authorizes every *request* against its own action, but the role gate is the only check on what the socket *receives*: broadcasts and notifications reach every admitted connection of the endpoint with no per-recipient filter, so a socket left open past the revocation would keep receiving what the gate exists to withhold. The role is the only revocation no handler closes for, so this guard is its one closer.

    For standard WS endpoints mounted via AppServerOptions.ws_endpoints, create_app_server registers the guard automatically per WsEndpointSpec.auth_guard. For custom wiring, register the handler inside the consumer's audit_factory body (or via audit.add_listener(...) post-assembly).

    Twin of the Rust spine's register_socket_revocation_listeners.

  • actions/transports_ws_backend.ts

    Backend WebSocket transport — 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. BackendWebsocketTransport.register_pending inserts the entry un-admitted. It is closeable from that instant — close_sockets_for_* match 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 (get_pending_connection_count does).
    2. The caller re-checks the credential against the database (revalidate_resolved_auth in auth/resolved_auth.ts).
    3. BackendWebsocketTransport.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 (send, 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 transport 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 transport 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 transport 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_sockets — the close AppServer.close runs — closes every connection, pending or admitted, with WS_CLOSE_GOING_AWAY, and leaves the transport 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 transport hosts no further connections.

  • actions/transports_ws.ts

    Frontend WebSocket transport — thin adapter over WebsocketRpcConnection.

    Delegates request/response correlation, the durable queue, the heartbeat, and AbortSignal-driven cancel to the underlying connection (the canonical implementation is FrontendWebsocketClient). The transport's own job is the Transport contract: route inbound server-pushed messages into peer.receive, send the resulting response back over the socket (the server→client ActionPeer receive loop), and translate the connection's Promise<R>/ThrownJsonrpcError shape into JsonrpcResponseOrError envelopes. No parallel pending-request map.

    Inbound peer/ping requests are answered directly via the built-in peer_ping_responder — a protocol-liveness concern that works with zero consumer wiring, the same way the server runs the heartbeat echo.

  • actions/transports.ts

    Transport abstraction for action communication.

    Provides the Transport interface and Transports registry for managing multiple transports with fallback behavior.

  • actions/ws_endpoint_spec.ts

    WsEndpointSpec — the canonical WebSocket endpoint declaration consumed by create_app_server's ws_endpoints option (mirror of RpcEndpointSpec for HTTP RPC).

    Also resolve_ws_endpoints, which fills in the server's allowed_origins.

    Lives in its own module so both server/app_server.ts (which mounts endpoints from these specs) and http/surface.ts (which threads the resolved spec list into surface generation) can import it without a cycle between the two.

  • actions/ws_outbound_writer.ts

    The outbound side of one server WebSocket connection — a byte-budgeted queue of frames, the pump that hands them to the socket, and the deadlines on what the socket has not yet written.

    Every frame the server sends a connection goes through WsOutboundWriter.push: a response, an error frame, a notification, a broadcast, a server-initiated request. BackendWebsocketTransport owns one writer per connection and is its only caller.

    What waits

    A frame pushed at an idle connection — nothing waiting, nothing unwritten in the runtime's socket buffer, and no other frame pushed in the same synchronous run — goes straight to the socket. Every other frame waits in the writer's queue: the rest of a burst pushed in one synchronous run (a loop with no await in it gives the pump no turn), and anything pushed while the socket's buffer is at or over WS_WRITE_HIGH_WATER_BYTES. The pump hands waiting frames over in order — on the microtask after a push, and whenever the buffer has drained below the high-water mark.

    What waits is held to the connection's budget (OutboundBudget): each waiting frame is charged its UTF-8 length plus OUTBOUND_FRAME_OVERHEAD_BYTES, and a frame is always admitted when nothing is waiting. A frame handed to the socket is no longer charged — the budget bounds what waits, and the write deadline bounds what is in flight.

    A frame that does not fit is reported as an overflow and not queued; the owner closes the connection for it (WS_CLOSE_OUTBOUND_OVERFLOW).

    The queue is a linked list: a frame handed to the socket is unlinked, so the writer holds the frames that wait and no others, however long the queue goes without emptying.

    Closing

    close sends the close frame and takes nothing more. With 'flush' it first runs the pump's rule once: waiting frames are handed to the socket while its buffer is below WS_WRITE_HIGH_WATER_BYTES. Whatever still waits is discarded, with either backlog choice. So a frame sent to an otherwise idle connection just before a close reaches the client ahead of the close frame, and a frame queued behind a backed-up socket does not. After a close a socket whose buffer the writer can read ("Runtimes") holds less than the high-water mark plus one frame, and the close frame.

    Deadlines

    • A frame: bytes the socket's buffer still holds WS_WRITE_STALL_TIMEOUT after they were handed to it mean the client is not reading. The writer discards what waits, reports the stall, and cuts the socket with no close frame (the client sees an abnormal close, 1006).
    • A close frame: a close still unwritten WS_CLOSE_WRITE_TIMEOUT after close cuts the socket the same way, so a closed connection whose client stopped reading does not hold its buffered frames for the runtime's own close timeout — where the runtime can cut a socket ("Runtimes").

    Runtimes

    The buffer is read through the socket Hono's WSContext wraps (raw): bufferedAmount on Node's ws and on Deno's standard WebSocket, getBufferedAmount() on Bun's ServerWebSocket. A socket that exposes neither reads as never backed up: its frames are still budgeted while they wait for the pump, but nothing detects a stall. The cut is terminate(), which Node's ws and Bun's ServerWebSocket have. Deno's standard WebSocket has no way to drop a socket without the closing handshake, so there a stalled connection is closed with WS_CLOSE_OUTBOUND_OVERFLOW instead — a frame the client reads only if it starts reading again — and the close deadline cuts nothing: a closed socket whose client is not reading stays in the runtime, holding what its buffer held at the close ("Closing"), until the runtime or the client ends it.