WebSockets: WSX and raw hosting

Status: Draft; implementation checked against the development source on 2026-09-08.

The core supports two ways to serve a WebSocket: its WSX message protocol, and an application’s serve_websocket(scope, receive, send) raw seam. The websockets backend required by uvicorn is a package dependency.

At a glance

Both WebSocket modes share the server-state gate. Raw applications own their protocol; WSX supplies its own handshake and routes messages.

The raw seam and WSX have different protocol owners. Neither should be confused with the ordinary HTTP middleware path.

Sending a WSX request

A WSX frame is text: WSX:// followed by JSON. Its data field is a TYTX string, not a nested JSON object. Use WsxEnvelope to encode and decode:

from genro_asgi.wsx import WsxEnvelope

message = WsxEnvelope(id="request-1", path="/greet", data={"name": "Ada"})
text = message.encode()
assert WsxEnvelope(text).data == {"name": "Ada"}

Against the getting-started server, a Python client can make that call. AsgiServer already supplies pydantic parameter validation. Run the client in a separate process:

import asyncio
from websockets.asyncio.client import connect
from genro_asgi.wsx import WsxEnvelope


async def main():
    async with connect("ws://127.0.0.1:8000/") as socket:
        await socket.send(WsxEnvelope(id="request-1", path="/greet",
                                      data={"name": "Ada"}).encode())
        reply = WsxEnvelope(await socket.recv())
        assert reply.id == "request-1"
        assert reply.status == 200
        assert reply.data == {"hello": "Ada"}


asyncio.run(main())

Every message goes through the server’s mount demux and then the application as a synthetic HTTP scope with method WSK, even if its envelope named a method. A message with an id is registered and answered with that id and a status. An ordinary message without an id is an event and gets no reply. The reserved /_wsx/ping is handled inline and answered outside the concurrency limit. Non-WSX text and binary frames are logged and dropped.

Handshake and limits

The handshake path selects a home application. An unknown home or a missing cookie demanded by handshake_cookie is accepted and then closed with code 1008. A hostile Origin is refused before accept. With no allow-list, a browser’s Origin must match the handshake Host; clients without an Origin header pass. Configure browser origins explicitly when necessary:

# Inside AsgiConfigBuilder.main:
root.configuration().server().websocket(
    origins="https://app.example.com,https://admin.example.com",
    max_concurrent=16,
)

Constructor form: AsgiServer(websocket={"origins": ["https://app.example.com"], "max_concurrent": 16}). The default limit is 16 executing messages per connection. It does not bound the number of pending tasks or the total memory queued on that connection. Closing a connection waits up to five seconds for its message tasks and cancels the remainder.

Identity is authenticated once at the handshake; the existing session is read without creating one. The identity and session are carried into each message. The HTTP middleware chain does not run on the handshake or per message. WSX responses must be finite and buffered: an ASGI response chunk with more_body=True is rejected and the call becomes a 500 response.

Raw WebSockets

An application defining serve_websocket receives the mount-relative scope, receive and send directly. It owns accept/close, Origin and identity validation, its protocol, and cleanup; the WSX gate and registry are not used. The server’s not-running admission gate still applies before delegation. See the adapter in Mounting applications.

SPA page channels and push

A SpaApplication handshake requires its spa_connection_id cookie. A page first calls /<mount>/_wsx/openchannel (omit the mount for a root app), carrying page_id in the envelope. The application validates ownership and records the channel; only a 200 response binds the page to that socket. A page RPC before this step is refused with 409. Page requests follow ordinary worker placement; a sequential page’s requests serialize on that page’s queue.

await server.send_message(page_id, path, data) writes an event to a bound page and returns a boolean: True means written to the socket, not executed by the browser. Worker code can call worker.send_message(...), which forwards through the commander. Datachanges and database events are not automatically converted to push notifications by this API. See Multiworker SPA.