jfastframework
Docs Español PyPI GitHub

WebSockets

A message is addressed to a person, not to a socket. Which worker happens to hold that person's connection is not something your code should know.

[plugins]
enabled = ["observability", "cache", "auth", "websocket"]

[plugin.websocket]
channel = "websocket.fanout"
send_buffer = 32
heartbeat_seconds = 20
idle_timeout_seconds = 60

Declare an endpoint:

from jfastframework.plugins.builtin.websocket import Connection, socket

@socket("/ws/notifications", scopes=("notifications:read",))
async def notifications(conn: Connection) -> None:
    await conn.join("orders")
    await conn.listen()

Send to a person, from any route, on any worker:

hub = ctx.require("websockets")
await hub.send("user-42", {"kind": "invoice.paid", "id": 7})
await hub.send_to_room("orders", {"kind": "order.moved"}, tenant_id="acme")

Off by default. requires = ("auth", "cache") is enforced by the kernel, not suggested here: the handshake verifies a token, and Redis is the backplane that carries a message to the worker holding the recipient's socket. Without it this is a single-process toy that looks like it scales.

How a message crosses workers

Every worker subscribes to one channel and delivers only to the sockets it holds. A publish never delivers locally; local delivery happens only through the subscription.

POST /invoices        worker A ──publish──▶ Redis ──▶ worker A  (holds nobody)
   (on worker A)                              │
                                              └──▶ worker B  (holds user-42) ──▶ socket

That single rule is what makes it exactly once per socket. Delivering locally and over the backplane would give every socket on the publishing worker two copies of the same message — which is the bug this design exists to prevent, and the one the test suite asserts against.

Because no worker is special, the load balancer needs no sticky sessions. Any worker can accept any connection.

The cost of one channel is that every worker sees every message and discards most of them. Per-subject Redis channels would cut that traffic, at the price of a SUBSCRIBE and UNSUBSCRIBE per connection. That trade is worth revisiting above a few thousand messages a second, and not before.

One subject with two sockets — two browser tabs — gets one copy per socket. Exactly-once is per connection, not per person.

The handshake

A browser cannot set an Authorization header on a WebSocket. The two usual answers are both worse than they look:

Route What it costs
Token in the query string Written into every access log, proxy trace and browser history entry it passes through, and it cannot be rotated out of any of them.
Token in the first message The socket exists, and holds a file descriptor, before anybody has proved who they are.

This plugin reads the token from Sec-WebSocket-Protocol, and never from the query string. A browser can offer subprotocols, so the token travels in a header:

const ws = new WebSocket("wss://api.example.com/ws/notifications", [
  "jfast.auth.bearer",
  accessToken,
]);

The server verifies the second entry and echoes back jfast.auth.bearer — a browser aborts the connection if the server selects a subprotocol it did not offer.

Non-browser clients — a worker, a mobile app, a Go service — can set a real header, so Authorization: Bearer … is read first and preferred.

Verification goes through the auth plugin's verify_token, so JWKS rotation, issuer, audience, algorithm pinning and revocation all apply exactly as they do to an HTTP request. A refresh token is refused: it verifies like any other, and without that check a 30-day token would open a socket wherever an access token would.

The case the first message covers is a client that controls neither header. It is available, and off by default:

[plugin.websocket]
allow_first_message_auth = true
auth_deadline_seconds = 5

With it on, the socket is accepted and the server waits for {"type": "auth", "token": "…"}. Until that arrives the connection is not in the registry, receives nothing, and is closed when the deadline passes. The deadline is what bounds the unauthenticated window; that window is the reason this is not the default.

Rejection happens before accept(), so the HTTP handshake is answered with a refusal and no socket is ever upgraded.

Backpressure: a slow client is disconnected, not buffered

Every connection has a bounded outbound queue drained by a writer task. Sends never touch the socket directly — otherwise a publisher would await a TCP write to the slowest client in the fan-out.

When that queue fills, there are three possible answers and only one default:

Policy Behaviour When
close (default) Close with 1013 Try Again Later. The client reconnects and re-reads its state. Anything where a missing message changes what the user sees.
drop_oldest Keep the newest frames, discard the oldest, count them on conn.dropped. A telemetry feed where only the latest value matters.
unbounded Not offered. —

