WebSocket and WSX

See WebSockets: WSX and raw hosting for handshake ownership, message encoding, raw hosting and buffering limits.

The websocket facade: one ASGI socket as an object, and nothing above it.

WebSocket wraps the three things an ASGI server hands a websocket application — the scope, receive and send — and gives them a shape a reader can follow: accept() consumes the connect and answers it, receive_text() and receive_bytes() read one message, send_text() and send_bytes() write one, close() ends the connection once, and iterating the object yields the incoming texts until the client leaves.

It knows nothing of WSX. The protocol lives in wsx.py and the motor in the server; this object is the transport, so the admitted raw seam — an application that wants the socket itself — is served by the same class (internals/10_server/055_websocket/decisions.md).

The state is one boolean. connected is true between the accept and the end, and everything that depends on the state reads it: an accept happens once, a close writes once, and a read or a write with nothing accepted raises. There is no exported state type, because the three readers of the state are those three rules and each is a question with a yes or a no (owner, 2026-09-06; the precedent is WorkerConnector.connected).

A disconnect is an exception, never a value. Every read raises WebSocketDisconnect when the client is gone, so a read loop never has to check what it got back, and the iterator ends on it.

The handshake facts are read once, in the constructor. The path, the headers, the cookies and the subprotocols the client offered come off the scope — headers lowercased and TYTX-hydrated, cookies split out of the Cookie header, exactly as Request does for HTTP.

WebSocketRegistry is the server’s picture of what is connected: the live sockets, and the page_id socket association a page writes when it opens its channel, so the server can address one page later. It is NEUTRAL — it does not know the SPA and validates nothing: whether a page belongs to the connection asking for it is judged by the application that holds the pool.

class genro_asgi.websocket.WebSocket(scope, receive, send)[source]

Bases: object

One ASGI websocket connection, as an object.

Parameters:
  • scope (Scope)

  • receive (Receive)

  • send (Send)

__init__(scope, receive, send)[source]

Args: scope: the ASGI websocket scope of the handshake. receive: the ASGI receive callable. send: the ASGI send callable.

Parameters:
Return type:

None

read_handshake()[source]

Fill the header and cookie maps off the scope.

Keys are lowercased and values TYTX-hydrated; cookie stays out of the map and becomes cookies, the way Request reads an HTTP request. Acts on the instance; called by __init__.

Return type:

None

property connected: bool

Whether this socket is accepted and not yet closed.

property path: str

The path of the handshake — what names the home application.

property headers: dict[str, Any]

The handshake headers, lowercase keys, values hydrated by TYTX.

property cookies: dict[str, str]

The cookies of the handshake, from its Cookie header.

property subprotocols: tuple[str, ...]

The subprotocols the client offered, in the order it offered them.

async accept(subprotocol=None, headers=None)[source]

Consume the connect and accept the connection.

Parameters:
  • subprotocol (str | None) – the one to negotiate, when the client offered any.

  • headers (dict[str, str] | None) – response headers of the handshake — the one place a websocket can carry a Set-Cookie.

Raises:

RuntimeError – this socket was already accepted, or the first message on the wire was not websocket.connect.

Return type:

None

Sets connected.

async refuse(code=1008, reason='')[source]

Turn the handshake away without accepting it.

Parameters:
  • code (int) – the close code the client sees.

  • reason (str) – the text that travels with it.

Raises:

RuntimeError – this socket was accepted already — turning away what is already in is a close, not a refusal.

Return type:

None

The connect is consumed first: a close written before it is read leaves that message on the wire. Sets connected to false, so nothing can be written afterwards.

async close(code=1000, reason='')[source]

End the connection, once.

Parameters:
  • code (int) – the websocket close code.

  • reason (str) – the text that travels with it.

Raises:

RuntimeError – nothing was accepted yet — before the accept a handshake is turned away with refuse, which is what a hostile Origin gets. Everything judged AFTER the accept — the home application’s cookie, an invalid credential, a server that is not running — is accepted first and closed here with its code, so the browser can read why.

Return type:

None

Sets connected to false. Calling it again writes nothing.

Return type:

None

Parameters:
async receive_text()[source]

The next message, as text.

Return type:

str

Returns:

The message’s text.

Raises:
async receive_bytes()[source]

The next message, as bytes.

Return type:

bytes

Returns:

The message’s bytes.

Raises:
async read_message()[source]

One raw ASGI message, with the disconnect turned into an exception.

Return type:

dict[str, Any]

Returns:

The websocket.receive message as it came.

Raises:
async send_text(text)[source]

Write one text message.

Raises:

RuntimeError – this socket is not connected.

Return type:

None

Parameters:

text (str)

async send_bytes(data)[source]

Write one binary message.

Raises:

RuntimeError – this socket is not connected.

Return type:

None

Parameters:

data (bytes)

async write_message(message)[source]

Write one raw ASGI message.

Parameters:

message (dict[str, Any]) – the websocket.send message to write.

Raises:

RuntimeError – this socket is not connected — nothing is written to a socket nobody accepted, and nothing after a close.

Return type:

None

class genro_asgi.websocket.WebSocketRegistry[source]

Bases: object

The live sockets of one server, and which one each page speaks on.

register(socket)[source]

Take one accepted socket into the picture.

Parameters:

socket (WebSocket) – the facade of a connection that was just accepted.

Return type:

None

unregister(socket)[source]

Take one socket out, and every page that still speaks on IT.

Parameters:

socket (WebSocket) – the connection that ended.

Return type:

None

