WebSockets¶
BlackBull serves WebSocket connections over HTTP/1.1 Upgrade
(RFC 6455) by default, and over HTTP/2 Extended CONNECT (RFC 8441)
as an opt-in. permessage-deflate (RFC 7692) compression is
negotiated automatically.
Registering a route¶
WebSocket routes use scheme=Scheme.websocket. The handler
always receives the full (scope, receive, send) triplet —
the simplified handler form does not apply:
from blackbull import BlackBull
from blackbull.utils import Scheme
app = BlackBull()
@app.route(path='/ws', scheme=Scheme.websocket)
async def ws_handler(scope, receive, send):
await receive() # consume 'websocket.connect'
await send({'type': 'websocket.accept'})
while True:
event = await receive()
if event['type'] == 'websocket.disconnect':
break
text = event.get('text') or event.get('bytes', b'').decode()
await send({'type': 'websocket.send', 'text': text})
Sec-WebSocket-Version: 13 is validated automatically.
The websocket middleware¶
The built-in blackbull.middleware.websocket consumes the initial
websocket.connect event and sends websocket.accept, so the
inner handler can skip that boilerplate:
from blackbull.middleware import websocket
@app.route(path='/chat', scheme=Scheme.websocket, middlewares=[websocket])
async def chat(scope, receive, send):
# Connection already accepted; go straight to reading messages
while True:
event = await receive()
if event['type'] == 'websocket.disconnect':
break
await send({'type': 'websocket.send', 'text': event.get('text', '')})
permessage-deflate (RFC 7692)¶
permessage-deflate compression is negotiated automatically when
the client offers it on the handshake. The server replies with
Sec-WebSocket-Extensions: permessage-deflate;
server_no_context_takeover; client_no_context_takeover — the
no-context-takeover flags trade a small compression-ratio penalty
for bounded per-connection memory (each side resets its deflate
state between messages instead of keeping it for the whole
connection).
| Aspect | Behaviour |
|---|---|
| Default | On — matches modern browsers, Node ws, Python websockets, aiohttp. |
| Disable | BB_WS_PERMESSAGE_DEFLATE=0. The handshake still succeeds; just no extension is negotiated. |
| Per-message-deflate strategy | Both server_no_context_takeover and client_no_context_takeover always advertised. |
| RSV1 bit | Set on compressed data frames per §7 of the RFC; clients without the negotiated extension that send RSV1 are rejected as protocol violations. |
Transport: HTTP/1.1 Upgrade vs HTTP/2 Extended CONNECT¶
WebSocket is always available over the HTTP/1.1 Upgrade
handshake (RFC 6455 §4). Over HTTP/2 it is opt-in via
Extended CONNECT (RFC 8441):
BB_H2_ENABLE_WEBSOCKET=1 python app.py --port 8443 --cert cert.pem --key key.pem
When enabled the server advertises
SETTINGS_ENABLE_CONNECT_PROTOCOL=1 in its initial SETTINGS
frame. An HTTP/2 peer may then open a WebSocket by sending
:method = CONNECT, :protocol = websocket, and the usual
Sec-WebSocket-* pseudo-headers on a single stream. The
bidirectional DATA frames on that stream then carry WebSocket
frames.
This path is off by default because it has fewer conformance tests than the HTTP/1.1 Upgrade path and few clients use it in practice — Cloudflare's edge stack is the main consumer. Browsers that negotiate HTTP/2 via ALPN normally still use HTTP/1.1 for WebSocket, so most apps do not need to enable RFC 8441.
Subprotocol negotiation¶
Register the protocols the server supports before starting:
app.available_ws_protocols = ['chat', 'superchat']
BlackBull picks the first protocol from the client's
Sec-WebSocket-Protocol offer that appears in this list and
returns it in the 101 handshake response. If there is no match,
or if the client did not offer any protocol, no
Sec-WebSocket-Protocol header is sent and the connection
proceeds without a subprotocol.
The list accepts str or bytes values. Common protocol names:
| Protocol | Use case |
|---|---|
graphql-ws |
Legacy GraphQL subscriptions (Apollo) |
graphql-transport-ws |
Modern GraphQL subscriptions |
stomp / v12.stomp |
STOMP messaging (RabbitMQ, ActiveMQ) |
mqtt |
MQTT over WebSocket (IoT) |
wamp |
Web Application Messaging Protocol |
ocpp1.6 / ocpp2.0 |
EV charging stations |
Fragmented messages¶
WebSocket clients may split a single logical message across
multiple frames (RFC 6455 §5.4). BlackBull reassembles fragments
transparently — the app always receives one websocket.receive
event containing the full payload, regardless of how many frames
the client used.
A fragmented sequence on the wire:
FIN=0, opcode=TEXT, payload=b'hel' ← opener
FIN=0, opcode=0x0, payload=b'lo' ← continuation
FIN=1, opcode=0x0, payload=b'' ← final continuation
The app sees a single event:
{'type': 'websocket.receive', 'text': 'hello', 'bytes': None}
Control frames (ping, pong, close) may legally appear between data fragments; BlackBull handles them immediately (responding to pings with pong) and then continues reassembling the fragmented message.
The following are protocol violations and raise
ProtocolError:
| Violation | RFC reference |
|---|---|
| CONTINUATION frame with no fragmentation in progress | §5.4 |
| New TEXT or BINARY frame while a fragment sequence is open | §5.4 |
| Control frame (ping/pong/close) with FIN=0 | §5.5 |
Queue depth and back-pressure¶
Each WebSocket connection has an inbound event queue; the depth
defaults to 256 and is configurable via BB_WS_QUEUE_DEPTH.
When the queue fills (your handler is slower than the client),
new frames block the per-connection read loop rather than
unbounded buffering in memory.
Next¶
- Routing —
@app.routefor HTTP routes and the rest of the routing surface. - Middleware — the
websocketmiddleware and other built-ins.