WebSocket System (Real-Time)
Lila Framework provides a zero-dependency, Socket.io-style WebSocket engine built on top of Starlette.
It uses orjson-serialized JSON text frames, room management,
automatic heartbeat ping/pong, and a reconnecting client
(public/js/ws.js). The hub class is
WebSocketHub (also exported as
WebSocketManager); the global instance is
ws.
1. Backend Setup (lila/core/websocket.py)
Mount the hub with router.ws_hub("/ws/chat", hub) and handle events with
@hub.on("event"). Event handlers receive
(websocket, data).
import datetime
from lila.core.routing import Router
from lila.core.websocket import ws # global, pre-configured WebSocketHub
router = Router()
# Mounts accept / receive loop / heartbeats / disconnect handling in 1 line:
router.ws_hub("/ws/chat", ws)
# Event handler for joining a room
@ws.on("join_channel")
async def handle_join(websocket, data: dict):
room = (data or {}).get("room", "general")
username = (data or {}).get("username", "Guest")
ws.join_room(websocket, room)
# Broadcast a join notice to the room members
await ws.broadcast("user_joined", {
"user": username,
"room": room,
"time": datetime.datetime.now().strftime("%H:%M"),
}, room=room)
# Event handler for chat messages
@ws.on("send_message")
async def handle_message(websocket, data: dict):
data = data or {}
room = data.get("room", "general")
text = str(data.get("text", ""))
username = data.get("username", "Guest")
if not text.strip():
return
await ws.broadcast("new_message", {
"user": username,
"text": text,
"room": room,
"time": datetime.datetime.now().strftime("%H:%M"),
}, room=room)
# A private hub (instead of the global one) works the same way.
from lila.core.websocket import WebSocketHub
chat_hub = WebSocketHub()
router.ws_hub("/ws/private", chat_hub)
# Starlette-style raw endpoint, if you prefer handling the loop yourself:
from starlette.websockets import WebSocket
@router.websocket("/ws/raw")
async def raw_socket(websocket: WebSocket):
await chat_hub.handle_connection(websocket, default_room="lobby")
2. Client-Side Usage (public/js/ws.js)
Connect from Jinja2 templates using the built-in lilaWS client.
Never inject untrusted values with innerHTML: build DOM nodes with
textContent (as below) or escape them first.
{# resources/html/chat.jinja — rendered with:
render(request, "chat", title="Chat", scripts=["js/ws.js"]) #}
<div class="w-full max-w-xl mx-auto bg-slate-800 border border-slate-700 rounded-2xl p-6 shadow-2xl space-y-4 text-slate-100">
<div class="flex items-center justify-between border-b border-slate-700 pb-3">
<h3 class="font-bold">Chat Room (<span class="text-blue-400">#general</span>)</h3>
<span id="status-indicator" class="text-xs px-2 py-1 rounded bg-amber-500/20 text-amber-400 font-mono">Connecting...</span>
</div>
<div id="messages" class="h-64 overflow-y-auto bg-slate-950 p-4 rounded-xl space-y-2 text-sm"></div>
<form onsubmit="sendMessage(event)" class="flex gap-2">
<input type="text" id="msg" placeholder="Type a message..." class="flex-1 bg-slate-900 border border-slate-700 rounded-xl px-4 py-2" required />
<button type="submit" class="bg-blue-600 hover:bg-blue-500 text-white font-semibold px-4 py-2 rounded-xl">Send</button>
</form>
</div>
<script>
// `var`: this script runs again on every SPA visit.
// Relative URL -> ws:// or wss:// automatically. The socket is closed when
// the SPA router leaves this page (use { persist: true } to keep it).
var chat = lilaWS('/ws/chat');
chat.on('connect', () => {
document.getElementById('status-indicator').className = 'text-xs px-2 py-1 rounded bg-emerald-500/20 text-emerald-400 font-mono';
document.getElementById('status-indicator').innerText = 'Connected';
chat.emit('join_channel', { room: 'general', username: 'Alice' });
});
chat.on('disconnect', () => {
document.getElementById('status-indicator').className = 'text-xs px-2 py-1 rounded bg-rose-500/20 text-rose-400 font-mono';
document.getElementById('status-indicator').innerText = 'Disconnected';
});
chat.on('new_message', (data) => {
const box = document.getElementById('messages');
const row = document.createElement('div');
const user = document.createElement('b');
user.className = 'text-blue-400';
user.textContent = data.user; // textContent, not innerHTML
const text = document.createElement('span');
text.textContent = `: ${data.text}`;
row.append(user, text);
box.appendChild(row);
box.scrollTop = box.scrollHeight;
});
function sendMessage(e) {
e.preventDefault();
const input = document.getElementById('msg');
chat.emit('send_message', { room: 'general', username: 'Alice', text: input.value });
input.value = '';
}
</script>
Client behaviour
- Relative URLs (
'/ws/chat') andhttp(s)://URLs are converted tows(s)://. - Reconnects with exponential backoff + jitter, and immediately when the browser goes back
online. No reconnect afterdisconnect()or when the server closes with 1003/1008/1009/1011 (noReconnectCodesoption). - Messages emitted while offline are queued (max 500) and sent on reconnect; rooms are re-joined once.
- SPA aware: sockets created by a page are closed on
lila:before-swap; pass{ persist: true }for an app-wide socket (e.g. in your layout). /js/ws.jsis always served from the installed Lila package, so upgrades fix old copies inpublic/js.
Authorizing rooms
Clients can send {"event": "join", "data": "room"}. Protect private rooms with
@hub.can_join; denied joins receive a join:denied event. Room names are limited to 128
characters and 50 rooms per connection.
from lila.core.websocket import ws
@ws.can_join
async def allow_join(websocket, room: str) -> bool:
if not room.startswith("user:"):
return True
user = websocket.scope.get("state", {}).get("user") # set it in your own auth handshake
return bool(user) and room == f"user:{user['id']}"
3. Distributed Sync (Redis Pub/Sub)
In multi-worker (e.g. multiple Uvicorn worker processes) or load-balanced environments, WebSocket connections are split across different servers/workers. To ensure messages are successfully broadcasted to everyone, Lila integrates Redis Pub/Sub directly inside WebSocketManager.
When you call broadcast() or broadcast_to_room():
- The event is sent locally to all sockets connected to the current process.
- The manager automatically publishes the message payload to a shared Redis channel (
lila:ws:pubsub). - A background daemon listener thread on each scaling instance receives the message and pushes it to local sockets in that process.
- If Redis is down or not configured, it seamlessly falls back to local memory single-instance mode.
4. API Reference Summary
Below are the primary methods for backend and client WebSocket management:
# Backend (WebSocketHub instance; `ws` is the global one)
async def hub_example(ws, websocket, sock, data):
@ws.on("event_name") # Register event callback
async def on_event(websocket, data):
return None
await ws.broadcast("event", data) # Send to every connected socket
await ws.broadcast("event", data, room="room") # Send to room members only
await ws.broadcast("event", data, exclude=sock) # Skip one socket
await ws.emit(websocket, "event", data) # Send to a single socket
ws.join_room(websocket, "room_name") # Join socket to a channel
ws.leave_room(websocket, "room_name") # Remove socket from a channel
ws.can_join(lambda websocket, room: True) # Authorize client "join" events
await ws.handle_connection(websocket) # Full receive loop (or router.ws_hub)
// Client JavaScript (lilaWS)
const sock = lilaWS('/ws'); // Connect (ws:// or wss:// automatically)
const app = lilaWS('/ws', { persist: true }); // Survives SPA navigations
sock.on('event', (data) => { /* ... */ }); // Handle a server event
sock.emit('event', { key: 'value' }); // Send an event to the server
sock.join('room_name'); // Join a channel
sock.leave('room_name'); // Leave a channel
sock.once('event', (data) => { /* ... */ }); // One-time listener
sock.connected; // true when open
sock.disconnect(); // Close, no auto-reconnect