A page whose association has moved to another socket — a reconnection that happened before this one closed — is left alone: the comparison is on the socket itself, never on the page. A socket that was never registered is no error: the finally of a handshake that failed before the accept comes through here too.

bind_page(page_id, socket)[source]

Say that this page speaks on this socket.

Parameters:
  • page_id (str) – the page opening its channel.

  • socket (WebSocket) – the connection its messages arrive on.

Return type:

None

A page already bound is REBOUND, with no error: a browser that lost its socket and opened a new one says so again, and the association follows it. One socket carries as many pages as the browser has under that connection.

get_page_socket(page_id)[source]

The socket that page speaks on, or None when it speaks on none.

Parameters:

page_id (str) – the page to address.

Return type:

WebSocket | None

Returns:

Its socket, or None — the page never opened a channel, its socket is gone, or the page itself is.

snapshot()[source]

The live sockets, in the order they were accepted.

Return type:

list[WebSocket]

The WSX envelope: one message on a websocket, in either direction.

A WSX message is the text WSX:// followed by JSON. WsxEnvelope is that message as an object, and it is built two ways — from the text a socket delivered, or from the fields somebody is about to send:

envelope = WsxEnvelope(text)                                # read one
reply = WsxEnvelope(id=envelope.id, status=200, data=…)     # write one
await socket.send_text(reply.encode())

The prefix is what tells a WSX message from any other text on the socket, and the routing fields parallel the internal channel’s info, so a message copies into a CALL one field at a time.

A request carries ``method`` and ``path``; an answer carries ``status``. Both carry data and, when they belong to a page, page_id. id is what correlates an answer with the message it answers, and its ABSENCE is meaningful twice: a message with no id is an event nobody answers, and a message the server sends by itself never has one. reply_path is where a page asks to be called back when the work is done. A field nobody set does not reach the wire — a null there would read as a value.

The application data stays serialized while routing. The outer JSON contains a TYTX string. WsxEnvelope parses only that JSON and keeps an explicit SerializedWsxPayload. Its data property is an opt-in consumer decoder; routing uses serialized_data. Value constructors and public send_message still serialize ordinary Python values. Application endpoints adapt XML/msgpack responses to browser JSON, while forwarding responses travel without application codecs.

A text that is not a WSX message raises. So does a body that is not JSON, and one that is not an object. All three are the same thing to a reader — this text is not a message of ours — and the read loop logs and moves on.

WsxConnection is one live connection speaking that protocol: serve() is its whole life. It gates the handshake, accepts, reads messages until the client leaves, and waits for what is still in flight. Every message with an id becomes a synthetic HTTP request with the method WSK, handed to the application its path names through the server’s own demux — so an application learns no new method and a websocket message travels the road a request travels. A message with no id is an event: served, answered by nothing.

The gate answers in one shape: accept, then close with a readable code. A handshake on a path no application serves, or one missing the cookie its home application demands, closes 1008. The single exception is a hostile Origin, refused BEFORE the accept — there is nobody to tell, because nobody was admitted. The state of the server is judged higher up, in on_websocket, above the demux and for every websocket alike: this gate never sees a connection the machine had already refused.

class genro_asgi.wsx.WsxConnection(server, scope, receive, send)[source]

Bases: object

One websocket connection speaking WSX, from the handshake to the end.

Parameters:
  • server (Any)

  • scope (Scope)

  • receive (Receive)

  • send (Send)

__init__(server, scope, receive, send)[source]

Initialize this instance.

Parameters:
Return type:

None

async serve()[source]

Live this connection: gate, accept, read, drain.

Returns when the socket is over — the client left, or the gate turned it away. Registers the socket for the span of the connection and takes it out in the finally, whatever happened.

Return type:

None

class genro_asgi.wsx.WsxEnvelope(text=None, *, id=None, method=None, path=None, data=None, page_id=None, reply_path=None, status=None, serialized_data=None)[source]

Bases: object

One WSX message: read from its text, or built to be sent.

Parameters:
  • text (str | None)

  • id (str | None)

  • method (str | None)

  • path (str | None)

  • data (Any)

  • page_id (str | None)

  • reply_path (str | None)

  • status (int | None)

  • serialized_data (SerializedWsxPayload | None)

__init__(text=None, *, id=None, method=None, path=None, data=None, page_id=None, reply_path=None, status=None, serialized_data=None)[source]

Initialize this instance.

Parameters:
  • text (str | None) – a WSX message to read; the keywords are ignored when it is given.

  • id (str | None) – what correlates an answer with its message; None for an event.

  • method (str | None) – the request’s method — WSK for a page’s rpc.

  • path (str | None) – the request’s path, which names the application and the route.

  • data (Any) – the payload, as a Python value.

  • page_id (str | None) – the page the message belongs to, when it belongs to one.

  • reply_path (str | None) – where the page asks to be called back.

  • status (int | None) – the answer’s status; None on a request.

  • serialized_data (SerializedWsxPayload | None)

Raises:

ValueErrortext is not a WSX message, its body is not JSON, or that body is not an object.

Return type:

None

property data: Any

Read a value at an explicit consumer; routers use serialized_data.

read_text(text)[source]

The JSON body of one WSX message.

Parameters:

text (str) – the message as the socket delivered it.

Return type:

dict[str, Any]

Returns:

The body, its data still the TYTX string.

Raises:

ValueError – no WSX:// prefix, a body that is not JSON, or a body that is not an object.

encode()[source]

The wire text: the prefix and the JSON body.

Return type:

str

Returns:

WSX:// followed by the JSON of the fields that were set — data as its TYTX string, and nothing for a field left out.