An unbounded buffer is how one slow client takes a worker down: memory grows until the process dies, and it dies serving everyone. Dropping silently is worse in a different way — the client's state diverges from the server's with no signal that it happened. Disconnecting is loud, and the client already has to handle a reconnect.

send_buffer = 32 is roughly a second of a chatty feed. Raising it raises the memory a single stalled client can pin.

Heartbeats

A dropped TCP connection is not observable from the ASGI layer: the socket looks writable for as long as the kernel buffer accepts writes. Only inbound traffic proves the peer is alive, which is why the timeout is on receiving and not on sending.

Clients should answer:

ws.onmessage = (event) => {
  const frame = JSON.parse(event.data);
  if (frame.type === "ping") return ws.send(JSON.stringify({ type: "pong" }));
  handle(frame.data);
};

A pong never reaches your handler — it is transport bookkeeping, and a handler that has to filter it is a handler that will forget to.

20 seconds is deliberately under the idle timeout of the proxies that sit in front of this: nginx proxy_read_timeout and an AWS ALB both default to 60. Set idle_timeout_seconds = 0 to disable the application-level timeout and rely on the protocol-level pings uvicorn already sends (--ws-ping-interval), which browsers answer without any application code.

Auth expiry mid-connection

A socket does not outlive the token that opened it. When the access token's exp passes, the connection is closed with 1008, and the client refreshes and reconnects.

The alternative — leaving it open — means a socket opened with a 15-minute token is still streaming eight hours later under an authorization nothing ever re-checked, and a logout or a revocation could never reach it. A reconnect costs one round trip; the other option costs the meaning of the token lifetime.

[plugin.websocket]
close_on_token_expiry = false   # only if you know why

A token with no exp is never aged out, because there is nothing to age.

Reconnection: messages sent while disconnected are lost

Stated plainly, because the transport does not support the alternative:

So: a message published while a client was between sockets is gone. Design for it — have the client re-read its state on connect rather than assume the stream is a complete history:

@socket("/ws/notifications")
async def notifications(conn: Connection) -> None:
    await conn.send({"kind": "resync", "unread": await unread_for(conn.subject)})
    await conn.listen()

Anything that must not be lost belongs in the queue plugin, which has retries and a dead-letter, not here.

Tenancy

The registry is keyed by (tenant_id, subject), and the tenant comes from the verified token, never from the client. Two consequences:

await hub.send("alice", {"kind": "invoice.paid"}, tenant_id="acme")

The connection registry

conn.join(room) / conn.leave(room) manage room membership; everything else is automatic. A connection is removed from the subject index and every room it joined in a finally, so an abrupt disconnect — 1006, no close frame — leaves nothing behind. Removing it from only the subject index is the leak that shows up in production as memory that grows with connection churn.

GET /health reports the live count:

{ "detail": "3 connection(s)", "connections": 3, "subjects": 2, "rooms": 1 }

What the client receives

Two frame shapes, both JSON objects:

{ "type": "message", "room": null, "data": { "kind": "invoice.paid", "id": 7 } }
{ "type": "ping" }

room is the room name for a room message and null for one addressed to a subject. Your payload is always under data, so a payload with its own type key cannot collide with the transport's.

Proxies

The upgrade has to survive the path. The generated Caddy config and the gateway plugin pass it through; a hand-written nginx needs it spelled out:

location /ws/ {
    proxy_pass http://app;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
    proxy_read_timeout 300s;   # or the heartbeat has to be under 60
}

On Kubernetes, an Ingress that terminates idle connections at 60 seconds will cut a WebSocket that is otherwise healthy. Either raise the timeout (nginx.ingress.kubernetes.io/proxy-read-timeout) or leave heartbeat_seconds under it — the default 20 already is.

Maturity

alpha. See STATUS.md for what that means and what has not been run.

The cross-worker test — two app instances, one real Redis, a socket on each, exactly-once asserted by sequence number — exists in tests/test_websocket.py and passes against redis:7-alpine. It skips unless a Redis is reachable, and CI does not currently start one, so this has not been proven by a CI job. That is the promotion gate, and it has not been met.