Appearance
WebSocket Handlers
WebSocket ingress rules map a URL path to a JavaScript script that handles real-time, bidirectional communication. Unlike HTTP handlers (which get a new context per request), WebSocket scripts maintain a persistent GraalVM context for the lifetime of the connection.
How It Works
- A client connects to a WebSocket URL matching an ingress rule (e.g.,
ws://host/ws/chat) - The engine loads the linked script and creates a GraalVM context that stays open
- The script's
onOpen(session)export is called - For each incoming message,
onMessage(session, message)is called - When the client disconnects,
onClose(session)is called - The GraalVM context is closed
INFO
WebSocket routes must start with /ws/. Clients connect directly — there is no /backend prefix like HTTP routes.
Required Exports
A WebSocket handler script must export three lifecycle functions:
javascript
export function onOpen(session) {
// Called when a client connects
}
export function onMessage(session, message) {
// Called for each incoming text message
}
export function onClose(session) {
// Called when the client disconnects
}All three exports are required. The session parameter is a WebSocketSession object.
WARNING
The Results global is not available in WebSocket scripts. These handlers don't return HTTP responses — they communicate via session.send().
Basic Echo Server
javascript
export function onOpen(session) {
session.send(JSON.stringify({ type: "connected", id: session.id }));
}
export function onMessage(session, message) {
session.send(message); // echo back
}
export function onClose(session) {
console.log("Client disconnected:", session.id);
}Working with Rooms
Rooms enable group communication. Sessions can join multiple rooms simultaneously.
javascript
export function onOpen(session) {
const roomName = session.getParameter("room") || "general";
session.setAttribute("room", roomName);
session.room(roomName).join();
session.send(JSON.stringify({
type: "joined",
room: roomName,
members: session.room(roomName).members()
}));
session.room(roomName).broadcastExcept(JSON.stringify({
type: "user-joined",
id: session.id
}));
}
export function onMessage(session, message) {
const roomName = session.getAttribute("room");
session.room(roomName).broadcastExcept(message);
}
export function onClose(session) {
const roomName = session.getAttribute("room");
session.room(roomName).broadcastExcept(JSON.stringify({
type: "user-left",
id: session.id
}));
// Room leave is automatic after onClose returns
}Room Key Points
- Rooms are lazy — created on first
join(), removed when empty room.broadcast(msg)— sends to everyone in the room, including the senderroom.broadcastExcept(msg)— sends to everyone except the senderroom.members()— returns the count of current room members- Cleanup is automatic — room memberships are removed after
onClosereturns
Per-Connection State
Use session.setAttribute() / session.getAttribute() for state that lives as long as the connection:
javascript
export function onOpen(session) {
const token = session.getParameter("token");
const user = AuthService.validateToken(token);
if (!user) {
session.send(JSON.stringify({ type: "error", message: "Unauthorized" }));
session.close();
return;
}
session.setAttribute("user", user);
session.setAttribute("connectedAt", Date.now());
}
export function onMessage(session, message) {
const user = session.getAttribute("user");
// user is available for the lifetime of this connection
}Query Parameters
Pass data during the WebSocket handshake via query parameters:
ws://your-host/ws/game?room=arena1&player=alicejavascript
export function onOpen(session) {
const room = session.getParameter("room");
const player = session.getParameter("player");
// ...
}Message Protocol Pattern
A common pattern is to use JSON messages with a type field for routing:
javascript
export function onMessage(session, raw) {
const msg = JSON.parse(raw);
switch (msg.type) {
case "chat":
handleChat(session, msg);
break;
case "move":
handleMove(session, msg);
break;
case "ping":
session.send(JSON.stringify({ type: "pong" }));
break;
default:
session.send(JSON.stringify({ type: "error", message: "Unknown type" }));
}
}
function handleChat(session, msg) {
session.room("lobby").broadcastExcept(JSON.stringify({
type: "chat",
from: session.getAttribute("username"),
text: msg.text,
timestamp: Date.now()
}));
}
function handleMove(session, msg) {
// Game logic...
}Full Example: Multi-Room Chat
javascript
export function onOpen(session) {
const username = session.getParameter("user") || "anon-" + session.id.slice(0, 6);
session.setAttribute("username", username);
session.setAttribute("rooms", []);
session.send(JSON.stringify({
type: "welcome",
username,
id: session.id
}));
}
export function onMessage(session, raw) {
const msg = JSON.parse(raw);
switch (msg.type) {
case "join": {
const room = session.room(msg.room);
room.join();
const rooms = session.getAttribute("rooms");
rooms.push(msg.room);
session.setAttribute("rooms", rooms);
session.send(JSON.stringify({
type: "joined",
room: msg.room,
members: room.members()
}));
room.broadcastExcept(JSON.stringify({
type: "user-joined",
room: msg.room,
user: session.getAttribute("username")
}));
break;
}
case "leave": {
session.room(msg.room).broadcastExcept(JSON.stringify({
type: "user-left",
room: msg.room,
user: session.getAttribute("username")
}));
session.room(msg.room).leave();
break;
}
case "message": {
session.room(msg.room).broadcastExcept(JSON.stringify({
type: "message",
room: msg.room,
from: session.getAttribute("username"),
text: msg.text,
timestamp: Date.now()
}));
break;
}
}
}
export function onClose(session) {
const username = session.getAttribute("username");
const rooms = session.getAttribute("rooms") || [];
for (const roomName of rooms) {
session.room(roomName).broadcastExcept(JSON.stringify({
type: "user-left",
room: roomName,
user: username
}));
}
// All room memberships are cleaned up automatically
}