WebSockets
Build real-time, interactive experiences. Soli provides native WebSocket support with Phoenix-inspired presence tracking for chat apps, notifications, and live collaboration.
Want to see it in action?
Check out the interactive WebSocket chat demo included in this app.
Defining a Route
Register WebSocket endpoints in config/routes.sl.
# Map path to handler function
router_websocket("/ws/chat", "websocket#chat_handler");
# With dynamic segments
router_websocket("/ws/room/:room_id", "websocket#room_handler");
Handling Events
Handlers receive connection events and return actions.
def chat_handler(event: Any) let type = event["type"];
let id = event["connection_id"];
if (type == "connect")
return { "broadcast": json_encode({ "type": "join", "user": id }) };
end
if (type == "message")
let msg = event["message"];
return { "broadcast": msg };
end
{}
end
The Event Object
type
Event kind: "connect", "message", or "disconnect".
connection_id
Unique UUID string identifying the client connection.
message
The text payload sent by the client (only for "message").
Response Actions
Broadcast
Send a message to ALL connected clients (including sender).
{"broadcast": "Hello All"}
Send (Reply)
Send a message ONLY to the client who triggered the event.
{"send": "Hello You"}
Rooms & Channels
Organize connections into channels for targeted messaging. Use join and leave actions to manage room membership.
def room_handler(event: Any) let room = "room:" + event["params"]["room_id"];
if (event["type"] == "connect")
# Join the room channel on connect
return { "join": room };
end
if (event["type"] == "message")
let data = JSON.parse(event["message"]);
# Broadcast only to users in this room
return { "broadcast_room": json_encode(data) };
end
# Leave is automatic on disconnect
{}
end
join
Subscribe to a channel: {"join": "room:lobby"}
leave
Unsubscribe from a channel: {"leave": "room:lobby"}
broadcast_room
Send to all in the joined channel.
Presence Tracking
Phoenix-inspired presence tracking enables real-time awareness of users in rooms. Track who's online, their status (typing, away), and handle multi-device scenarios gracefully.
Multi-Device Support
Presence is grouped by user_id, not connection. A user with multiple tabs/devices appears once in the user list,
and join events only fire when their first connection enters. Leave events only fire when their last connection exits.
def chat_handler(event: Any) let room = "room:general";
# Get user from session/auth (example)
let user = get_current_user();
if (event["type"] == "connect")
return {
"join": room,
"track": {
"channel": room,
"user_id": user["id"], # Required - groups connections
"name": user["name"], # Extra metadata
"avatar": user["avatar"]
}
};
end
if (event["type"] == "message")
let data = JSON.parse(event["message"]);
# Handle typing indicators
if (data["event"] == "typing")
return { "set_presence": { "channel": room, "state": "typing" } };
end
if (data["event"] == "stop_typing")
return { "set_presence": { "channel": room, "state": "online" } };
end
# Regular messages
return { "broadcast_room": json_encode(data) };
end
# Disconnect auto-untracks and broadcasts leave diff
{}
end
Presence Actions
track
Start tracking presence with metadata. Requires channel and user_id.
{"track": {"channel": "room:lobby", "user_id": "123", "name": "Alice"}}
untrack
Manually stop tracking (auto on disconnect).
{"untrack": "room:lobby"}
set_presence
Update state (typing, away, online).
{"set_presence": {"channel": "room:lobby", "state": "typing"}}
Client-Side Presence Events
Clients receive two types of presence messages:
{
"event": "presence_state",
"payload": {
"user_123": {
"metas": [
{ "phx_ref": "1", "state": "online", "name": "Alice", "avatar": "/alice.png" },
{ "phx_ref": "2", "state": "typing", "name": "Alice", "avatar": "/alice.png" }
]
},
"user_456": {
"metas": [{ "phx_ref": "3", "state": "online", "name": "Bob" }]
}
}
}
{
"event": "presence_diff",
"payload": {
"joins": {
"user_789": { "metas": [{ "phx_ref": "4", "state": "online", "name": "Carol" }] }
},
"leaves": {
"user_456": { "metas": [{ "phx_ref": "3", "state": "online", "name": "Bob" }] }
}
}
}
let presences = {};
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.event === 'presence_state') {
// Initial state - replace all
presences = data.payload;
renderUserList();
}
else if (data.event === 'presence_diff') {
// Apply diff
Object.entries(data.payload.joins).forEach(([userId, presence]) => {
presences[userId] = presence;
});
Object.entries(data.payload.leaves).forEach(([userId]) => {
delete presences[userId];
});
renderUserList();
}
};
function renderUserList() {
const users = Object.entries(presences).map(([userId, { metas }]) => ({
userId,
...metas[0], // Use first meta for display
connectionCount: metas.length
}));
// Render users...
}
Server-Side Presence Functions
Query presence state from your handlers:
# Get all users in a room
let users = ws_list_presence("room:lobby");
# Returns: [{ user_id: "123", metas: [...] }, ...]
# Get user count (unique users, not connections)
let count = ws_presence_count("room:lobby");
# Returns: 5
# Get a specific user's presence
let user = ws_get_presence("room:lobby", "user_123");
# Returns: { user_id: "123", metas: [...] } or null
Client-Side Code
// Connect to WebSocket
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
const ws = new WebSocket(`${protocol}//${location.host}/ws/chat`);
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
console.log('Received:', data);
};
ws.send(JSON.stringify({ text: 'Hello!' }));
// Send typing indicator
ws.send(JSON.stringify({ event: 'typing' }));
// Stop typing
ws.send(JSON.stringify({ event: 'stop_typing' }));
Performance
Optimization Tips
- Use
sendinstead ofbroadcastwhenever possible. - Use rooms to scope broadcasts -
broadcast_roomis cheaper than globalbroadcast. - Presence diffs are only sent when users join/leave, not for every connection.
Server-Side Helper Functions
These functions can be called from HTTP routes or other parts of your application to interact with WebSocket connections.
ws_send(connection_id, message)
Send a message to a specific WebSocket connection.
ws_send("uuid-1234", json_encode({ "type": "notification" }));
ws_broadcast(message)
Broadcast a message to all connected clients.
ws_broadcast(json_encode({ "type": "announcement" }));
ws_broadcast_room(channel, message)
Broadcast to all clients in a specific channel.
ws_broadcast_room("room:lobby", json_encode({ "type": "chat" }));
ws_count()
Get total number of active connections.
let total = ws_count();
ws_join(channel)
Join current connection to a channel.
ws_join("room:lobby");
ws_leave(channel)
Leave a channel.
ws_leave("room:lobby");
ws_clients()
Get all connected client IDs.
let clients = ws_clients();
ws_clients_in(channel)
Get all client IDs in a channel.
let room_clients = ws_clients_in("room:lobby");
ws_close(connection_id, reason)
Close a connection with a reason.
ws_close("uuid-1234", "Session expired");
ws_list_presence(channel)
Get all users in a channel with presence data.
let users = ws_list_presence("room:lobby");
ws_presence_count(channel)
Get unique user count in a channel.
let count = ws_presence_count("room:lobby");
ws_get_presence(channel, user_id)
Get presence data for a specific user.
let user = ws_get_presence("room:lobby", "user_123");