Skip to content

WebSocket Handlers

WebSocket ingress rules map a URL path to a JavaScript script that handles real-time, bidirectional communication. DieselEngine supports two WebSocket modes:

  • Per-connection mode (default) — each connection gets its own GraalVM context, persistent for the lifetime of the connection.
  • Real-time mode — activated by exporting tickRate. All connections to the route share one context with a server-driven tick loop (1–120 Hz). Ideal for game servers, physics simulations, and live dashboards.

How It Works (Per-Connection Mode)

  1. A client connects to a WebSocket URL matching an ingress rule (e.g., ws://host/ws/chat)
  2. The engine loads the linked script and creates a GraalVM context that stays open
  3. The script's onOpen(session) export is called
  4. For each incoming message, onMessage(session, message) is called
  5. When the client disconnects, onClose(session) is called
  6. 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 (Per-Connection Mode)

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 sender
  • room.broadcastExcept(msg) — sends to everyone except the sender
  • room.members() — returns the count of current room members
  • Cleanup is automatic — room memberships are removed after onClose returns

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=alice
javascript
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...
}

Server-Side Push with diesel.getWebSocketService()

The WebSocketService lets any script (HTTP handlers, cron jobs, standalone scripts) interact with WebSocket rooms and connected sessions — even though the script is not a WebSocket handler itself. This enables server-initiated push to WebSocket clients.

Broadcasting from an HTTP Handler

javascript
// HTTP POST /api/notify -- pushes a message to all WebSocket clients in a room
export default function(req) {
    const body = JSON.parse(req.body);
    const ws = diesel.getWebSocketService();
    const room = ws.room(body.room);

    if (room.exists()) {
        room.broadcast(JSON.stringify({
            type: "notification",
            text: body.message,
            timestamp: Date.now()
        }));
        Results.json().render({ sent: true, members: room.members() });
    } else {
        Results.json().status(404).render({ error: "Room not found" });
    }
}

Broadcasting from a Cron Job

javascript
// Runs on a schedule, pushes stock data to all subscribers
const stockData = fetchLatestPrices();
const room = diesel.getWebSocketService().room("stock-updates");

if (room.exists()) {
    room.broadcast(JSON.stringify({
        type: "stock-update",
        timestamp: Date.now(),
        data: stockData
    }));
    console.log("Broadcast stock update to", room.members(), "subscribers");
}

TIP

For sub-second periodic broadcasting to WebSocket clients on the same route (game state updates, physics ticks), use real-time mode with onTick instead of cron jobs.

Sending to a Specific Session

javascript
const ws = diesel.getWebSocketService();
const session = ws.getSession("abc-123");

if (session && session.isOpen()) {
    session.send(JSON.stringify({
        type: "direct-message",
        text: "Hello from the server!"
    }));
}

Enumerating Connected Sessions

javascript
const ws = diesel.getWebSocketService();
console.log("Active sessions:", ws.getActiveSessionCount());

for (const id of ws.getActiveSessionIds()) {
    const s = ws.getSession(id);
    if (s) {
        console.log(`  ${s.id} - ${s.getRequestPath()} from ${s.getRemoteAddr()}`);
    }
}

TIP

The WebSocketService room proxy does not have join(), leave(), or broadcastExcept() — those require a sender session and are only available inside WebSocket handler scripts via session.room().

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
}

Real-Time Mode (Tick Loop)

If your script exports tickRate (a number between 1 and 120), DieselEngine activates real-time mode for that WebSocket route. This fundamentally changes the execution model:

AspectPer-Connection (default)Real-Time (tickRate)
GraalVM contextOne per connectionOne per route (shared)
Module-level statePrivate per connectionShared across all connections
Server-initiated pushNot possible (reactive only)onTick(room, deltaMs) at configured Hz
ThreadingSynchronized on handlerDedicated route thread
Use caseChat, notificationsGame servers, physics, live dashboards

How Real-Time Mode Works

  1. First client connects — the engine creates a shared GraalVM context, evaluates the script once, extracts all exports, and starts a tick loop on a dedicated thread.
  2. Subsequent clients connect — they join the existing shared context. onOpen(session) is called on the route thread.
  3. Every tickonTick(room, deltaMs) is called with the actual milliseconds elapsed since the last tick.
  4. Client sends a messageonMessage(session, message) is dispatched to the route thread.
  5. Client disconnectsonClose(session) is called on the route thread.
  6. Last client disconnects — the tick loop stops, the context is closed.

All JS execution (onOpen, onMessage, onClose, onTick) runs on the same dedicated thread, so there are no concurrency issues with the shared GraalVM context.

Game Server Example

javascript
let state = { players: {}, tick: 0 };

export const tickRate = 60; // 60 ticks per second

export function onOpen(session) {
  state.players[session.getId()] = {
    x: 0, y: 0, hp: 100, input: null
  };
  session.room("game").join();
  session.send(JSON.stringify({ type: "welcome", id: session.getId() }));
}

export function onMessage(session, message) {
  const input = JSON.parse(message);
  // Buffer player input — it will be processed on the next tick
  const player = state.players[session.getId()];
  if (player) player.input = input;
}

export function onTick(room, deltaMs) {
  state.tick++;

  // Process all buffered inputs
  for (const [id, player] of Object.entries(state.players)) {
    if (player.input) {
      player.x += (player.input.dx || 0) * (deltaMs / 16.67);
      player.y += (player.input.dy || 0) * (deltaMs / 16.67);
      player.input = null;
    }
  }

  // Broadcast authoritative game state to all clients
  room.broadcast(JSON.stringify({
    type: "state",
    tick: state.tick,
    players: state.players
  }));
}

export function onClose(session) {
  delete state.players[session.getId()];
  // Notify remaining players
  session.room("game").broadcastExcept(JSON.stringify({
    type: "player-left",
    id: session.getId()
  }));
}

RealtimeRoomProxy

The room parameter in onTick(room, deltaMs) is a RealtimeRoomProxy — not a WebSocketSession. It provides:

MethodReturnsDescription
room.broadcast(message)voidSend to all connected sessions on this route
room.broadcastExcept(sessionId, message)voidSend to all except one session
room.send(sessionId, message)booleanSend to a specific session; returns true if sent
room.getSession(sessionId)WebSocketSession | nullGet a session proxy by ID
room.getSessionIds()string[]IDs of all connected sessions
room.getSessionCount()numberNumber of connected sessions
room.room(roomName)WebSocketServiceRoomAccess the named room system
room.getRoute()stringThe route path (e.g. /ws/game)

INFO

The session parameter in onOpen, onMessage, and onClose is still a regular WebSocketSession — the same as in per-connection mode. Only onTick receives a RealtimeRoomProxy.

tickRate Bounds

  • Minimum: 1 Hz (1 tick per second)
  • Maximum: 120 Hz (120 ticks per second)
  • Values outside this range are clamped automatically.
  • The deltaMs parameter reports the actual elapsed time, which may vary slightly from the configured interval due to scheduling jitter or long-running tick callbacks.

Wiring a Real-Time Route

Real-time scripts use the same WEBSOCKET ingress type — no special configuration needed:

ingress_create type=WEBSOCKET route="/ws/game" script_uuid=<game-uuid>

The presence of export const tickRate = N in the script automatically activates real-time mode. If the export is removed, the script reverts to per-connection mode on the next connection.

Filter Scripts

INFO

Filter script support for WebSocket ingress is planned but not yet implemented. The filterScriptUUID field is stored on WEBSOCKET ingress rules but is not executed during the WebSocket handshake or lifecycle. This will be added in a future release.

DieselEngine Scripting Documentation