Appearance
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)
- 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 (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 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...
}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:
| Aspect | Per-Connection (default) | Real-Time (tickRate) |
|---|---|---|
| GraalVM context | One per connection | One per route (shared) |
| Module-level state | Private per connection | Shared across all connections |
| Server-initiated push | Not possible (reactive only) | onTick(room, deltaMs) at configured Hz |
| Threading | Synchronized on handler | Dedicated route thread |
| Use case | Chat, notifications | Game servers, physics, live dashboards |
How Real-Time Mode Works
- 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.
- Subsequent clients connect — they join the existing shared context.
onOpen(session)is called on the route thread. - Every tick —
onTick(room, deltaMs)is called with the actual milliseconds elapsed since the last tick. - Client sends a message —
onMessage(session, message)is dispatched to the route thread. - Client disconnects —
onClose(session)is called on the route thread. - 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:
| Method | Returns | Description |
|---|---|---|
room.broadcast(message) | void | Send to all connected sessions on this route |
room.broadcastExcept(sessionId, message) | void | Send to all except one session |
room.send(sessionId, message) | boolean | Send to a specific session; returns true if sent |
room.getSession(sessionId) | WebSocketSession | null | Get a session proxy by ID |
room.getSessionIds() | string[] | IDs of all connected sessions |
room.getSessionCount() | number | Number of connected sessions |
room.room(roomName) | WebSocketServiceRoom | Access the named room system |
room.getRoute() | string | The 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
deltaMsparameter 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.