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).

app/routes/web/chat.py
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
{# 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

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():

4. API Reference Summary

Below are the primary methods for backend and client WebSocket management:

API Methods

# 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