Skip to content

WebSockets

Use a WebSocket when the protocol is genuinely bidirectional: the client and server both initiate messages over one long-lived connection. For one-way server push, prefer Server-Sent Events. SSE reconnects naturally, works through ordinary HTTP infrastructure, and is usually the simpler contract.

jero WebSockets are typed in both directions. Their framing, decoders, path binding, headers, and authentication are compiled at wiring time; a handler only receives an accepted connection after its handshake has passed those checks.

A complete typed protocol

from typing import assert_never

from msgspec import Struct

from jero import BaseApp, WebSocket, WebSocketEndpoint


class WeatherRequest(Struct, tag="weather-request"):
    request_id: str
    city: str


class ForecastRequest(Struct, tag="forecast-request"):
    request_id: str
    city: str


type Inbound = WeatherRequest | ForecastRequest


class WeatherResponse(Struct, tag="weather-response"):
    request_id: str
    temperature_c: float


class ForecastResponse(Struct, tag="forecast-response"):
    request_id: str
    summary: str


type Outbound = WeatherResponse | ForecastResponse


class WeatherWebSocket(WebSocketEndpoint, path="/weather"):
    async def handle(self, websocket: WebSocket[Inbound, Outbound]) -> None:
        async for message in websocket:
            match message:
                case WeatherRequest(request_id=request_id):
                    # In a class pattern, ``request_id=name`` extracts the attribute;
                    # it is not an assignment to the object.
                    await websocket.send(
                        WeatherResponse(request_id=request_id, temperature_c=20.0)
                    )
                case ForecastRequest(request_id=request_id):
                    await websocket.send(
                        ForecastResponse(request_id=request_id, summary="summary")
                    )
                case _:
                    assert_never(message)


class App(BaseApp):
    async def wire(self) -> None:
        self._include_websocket(WeatherWebSocket())


app = App()

Explicit kebab-case tags are required even when a direction currently has one Struct. The tag is a deployed wire contract: adding a second member later must not silently change the first message's representation. A final assert_never arm makes adding a new inbound member without handling it a static error.

Correlated ask/answer messages conventionally use XRequest / XResponse and carry a request_id: str that the server echoes. Unsolicited server-push messages use plain names such as Tick or Departed.

Handshake binding and authentication

handle may bind path, params, headers, cookies, raw_headers, and user exactly like an HTTP handler. cookies is the motivating case for cookie auth here: a browser's WebSocket API cannot set an Authorization header, but it always sends cookies — mount a CookieAuth/HybridAuth authenticator (see Cookies) and a browser client authenticates with the session cookie it already has. Authentication and binding run before the upgrade. A failure is returned as an ordinary typed HTTP rejection when the ASGI server advertises the WebSocket denial response extension. Without that optional extension, jero sends a pre-accept close and the server rejects the upgrade (normally as HTTP 403, without the typed body). Only a valid handshake receives websocket.accept and enters the handler.

Acceptance is therefore implicit—there is no websocket.accept(). Reject application state that a browser must understand after acceptance by sending a typed message and then closing:

await websocket.send(RoomFull(retry_after_seconds=30))
await websocket.close(code=4003, reason="room full")
return

Codes 4000–4999 are available to the application. jero uses 1003 for the wrong frame kind, 1007 for malformed JSON, 1008 for a schema violation, 1009 for an oversized message, 1011 for an uncaught handler error, and 1013 for a channel overflow close. Inbound frames default to at most 1 MiB; override this per mount with max_frame_size=. Ping/pong keepalive belongs to the ASGI server because standard ASGI does not expose ping frames to applications.

close() accepts jero's standard protocol codes or application codes 4000–4999. Close reasons are limited to the WebSocket protocol's 123 UTF-8 bytes; invalid values raise before a close event is sent. Code 1010 is client-only and therefore rejected for these server-originated closes. Sending after the connection starts closing is also rejected locally rather than emitting an invalid ASGI event.

Framing

Each direction chooses exactly one kind:

Declared type Text frame Binary frame
tagged Struct or tagged Struct union strict JSON close 1003
str text verbatim close 1003
bytes close 1003 bytes verbatim

The directions are independent, so WebSocket[bytes, Transcript] is valid. Mixed forms such as Message | str are not. Raw str and bytes are deliberate escape hatches for third-party JSON, msgpack, audio, or another user-owned protocol.

WebSockets are not represented by OpenAPI. The tagged message unions are the client-facing contract; AsyncAPI generation is outside jero's current scope.

Broadcasting with Channel

Channel[T] encodes a message once and fans the same JSON frame out to every local subscriber. Each connection gets a bounded writer queue, so one slow client never blocks publishing to the others:

from dataclasses import dataclass

from msgspec import Struct

from jero import BaseApp, Channel, WebSocket, WebSocketEndpoint


class ChatMessage(Struct, tag="chat-message"):
    text: str


class SendMessage(Struct, tag="send-message"):
    text: str


@dataclass
class ChatWebSocket(WebSocketEndpoint, path="/chat"):
    _room: Channel[ChatMessage]

    async def handle(self, websocket: WebSocket[SendMessage, ChatMessage]) -> None:
        async with self._room.attach(websocket) as subscription:
            subscription.join("global")
            async for message in websocket:
                self._room.publish("global", ChatMessage(text=message.text))


class App(BaseApp):
    async def wire(self) -> None:
        room = Channel(ChatMessage, overflow="close", queue_size=64)
        self._include_websocket(ChatWebSocket(room))


app = App()

publish is synchronous and fire-and-forget. On a full queue, "close" closes that consumer with 1013; "drop-oldest" replaces stale queued data, which suits tickers. The attachment context always removes the connection and its topic memberships. queue_size must be a positive, non-boolean integer.

A channel is per process. With multiple workers, relay an external pub/sub subscription into each worker's local channel:

async def relay(channel: Channel[ChatMessage], broker: Broker) -> None:
    async for message in broker.messages():
        channel.publish("global", message)

jero deliberately does not choose Redis, PostgreSQL, NATS, or another backplane.

Testing

The synchronous TestClient exposes the same typed contract:

with client.websocket("/weather", inbound=Inbound, outbound=Outbound) as websocket:
    websocket.send(WeatherRequest(request_id="request-id", city="city"))
    response = websocket.receive()

An HTTP handshake rejection raises WebSocketUpgradeError with its TestResponse on .response. A close raises WebSocketClosedError, whose .code and .reason are available for assertions. send_text and send_bytes allow deliberate protocol-error tests.