Realtime Hub Pattern
A comprehensive guide to designing bidirectional real-time communication hubs in Mindbricks. Covers Socket.IO rooms, built-in message types, custom message types, standard events, auto-bridged DataObject events, Kafka bridging, message persistence, room authorization, guardrails, server-side logic hooks (message interception, state management, scheduled actions), and REST fallback endpoints.
Realtime Hub Pattern
Overview
A RealtimeHub is a bidirectional Socket.IO communication channel that brings real-time messaging, presence, and custom events to your Mindbricks service. Unlike the older Realtime Service (a standalone microservice that bridges Kafka events to clients via topics), RealtimeHub lives inside a business service and provides room-based, full-duplex communication with built-in message types, automatic persistence, and rate limiting.
| Capability | Realtime Service | RealtimeHub |
|---|---|---|
| Transport | Socket.IO (one-way push) | Socket.IO (bidirectional) |
| Scope | Standalone microservice | Inside any business service |
| Model | Topic + rights-token authorization | Room + membership/ownership auth |
| Message flow | Kafka → Server → Client | Client ↔ Server ↔ Client |
| Persistence | Not built-in | Always-on auto-generated Message DataObject |
| Message types | N/A | Built-in (text, image, video, ...) + custom types |
| Standard events | No | Yes (typing, read receipts, presence, etc.) |
| Auto-bridge | Core function | Auto-bridges CRUD events from known DataObjects |
| REST fallback | No | Auto-generated REST endpoints |
Use RealtimeHub when: Users need to talk to each other in real time -- chat rooms, multiplayer games, collaborative editing, live dashboards, support tickets, auction bidding.
Use Realtime Service when: You only need one-way server-to-client push of backend events (e.g., order status notifications, live price feeds).
Architecture
Each RealtimeHub is mounted as a Socket.IO namespace (/hub/{hubName}) on the service's HTTP server. Rooms within the namespace correspond to records in an existing DataObject (e.g., a Chat, Match, or Dashboard). The framework handles:
-
Authentication -- Token-based auth middleware validates every socket connection via the existing session layer (HexaAuth).
-
Room authorization -- A configurable pipeline of role checks, script evaluations, and DataObject-based auth sources determines who can join each room and what hub role they receive.
-
Message types -- Built-in types (text, image, video, audio, document, sticker, contact, location, system) with known schemas, plus designer-defined custom message types for app-specific structured data.
-
Persistence -- Every hub auto-generates a Message DataObject. Messages are always persisted with system fields, a messageType discriminator, a content JSONB column, and denormalized sender identity (
senderName,senderAvatar) for fast history rendering. -
Standard events -- Typing indicators, recording indicators, read receipts, delivery receipts, and presence are built-in toggles.
-
Auto-bridge -- CRUD events from the room, membership, and message DataObjects are automatically bridged to connected clients via Kafka.
-
Custom events -- Named signals beyond messages for app-specific needs (game actions, cursor tracking).
-
External Kafka bridge -- Events from unrelated Kafka topics can be bridged to hub rooms.
-
REST endpoints -- Auto-generated REST routes for message history, deletion, and REST-based message sending.
-
Horizontal scaling -- Redis adapter for multi-instance deployments.
Pattern Structure
A RealtimeHub is a chapter in the Mindbricks ontology with 7 sections:
| Section | Purpose |
|---|---|
hubBasics | Name and description |
roomSettings | Room DataObject, authorization flow, auth sources, auth scripts |
hubRoles | Role definitions with granular permissions (read, send, moderate, etc.) |
messageSettings | Built-in message types, custom message types, cross-cutting features |
eventSettings | Standard events, auto-bridge, custom events, external Kafka events |
historySettings | Message history on join |
guardrails | Rate limits, size limits, connection limits, moderation settings |
Additionally, the service-level realtimeConfig section (inside ServiceSettings) controls the Socket.IO server itself (adapter, heartbeat, connection limits).
Service-Level Configuration: RealtimeConfig
Before designing individual hubs, configure the Socket.IO server at the service level:
{
"serviceSettings": {
"realtimeConfig": {
"adapter": "redis",
"heartbeatInterval": 25000,
"heartbeatTimeout": 20000,
"maxConnectionsPerUser": 10
}
}
}
| Property | Type | Default | Description |
|---|---|---|---|
adapter | "redis" | "memory" | "redis" | Scaling adapter. Use redis for production (multi-instance), memory for development. |
heartbeatInterval | Integer | 25000 | Ping interval in ms. |
heartbeatTimeout | Integer | 20000 | Pong timeout in ms. Must be less than interval. |
maxConnectionsPerUser | Integer | 10 | Max concurrent sockets per authenticated user. |
When adapter is redis, the generated package.json includes @socket.io/redis-adapter and the socket server initializer sets up the Redis pub/sub adapter automatically.
Hub Basics
{
"hubBasics": {
"name": "chatHub",
"description": "Real-time messaging hub for one-on-one and group conversations"
}
}
| Property | Type | Required | Description |
|---|---|---|---|
name | String | Yes | Unique identifier (camelCase). Becomes the namespace /hub/chatHub and REST prefix /chat-hub. |
description | Text | No | Human-readable purpose. Used in docs and AI context. |
Room Settings
Every hub requires a room DataObject -- an existing DataObject in the same service that represents the "room" entity. Users join rooms by referencing the DataObject record ID. The room settings define the authorization pipeline that decides who can join a room and what role they receive.
{
"roomSettings": {
"roomDataObject": "catalogEvent",
"roomEligibility": "catalogEvent.chatEnabled == true",
"absoluteRoles": ["superAdmin"],
"checkRoles": ["premiumUser", "subscriber"],
"checkScript": "user.city == catalogEvent.city",
"authSources": [
{ "name": "eventOwner", "sourceObject": "catalogEvent", "userField": "createdBy", "roomField": "id", "hubRole": "owner" },
{ "name": "eventHost", "sourceObject": "eventHost", "userField": "userId", "roomField": "eventId", "hubRole": "moderator" },
{ "name": "ticketHolder", "sourceObject": "issuedTicket", "userField": "ownerUserId", "roomField": "eventId", "condition": "issuedTicket.status == 'active'", "hubRole": "attendee" }
],
"authScripts": [
{ "name": "vipAccess", "script": "lib.checkVIPAccess(user, room)", "hubRole": "viewer" }
]
}
}
Properties
| Property | Type | Default | Description |
|---|---|---|---|
roomDataObject | LocalDataObjectName | -- | DataObject representing rooms in this hub. Selected from the service's DataObject list. |
roomEligibility | MScript | null | Condition evaluated against the room record. The context variable name matches the roomDataObject name (e.g., catalogEvent.chatEnabled). The generic alias room is also available. If false, nobody can join (including absoluteRoles). |
absoluteRoles | String[] | [] | List of role names that bypass all auth checks. Users with a matching role get the built-in system role with full permissions. |
checkRoles | String[] | [] | List of role names REQUIRED to proceed. Users without any of these roles are denied immediately. |
checkScript | MScript | null | Condition with user + roomDataObject name context (e.g., catalogEvent). Generic alias room is also available. Must return true to proceed to auth sources. |
authSources | Array | [] | Ordered DataObject-based auth sources. First match wins -- put highest-privilege sources first. |
authScripts | Array | [] | Ordered MScript-based auth sources. Checked after authSources. |
Auth Source Properties
Each entry in authSources queries an Elasticsearch-indexed DataObject to determine whether the user has a relationship with the room.
| Property | Type | Description |
|---|---|---|
name | String | Unique identifier for this auth source. Used in logs and documentation. |
sourceObject | DataObjectName | DataObject to query in Elasticsearch. Selected from the project's DataObject list. |
userField | PropRefer | Property on the sourceObject matching the user's ID. Selected from sourceObject's property list. |
roomField | PropRefer | Property on the sourceObject matching the room's ID. Selected from sourceObject's property list. |
condition | MScript | Optional condition evaluated on the found record. The context variable name matches the sourceObject name (e.g., issuedTicket). The generic alias record is also available. Example: issuedTicket.status == 'active'. |
hubRole | String | Optional. Hub role assigned to the user if this source matches. If null, the user is authorized with a default member role (read + send, no moderation). If provided, must reference a role defined in hubRoles. |
Auth Script Properties
Each entry in authScripts runs an MScript expression to determine authorization. Checked after all authSources have been tried.
| Property | Type | Description |
|---|---|---|
name | String | Unique identifier for this auth script. Used in logs and documentation. |
script | MScript | Expression with user + room context. Can call library functions via lib.*. Must return true for the source to match. |
hubRole | String | Optional. Hub role assigned to the user if the script returns true. If null, the user is authorized with a default member role. If provided, must reference a role defined in hubRoles. |
Authorization Flow
The framework evaluates authorization as a top-to-bottom pipeline. The first matching rule wins.
1. Fetch room record from Elasticsearch
2. Room not found → DENY
3. roomEligibility → eval({ roomDataObject, room }) → false → DENY (blocks everyone)
4. Check if user is BLOCKED (Redis/ES) → DENY
5. absoluteRoles → user.roleId in list → ALLOW with "system" role
6. checkRoles → not in list → DENY
7. checkScript → false → DENY
8. authSources top-to-bottom → first match → ALLOW with hubRole (or default "member") → BREAK
9. authScripts top-to-bottom → first match → ALLOW with hubRole (or default "member") → BREAK
10. No match → DENY
Cross-Service Auth Sources
Auth source queries use Elasticsearch rather than direct database queries. This allows the DataObjects referenced in authSources to live in a different service -- as long as they are indexed in Elasticsearch (which is the default for all DataObjects), the hub can query them across service boundaries. The Elasticsearch index name is automatically derived from the project codename and the DataObject name.
Hub Roles
Hub roles define what each user can do within a room. Roles are assigned by authSources and authScripts during the authorization flow. A built-in system role (all permissions enabled) is automatically assigned to users who match absoluteRoles.
If hubRoles is empty and auth sources/scripts use null for hubRole, all authorized members receive a default member role with read + send permissions, no moderation. This is the simplest configuration for hubs that do not need differentiated permissions.
{
"hubRoles": [
{ "name": "owner", "canRead": true, "canSend": true, "canModerate": true, "canDeleteAny": true, "canManageRoom": true },
{ "name": "moderator", "canRead": true, "canSend": true, "canModerate": true, "canDeleteAny": true },
{ "name": "attendee", "canRead": true, "canSend": true, "allowedMessageTypes": "text,image", "moderated": true },
{ "name": "viewer", "canRead": true, "canSend": false }
]
}
Permission Fields
| Permission | Default | Description |
|---|---|---|
name | -- | Role identifier referenced by authSources / authScripts. |
canRead | true | Can receive messages. |
canSend | true | Can send messages. |
allowedMessageTypes | all | Comma-separated message types this role can send. If omitted, all enabled types are allowed. |
moderated | false | Messages from this role require moderator approval before broadcast. |
canModerate | false | Can approve/reject pending messages, block/silence users. |
canDeleteAny | false | Can delete any message in the room. |
canManageRoom | false | Can update room settings, kick users. |
Block, Silence & Talk Permission
Runtime moderation actions stored in an auto-generated HubModeration DataObject and cached in Redis for fast lookups.
Block & Silence
| Action | Effect | Persistence |
|---|---|---|
| Block | User cannot enter the room. If currently connected, they are force-removed. | Elasticsearch + Redis cache |
| Silence | User can read messages but cannot send. | Elasticsearch + Redis cache |
Moderation Events
| Event | Direction | Who | Payload |
|---|---|---|---|
hub:block | Client → Server | canModerate | { roomId, userId, reason?, duration? } |
hub:unblock | Client → Server | canModerate | { roomId, userId } |
hub:silence | Client → Server | canModerate | { roomId, userId, reason?, duration? } |
hub:unsilence | Client → Server | canModerate | { roomId, userId } |
hub:blocked | Server → User | -- | { roomId, reason } (then force-leave) |
hub:unblocked | Server → User | -- | { roomId } (user may rejoin) |
hub:silenced | Server → User | -- | { roomId, reason } |
hub:unsilenced | Server → User | -- | { roomId } |
Duration: 0 or null = permanent. A positive value = seconds until automatic expiry.
Default Silenced Mode
When guardrails.defaultSilenced is true, all users join rooms in a silenced state. Users with canModerate permission are exempt. Others must explicitly request speak permission from a moderator.
| Event | Direction | Payload |
|---|---|---|
hub:requestSpeak | Client → Server | { roomId } |
hub:speakRequested | Server → Moderators | { roomId, userId } |
hub:grantSpeak | Moderator → Server | { roomId, userId } |
hub:revokeSpeak | Moderator → Server | { roomId, userId } |
hub:speakGranted | Server → User | { roomId } |
hub:speakRevoked | Server → User | { roomId } |
Message Moderation
When a role has moderated: true or guardrails.globalModeration is true, messages are saved with a status of "pending" instead of "approved". Only users with canModerate permission see pending messages. Moderators approve or reject pending messages:
| Event | Direction | Payload |
|---|---|---|
hub:approve | Moderator → Server | { roomId, messageId } |
hub:reject | Moderator → Server | { roomId, messageId, reason? } |
hub:messagePending | Server → Sender + Moderators | { roomId, messageId, sender?, message? } |
hub:messageRejected | Server → Room | { roomId, messageId, reason? } |
Approved messages are broadcast to the room as hub:messageArrived.
Message Settings
Built-in Message Types
Every hub selects which built-in message types to support. Each type has a known content schema managed by the framework -- no manual field definitions needed.
{
"messageSettings": {
"dataObjectName": "ChatHubMessage",
"messageTypes": ["text", "image", "video", "audio", "document", "location"],
"enableReplyTo": true,
"enableReaction": true,
"enableForwarded": true
}
}
| Type | Content Schema | Description |
|---|---|---|
text | { body } | Plain text message |
image | { mediaUrl, thumbnail, caption, width, height } | Image with optional caption |
video | { mediaUrl, thumbnail, caption, duration } | Video with preview |
audio | { mediaUrl, duration, waveform } | Voice message or audio clip |
document | { mediaUrl, fileName, fileSize, mimeType } | File attachment |
sticker | { stickerUrl, stickerPackId } | Sticker image |
contact | { contactName, contactPhone, contactUserId } | Shared contact card |
location | { lat, lng, address, label } | Location pin |
system | { systemAction, systemData } | Auto-generated system message (member joined, room renamed, etc.) |
Auto-Generated Message DataObject
Every hub generates a Message DataObject with these columns:
| Column | Type | Description |
|---|---|---|
id | ID | Primary key |
roomId | ID | Foreign key to the room DataObject |
senderId | ID | Foreign key to the authenticated user |
messageType | Enum | Discriminator: all built-in + custom type names |
content | JSONB | Type-specific payload (schema varies by messageType) |
timestamp | DateTime | Message creation time |
replyTo | JSON | (if enabled) Reply reference { id, preview } |
reaction | JSON | (if enabled) Reactions [{ emoji, userId, timestamp }] |
forwarded | Boolean | (if enabled) Whether message was forwarded |
Cross-Cutting Features
These apply to all message types:
| Toggle | Field Added | Description |
|---|---|---|
enableReplyTo | replyTo (JSON) | Any message can quote another |
enableReaction | reaction (JSON) | Any message can have emoji reactions |
enableForwarded | forwarded (Boolean) | Any message can be marked as forwarded |
Sender Identity in Messages
Every message persisted by the hub includes denormalized sender identity fields -- senderName and senderAvatar -- populated automatically from the user's session at send time. This eliminates the need for frontend clients to make separate user profile lookups when rendering chat history or incoming messages.
Real-time events (hub:messageArrived, hub:messagePending) include a sender envelope with { id, fullname, avatar }.
Persisted messages (history and REST responses) include senderName and senderAvatar directly on the message record.
The hub:presence event also includes fullname and avatar on the user object, so the frontend can display who joined or left with their display name and profile picture.
Custom Message Types
For app-specific structured data that doesn't fit the built-in types, define custom message types. Each custom type has a name, description, and field schema. The name is added to the messageType enum alongside built-in types. The fields are validated at runtime and stored in the content JSONB column.
{
"messageSettings": {
"dataObjectName": "GameChatMessage",
"messageTypes": ["text", "sticker"],
"customMessageTypes": [
{
"name": "chessMove",
"description": "A chess piece move with board coordinates",
"fields": [
{ "name": "from", "fieldType": "String", "required": true, "description": "Source square (e.g. e2)" },
{ "name": "to", "fieldType": "String", "required": true, "description": "Target square (e.g. e4)" },
{ "name": "piece", "fieldType": "Enum", "required": true, "enumValues": "pawn,rook,knight,bishop,queen,king" },
{ "name": "captured", "fieldType": "Enum", "required": false, "enumValues": "pawn,rook,knight,bishop,queen" },
{ "name": "isCheck", "fieldType": "Boolean", "required": false, "defaultValue": "false" },
{ "name": "notation", "fieldType": "String", "required": false, "description": "Standard algebraic notation" }
]
},
{
"name": "nudge",
"description": "A visual buzz/shake signal",
"fields": [
{ "name": "animation", "fieldType": "Enum", "required": false, "enumValues": "shake,bounce,flash", "defaultValue": "shake" }
]
}
]
}
}
The generated messageType enum becomes: text, sticker, chessMove, nudge.
Custom Field Types
| Type | Description |
|---|---|
String | Short text (up to 255 chars) |
Text | Long-form text |
Integer | Whole number |
Decimal | Floating point |
Boolean | True/false |
ID | UUID reference |
DateTime | ISO 8601 timestamp |
Enum | Fixed set of values (define in enumValues) |
JSON | Arbitrary JSON object or array |
Event Settings
Standard Events
Standard events are built-in, toggle-able signal types that every messaging app needs. Enable them with a single boolean -- the framework generates the socket handlers, payload validation, and client protocol.
{
"eventSettings": {
"enableTypingIndicator": true,
"enableRecordingIndicator": true,
"enableReadReceipts": true,
"enableDeliveryReceipts": true,
"enablePresence": true,
"autoBridgeDataEvents": true
}
}
| Toggle | Events Generated | Ephemeral | Description |
|---|---|---|---|
enableTypingIndicator | hub:typing, hub:stopTyping | Yes | User is typing / stopped typing |
enableRecordingIndicator | hub:recording, hub:stopRecording | Yes | User is recording a voice message |
enableReadReceipts | hub:messageRead | No | User has read messages up to a timestamp (blue ticks) |
enableDeliveryReceipts | hub:messageDelivered | No | Message was delivered to the recipient's device |
enablePresence | hub:online, hub:offline, hub:away | Yes | User connection state changes |
Auto-Bridged DataObject Events
When autoBridgeDataEvents is true (the default), the hub automatically subscribes to Kafka CRUD events from three DataObjects it already knows about:
From the Message DataObject:
| CRUD Event | Hub Event | Description |
|---|---|---|
{message}.updated | hub:messageEdited | A message was edited via REST/API |
{message}.deleted | hub:messageDeleted | A message was deleted via REST/API |
From the Auth Source DataObjects (when using authSources):
| CRUD Event | Hub Event | Description |
|---|---|---|
{authSource}.created | hub:memberJoined | New member added to the room |
{authSource}.deleted | hub:memberLeft | Member removed from the room |
{authSource}.updated | hub:memberUpdated | Member role or settings changed |
From the Room DataObject:
| CRUD Event | Hub Event | Description |
|---|---|---|
{room}.updated | hub:roomUpdated | Room name, icon, or settings changed |
{room}.deleted | hub:roomClosed | Room was deleted |
If the system message type is enabled, auto-bridged events also generate system messages (e.g., "Alice added Bob", "Room name changed to General").
Zero configuration required. The framework derives topic names and filter expressions from the DataObject names.
Custom Events
For app-specific signals that don't fit standard events or message types:
{
"eventSettings": {
"customEvents": [
{
"name": "cursorMoved",
"description": "User's cursor position in a collaborative editor",
"ephemeral": true,
"direction": "clientToRoom"
}
]
}
}
Event Directions
| Direction | Emitter | Receiver | Use case |
|---|---|---|---|
clientToRoom | Client via socket | All room members | Game actions, cursor position |
serverToClient | Server logic | Specific client | Private notifications |
serverToRoom | Server logic | All room members | System alerts |
External Kafka Events
For events from unrelated services or topics:
{
"eventSettings": {
"kafkaEvents": [
{
"name": "orderStatusChanged",
"description": "Order status update from the commerce service",
"topic": "order-events",
"filterExpression": "data.type === 'status.changed'",
"targetRoomExpression": "data.dashboardId"
}
]
}
}
| Property | Type | Description |
|---|---|---|
name | String | Event name emitted to clients. |
topic | String | Kafka topic to subscribe to. |
filterExpression | MScript | Filter incoming messages. Receives data. |
targetRoomExpression | MScript | Extract target room ID from the message. |
History Settings
Controls whether message history is delivered to clients when they join a room.
{
"historySettings": {
"historyEnabled": true,
"historyLimit": 50
}
}
| Property | Type | Default | Description |
|---|---|---|---|
historyEnabled | Boolean | true | Send recent messages on join. |
historyLimit | Integer | 50 | Number of most recent messages to send. |
Guardrails
Safety limits to prevent abuse and resource exhaustion.
{
"guardrails": {
"maxUsersPerRoom": 1000,
"maxRoomsPerUser": 50,
"messageRateLimit": 60,
"maxMessageSize": 65536,
"connectionTimeout": 300000,
"authCacheTTL": 300,
"globalModeration": false,
"defaultSilenced": false
}
}
| Property | Type | Default | Description |
|---|---|---|---|
maxUsersPerRoom | Integer | 1000 | Max concurrent users per room. |
maxRoomsPerUser | Integer | 50 | Max rooms a user can be in simultaneously. |
messageRateLimit | Integer | 60 | Max messages per user per minute. |
maxMessageSize | Integer | 65536 | Max message payload in bytes (64 KB). |
connectionTimeout | Integer | 300000 | Idle timeout in ms (5 minutes). |
authCacheTTL | Integer | 300 | Redis auth cache TTL in seconds. 0 = caching disabled. |
globalModeration | Boolean | false | When true, all messages require moderator approval regardless of role. |
defaultSilenced | Boolean | false | When true, all users join rooms silenced and must request speak permission. canModerate users are exempt. |
Hub Logic
HubLogic adds server-side processing hooks to a RealtimeHub. It allows intercepting custom message types with MScript before broadcast, delivering room state on join or on demand, and running scheduled actions on an interval. MScript hooks can call service library functions, Redis, DB, Business APIs, or external services -- the hub just provides the hook points and acts on the return values.
Pattern Structure
interface HubLogic = {
stateScript : MScript;
messageHandlers : HubMessageHandler[];
scheduledActions : HubScheduledAction[];
}
interface HubMessageHandler = {
messageType : String;
script : MScript;
}
interface HubScheduledAction = {
name : String;
intervalMs : Integer;
keepAlive : Boolean;
script : MScript;
}
Message Handlers
When a client sends a custom message type that has a matching handler, the hub intercepts the message instead of broadcasting it immediately. The handler's MScript is called with:
| Context Variable | Type | Description |
|---|---|---|
session | Object | Sender's session (userId, roleId, fullname, avatar, tenantId) |
message | Object | { type: "chessMove", content: { from: "e2", to: "e4" } } |
roomId | String | The room this message was sent in |
room | Object | The room DataObject record (fetched from Elasticsearch) |
lib | Object | Service library functions |
The script must return an object:
{
accept: true, // false to reject
rejectReason: "Not your turn", // sent to sender if rejected
broadcast: true, // false to suppress the original message
broadcastTo: "all", // "all" | "others" | "sender" | ["userId-1", "userId-2"]
modifiedContent: { ... }, // replace content before broadcast
persist: true, // false to skip persistence
serverMessages: [ // server-generated messages
{
type: "gameEvent",
content: { event: "check" },
to: "all", // "all" | "others" | "sender" | ["userId-1"]
persist: true // false to skip persistence
}
]
}
Per-room sequential queue: Intercepted messages are queued per-room and processed one at a time. This prevents race conditions when two messages arrive simultaneously for the same room (critical for games, auctions, and collaborative editing).
Standard messages pass through: Only custom message types with a matching handler are intercepted. Built-in types (text, image, sticker, etc.) and custom types without a handler follow the standard flow -- immediate broadcast and persistence.
State Script
The stateScript is called when a user joins a room and when a client emits hub:requestState. It fetches the current room state from wherever the designer stores it (Redis, DB, external service) and returns it to the requesting client only.
| Context Variable | Type | Description |
|---|---|---|
session | Object | Requesting user's session |
roomId | String | Room ID |
room | Object | Room DataObject record |
lib | Object | Service library functions |
The return value can be any shape -- the hub wraps it in a hub:state event:
// Client receives:
socket.on("hub:state", ({ roomId, state }) => {
renderChessBoard(state.board);
setTurnIndicator(state.turn);
});
// Client can request state anytime (e.g., after reconnect):
socket.emit("hub:requestState", { roomId });
Scheduled Actions
Scheduled actions run on a fixed interval for each active room. Each action's MScript is called every intervalMs milliseconds and can return serverMessages to broadcast.
| Field | Type | Default | Description |
|---|---|---|---|
name | String | — | Identifier for logging and lifecycle |
intervalMs | Integer | 1000 | Milliseconds between invocations |
keepAlive | Boolean | false | When true, keeps running even when the room has no connected users |
script | MScript | — | Called each tick; returns { serverMessages: [...] } |
Lifecycle:
- Starts when the first user joins the room
- Stops when the last user disconnects (unless
keepAlive: true) - Each room has its own independent timer
Example: Chess Game
{
"hubLogic": {
"stateScript": "lib.chess.getState(roomId)",
"messageHandlers": [
{
"messageType": "chessMove",
"script": "lib.chess.processMove(roomId, session, message)"
}
],
"scheduledActions": []
}
}
The service library function lib.chess.processMove handles validation, state updates, and generates server messages:
async function processMove(roomId, session, message) {
const state = JSON.parse(await redis.get(`chess:${roomId}`));
if (state.turn !== session.userId) {
return { accept: false, rejectReason: "Not your turn" };
}
const result = validateAndApply(state.board, message.content);
if (!result.valid) {
return { accept: false, rejectReason: result.reason };
}
await redis.set(`chess:${roomId}`, JSON.stringify(result.newState));
const serverMessages = [];
if (result.isCheckmate) {
serverMessages.push({
type: "gameEvent",
content: { event: "checkmate", winner: session.userId },
to: "all"
});
} else if (result.isCheck) {
serverMessages.push({
type: "gameEvent",
content: { event: "check" },
to: "all"
});
}
return { accept: true, broadcast: true, broadcastTo: "all", serverMessages };
}
Example: Multiplayer World with Ticks
{
"hubLogic": {
"stateScript": "lib.world.getVisibleState(roomId, session)",
"messageHandlers": [
{ "messageType": "playerMove", "script": "lib.world.processMove(roomId, session, message)" },
{ "messageType": "playerAttack", "script": "lib.world.processAttack(roomId, session, message)" }
],
"scheduledActions": [
{
"name": "worldTick",
"intervalMs": 1000,
"keepAlive": true,
"script": "lib.world.tick(roomId)"
}
]
}
}
Socket.IO Protocol
Connection
import { io } from "socket.io-client";
const socket = io("https://your-service.example.com/hub/chatHub", {
path: "/your-service-api/socket.io/", // HTTP path for reverse proxy routing
auth: { token: "Bearer <jwt-token>" },
transports: ["websocket", "polling"]
});
socket.on("connect", () => console.log("Connected to chatHub"));
socket.on("connect_error", (err) => console.error("Auth failed:", err.message));
Multi-Tenant Connection
For multi-tenant projects, include the tenant codename in the auth payload:
const socket = io("https://your-service.example.com/hub/chatHub", {
path: "/your-service-api/socket.io/",
auth: {
token: "Bearer <jwt-token>",
tenantCodename: "my-tenant" // required for multi-tenant projects
},
transports: ["websocket", "polling"]
});
Important: Do not rely on
extraHeadersfor the tenant codename. Browsers cannot send custom headers over the WebSocket transport -- only the HTTP polling transport receivesextraHeaders. Theauthpayload is delivered over all transports (WebSocket and polling), so it is the only reliable way to pass the tenant context.
Joining a Room
socket.emit("hub:join", { roomId: "chat-uuid-123", meta: { displayName: "Alice" } });
socket.on("hub:joined", ({ roomId }) => console.log("Joined room", roomId));
socket.on("hub:history", ({ roomId, messages }) => console.log(`${messages.length} messages loaded`));
socket.on("hub:error", ({ error }) => console.error(error));
Sending Messages
// Text message
socket.emit("hub:send", {
roomId: "chat-uuid-123",
messageType: "text",
content: { body: "Hello everyone!" }
});
// Image message
socket.emit("hub:send", {
roomId: "chat-uuid-123",
messageType: "image",
content: { mediaUrl: "https://cdn.example.com/photo.jpg", caption: "Check this out" }
});
// Custom message type (chess move)
socket.emit("hub:send", {
roomId: "match-uuid",
messageType: "chessMove",
content: { from: "e2", to: "e4", piece: "pawn", notation: "e4" }
});
// With reply
socket.emit("hub:send", {
roomId: "chat-uuid-123",
messageType: "text",
content: { body: "I agree!" },
replyTo: { id: "msg-uuid-456", preview: "Should we meet at 5?" }
});
Receiving Messages
socket.on("hub:messageArrived", ({ roomId, sender, message }) => {
console.log(`[${message.messageType}] from ${sender.id}:`, message.content);
});
Standard Events
// Emit typing
socket.emit("hub:event", { roomId: "chat-uuid-123", event: "typing", data: {} });
socket.emit("hub:event", { roomId: "chat-uuid-123", event: "stopTyping", data: {} });
// Receive typing
socket.on("hub:typing", ({ roomId, userId }) => console.log(userId, "is typing..."));
socket.on("hub:stopTyping", ({ roomId, userId }) => console.log(userId, "stopped typing"));
// Read receipts
socket.emit("hub:event", {
roomId: "chat-uuid-123",
event: "messageRead",
data: { lastReadTimestamp: "2025-01-15T10:30:00Z" }
});
socket.on("hub:messageRead", ({ roomId, userId, lastReadTimestamp }) => {
console.log(userId, "read up to", lastReadTimestamp);
});
// Presence
socket.on("hub:online", ({ roomId, userId }) => console.log(userId, "is online"));
socket.on("hub:offline", ({ roomId, userId }) => console.log(userId, "went offline"));
Auto-Bridged Events
// These fire automatically when DataObject CRUD operations happen via REST/API
socket.on("hub:memberJoined", ({ roomId, ...data }) => console.log("New member:", data));
socket.on("hub:memberLeft", ({ roomId, ...data }) => console.log("Member left:", data));
socket.on("hub:messageEdited", ({ roomId, ...data }) => console.log("Message edited:", data));
socket.on("hub:messageDeleted", ({ roomId, messageId }) => console.log("Deleted:", messageId));
socket.on("hub:roomUpdated", ({ roomId, ...data }) => console.log("Room updated:", data));
Full Event Reference
| Event | Direction | Payload | Description |
|---|---|---|---|
hub:join | Client → Server | { roomId, meta? } | Request to join a room |
hub:joined | Server → Client | { roomId } | Join confirmed |
hub:leave | Client → Server | { roomId } | Request to leave a room |
hub:send | Client → Server | { roomId, messageType, content, replyTo?, forwarded? } | Send a message |
hub:messageArrived | Server → Room | { roomId, sender, message } | New message broadcast (or approved moderated message) |
hub:history | Server → Client | { roomId, messages[] } | Historical messages on join |
hub:presence | Server → Room | { event, roomId, user } | Join/leave presence |
hub:event | Client → Server | { roomId, event, data } | Standard or custom event |
hub:typing | Server → Room | { roomId, userId } | Typing indicator |
hub:stopTyping | Server → Room | { roomId, userId } | Stopped typing |
hub:recording | Server → Room | { roomId, userId } | Recording voice |
hub:stopRecording | Server → Room | { roomId, userId } | Stopped recording |
hub:messageRead | Server → Room | { roomId, userId, lastReadTimestamp } | Read receipt |
hub:messageDelivered | Server → Room | { roomId, userId, messageId } | Delivery receipt |
hub:online | Server → Room | { roomId, userId } | User came online |
hub:offline | Server → Room | { roomId, userId } | User went offline |
hub:block | Client → Server | { roomId, userId, reason?, duration? } | Block a user (requires canModerate) |
hub:unblock | Client → Server | { roomId, userId } | Unblock a user (requires canModerate) |
hub:silence | Client → Server | { roomId, userId, reason?, duration? } | Silence a user (requires canModerate) |
hub:unsilence | Client → Server | { roomId, userId } | Unsilence a user (requires canModerate) |
hub:blocked | Server → User | { roomId, reason } | Notifies user they were blocked (then force-leave) |
hub:unblocked | Server → User | { roomId } | Notifies user they were unblocked (may rejoin) |
hub:silenced | Server → User | { roomId, reason } | Notifies user they were silenced |
hub:unsilenced | Server → User | { roomId } | Notifies user they were unsilenced |
hub:requestSpeak | Client → Server | { roomId } | Request speak permission (defaultSilenced mode) |
hub:speakRequested | Server → Moderators | { roomId, userId } | Notifies moderators of speak request |
hub:grantSpeak | Moderator → Server | { roomId, userId } | Grant speak permission |
hub:revokeSpeak | Moderator → Server | { roomId, userId } | Revoke speak permission |
hub:speakGranted | Server → User | { roomId } | Notifies user speak was granted |
hub:speakRevoked | Server → User | { roomId } | Notifies user speak was revoked |
hub:approve | Moderator → Server | { roomId, messageId } | Approve a pending message |
hub:reject | Moderator → Server | { roomId, messageId, reason? } | Reject a pending message |
hub:messagePending | Server → Sender + Moderators | { roomId, messageId, sender?, message? } | Message awaiting moderation |
hub:messageRejected | Server → Room | { roomId, messageId, reason? } | Moderated message was rejected |
hub:requestState | Client → Server | { roomId } | Request current room state (HubLogic) |
hub:state | Server → Client | { roomId, state } | Room state delivered (HubLogic) |
hub:rejected | Server → Client | { roomId, messageType, reason } | Intercepted message rejected by server logic |
hub:messageEdited | Server → Room | { roomId, ...record } | Auto-bridged: message edited |
hub:messageDeleted | Server → Room | { roomId, messageId, deletedBy } | Auto-bridged: message deleted |
hub:memberJoined | Server → Room | { roomId, ...record } | Auto-bridged: member added |
hub:memberLeft | Server → Room | { roomId, ...record } | Auto-bridged: member removed |
hub:memberUpdated | Server → Room | { roomId, ...record } | Auto-bridged: member updated |
hub:roomUpdated | Server → Room | { roomId, ...record } | Auto-bridged: room updated |
hub:roomClosed | Server → Room | { roomId, ...record } | Auto-bridged: room deleted |
hub:error | Server → Client | { roomId?, error } | Error notification |
REST Endpoints
REST endpoints are auto-generated for server-side access and fallback scenarios.
| Method | Path | Description |
|---|---|---|
GET | /{hub-name}/:roomId/messages | Paginated message history. Query params: limit, offset. |
POST | /{hub-name}/:roomId/messages | Send a message via REST (also broadcasts to connected clients). |
DELETE | /{hub-name}/:roomId/messages/:messageId | Delete a message (also broadcasts hub:messageDeleted). |
GET | /{hub-name}/:roomId/eligible | Check if a room supports real-time features. Returns the roomEligibility evaluation result for the given room. |
Complete Examples
Example 1: WhatsApp-Style Messenger
A messaging hub supporting 1-on-1 and group conversations with all media types, read receipts, typing indicators, role-based permissions, and auto-bridged membership events.
{
"hubBasics": {
"name": "messenger",
"description": "WhatsApp-style messaging hub with 1-on-1 and group conversations"
},
"roomSettings": {
"roomDataObject": "conversation",
"authSources": [
{ "name": "conversationAdmin", "sourceObject": "conversationMember", "userField": "userId", "roomField": "conversationId", "condition": "conversationMember.role == 'admin'", "hubRole": "admin" },
{ "name": "conversationMember", "sourceObject": "conversationMember", "userField": "userId", "roomField": "conversationId", "hubRole": "member" }
]
},
"hubRoles": [
{ "name": "admin", "canRead": true, "canSend": true, "canModerate": true, "canDeleteAny": true, "canManageRoom": true },
{ "name": "member", "canRead": true, "canSend": true }
],
"messageSettings": {
"dataObjectName": "ChatMessage",
"messageTypes": ["text", "image", "video", "audio", "document", "sticker", "contact", "location", "system"],
"enableReplyTo": true,
"enableReaction": true,
"enableForwarded": true
},
"eventSettings": {
"enableTypingIndicator": true,
"enableRecordingIndicator": true,
"enableReadReceipts": true,
"enableDeliveryReceipts": true,
"enablePresence": true,
"autoBridgeDataEvents": true
},
"historySettings": {
"historyEnabled": true,
"historyLimit": 50
},
"guardrails": {
"maxUsersPerRoom": 1024,
"maxRoomsPerUser": 500,
"messageRateLimit": 30,
"maxMessageSize": 65536,
"connectionTimeout": 600000
}
}
Example 2: Chess Game Hub
A game hub with text chat, stickers, and custom message types for chess moves and nudges.
{
"hubBasics": {
"name": "chessLobby",
"description": "Real-time chess game hub with move broadcasting and chat"
},
"roomSettings": {
"roomDataObject": "chessMatch",
"authSources": [
{ "name": "matchPlayer", "sourceObject": "matchPlayer", "userField": "playerId", "roomField": "matchId", "hubRole": "player" }
]
},
"hubRoles": [
{ "name": "player", "canRead": true, "canSend": true }
],
"messageSettings": {
"dataObjectName": "ChessMessage",
"messageTypes": ["text", "sticker", "system"],
"enableReplyTo": false,
"enableReaction": true,
"enableForwarded": false,
"customMessageTypes": [
{
"name": "chessMove",
"description": "A chess piece move with board coordinates",
"fields": [
{ "name": "from", "fieldType": "String", "required": true, "description": "Source square" },
{ "name": "to", "fieldType": "String", "required": true, "description": "Target square" },
{ "name": "piece", "fieldType": "Enum", "required": true, "enumValues": "pawn,rook,knight,bishop,queen,king" },
{ "name": "captured", "fieldType": "Enum", "required": false, "enumValues": "pawn,rook,knight,bishop,queen" },
{ "name": "isCheck", "fieldType": "Boolean", "required": false, "defaultValue": "false" },
{ "name": "isCheckmate", "fieldType": "Boolean", "required": false, "defaultValue": "false" },
{ "name": "notation", "fieldType": "String", "required": false }
]
},
{
"name": "nudge",
"description": "A visual nudge to get attention",
"fields": [
{ "name": "animation", "fieldType": "Enum", "required": false, "enumValues": "shake,bounce,flash", "defaultValue": "shake" }
]
},
{
"name": "drawOffer",
"description": "Player offers or responds to a draw",
"fields": [
{ "name": "action", "fieldType": "Enum", "required": true, "enumValues": "offer,accept,decline" }
]
}
]
},
"eventSettings": {
"enableTypingIndicator": true,
"enableRecordingIndicator": false,
"enableReadReceipts": false,
"enableDeliveryReceipts": false,
"enablePresence": true,
"autoBridgeDataEvents": true,
"customEvents": [
{
"name": "clockUpdate",
"description": "Timer sync from server",
"ephemeral": true,
"direction": "serverToRoom"
}
]
},
"historySettings": {
"historyEnabled": true,
"historyLimit": 200
},
"guardrails": {
"maxUsersPerRoom": 10,
"maxRoomsPerUser": 5,
"messageRateLimit": 120,
"maxMessageSize": 4096,
"connectionTimeout": 1800000
}
}
Example 3: Live Dashboard with Kafka Bridge
A read-mostly hub where backend events are pushed to dashboard viewers. Uses the system message type for auto-generated event logs and external Kafka events for metrics and alerts.
{
"hubBasics": {
"name": "liveDashboard",
"description": "Real-time dashboard streaming backend metrics and events"
},
"roomSettings": {
"roomDataObject": "dashboard",
"authSources": [
{ "name": "dashboardOwner", "sourceObject": "dashboard", "userField": "createdBy", "roomField": "id", "hubRole": "dashboardOwner" }
]
},
"hubRoles": [
{ "name": "dashboardOwner", "canRead": true, "canSend": false, "canManageRoom": true }
],
"messageSettings": {
"dataObjectName": "DashboardEvent",
"messageTypes": ["system"],
"enableReplyTo": false,
"enableReaction": false,
"enableForwarded": false
},
"eventSettings": {
"enableTypingIndicator": false,
"enableRecordingIndicator": false,
"enableReadReceipts": false,
"enableDeliveryReceipts": false,
"enablePresence": false,
"autoBridgeDataEvents": true,
"kafkaEvents": [
{
"name": "metricUpdate",
"description": "New metric data point",
"topic": "analytics-events",
"filterExpression": "data.type === 'metric'",
"targetRoomExpression": "data.dashboardId"
},
{
"name": "alertTriggered",
"description": "Threshold alert",
"topic": "analytics-events",
"filterExpression": "data.type === 'alert'",
"targetRoomExpression": "data.dashboardId"
}
]
},
"historySettings": {
"historyEnabled": false
},
"guardrails": {
"maxUsersPerRoom": 200,
"maxRoomsPerUser": 10,
"messageRateLimit": 10,
"maxMessageSize": 8192,
"connectionTimeout": 900000
}
}
Example 4: Customer Support Chat
A support hub with script-based auth, text and image messages, and a custom internalNote message type visible only to agents.
{
"hubBasics": {
"name": "supportChat",
"description": "Customer support live chat tied to support tickets"
},
"roomSettings": {
"roomDataObject": "supportTicket",
"absoluteRoles": ["supportAgent", "supportLead"],
"authSources": [
{ "name": "ticketCreator", "sourceObject": "supportTicket", "userField": "createdBy", "roomField": "id", "hubRole": "customer" }
]
},
"hubRoles": [
{ "name": "customer", "canRead": true, "canSend": true }
],
"messageSettings": {
"dataObjectName": "SupportMessage",
"messageTypes": ["text", "image", "document", "system"],
"enableReplyTo": false,
"enableReaction": false,
"enableForwarded": false,
"customMessageTypes": [
{
"name": "internalNote",
"description": "Internal note visible only to support agents",
"fields": [
{ "name": "note", "fieldType": "Text", "required": true },
{ "name": "isResolution", "fieldType": "Boolean", "required": false, "defaultValue": "false" }
]
}
]
},
"eventSettings": {
"enableTypingIndicator": true,
"enableRecordingIndicator": false,
"enableReadReceipts": true,
"enableDeliveryReceipts": false,
"enablePresence": true,
"autoBridgeDataEvents": true
},
"historySettings": {
"historyEnabled": true,
"historyLimit": 100
},
"guardrails": {
"maxUsersPerRoom": 10,
"maxRoomsPerUser": 20,
"messageRateLimit": 30,
"maxMessageSize": 65536,
"connectionTimeout": 600000
}
}
Example 5: Auction Bidding Room
A hub for live auctions with a custom bid message type, Kafka-bridged timer events, and message moderation for bids.
{
"hubBasics": {
"name": "auctionRoom",
"description": "Live auction bidding with real-time bid updates and timer"
},
"roomSettings": {
"roomDataObject": "auctionListing",
"roomEligibility": "auctionListing.status == 'active'",
"checkRoles": ["verifiedBidder"],
"authScripts": [
{ "name": "allVerifiedBidders", "script": "true", "hubRole": "bidder" }
]
},
"hubRoles": [
{ "name": "bidder", "canRead": true, "canSend": true, "allowedMessageTypes": "text,bid" }
],
"messageSettings": {
"dataObjectName": "AuctionActivity",
"messageTypes": ["text", "system"],
"enableReplyTo": false,
"enableReaction": false,
"enableForwarded": false,
"customMessageTypes": [
{
"name": "bid",
"description": "A bid placed on the auction listing",
"fields": [
{ "name": "amount", "fieldType": "Decimal", "required": true, "description": "Bid amount" },
{ "name": "currency", "fieldType": "String", "required": true, "defaultValue": "USD" },
{ "name": "isAutoBid", "fieldType": "Boolean", "required": false, "defaultValue": "false" }
]
}
]
},
"eventSettings": {
"enableTypingIndicator": false,
"enableRecordingIndicator": false,
"enableReadReceipts": false,
"enableDeliveryReceipts": false,
"enablePresence": true,
"autoBridgeDataEvents": true,
"kafkaEvents": [
{
"name": "auctionEnding",
"topic": "auction-events",
"filterExpression": "data.type === 'auction.ending'",
"targetRoomExpression": "data.listingId"
},
{
"name": "auctionClosed",
"topic": "auction-events",
"filterExpression": "data.type === 'auction.closed'",
"targetRoomExpression": "data.listingId"
}
]
},
"historySettings": {
"historyEnabled": true,
"historyLimit": 200
},
"guardrails": {
"maxUsersPerRoom": 10000,
"maxRoomsPerUser": 30,
"messageRateLimit": 10,
"maxMessageSize": 2048,
"connectionTimeout": 1800000
}
}
Example 6: Online Chess Platform with HubLogic
A complete online chess platform where two players play chess with a side chat panel. The game room is a ChessGame DataObject, moves are validated server-side via a chess engine in the service library, and the board state is stored in Redis for fast access with history backed up to a ChessMove DataObject. Chat messages (text, stickers) flow through standard hub mechanics, while chessMove messages are intercepted by HubLogic for validation before broadcast.
DataObjects
| DataObject | Purpose |
|---|---|
ChessGame | Room record — players, status (waiting/active/completed), result, winner, timeControl |
ChessGameMessage | Auto-generated message DataObject — chat messages AND moves (via messageType enum) |
ChessMove | Dedicated move history — from, to, piece, promotion, notation, moveNumber, fen (separate from messages for analysis/replay) |
ChessGameMembership | Player membership — userId, color (white/black), joined timestamp |
Pattern Configuration
{
"hubBasics": {
"name": "chessGame",
"description": "Online chess game with real-time moves, chat, and spectator mode"
},
"roomSettings": {
"roomDataObject": "ChessGame",
"roomEligibility": "chessGame.status !== 'completed'",
"absoluteRoles": ["superAdmin"],
"authSources": [
{
"name": "player",
"sourceObject": "ChessGameMembership",
"userField": "userId",
"roomField": "chessGameId",
"hubRole": "player"
}
],
"authScripts": [
{
"name": "spectatorAccess",
"script": "room.allowSpectators === true",
"hubRole": "spectator"
}
]
},
"hubRoles": [
{
"name": "player",
"canRead": true,
"canSend": true,
"canModerate": false,
"allowedMessageTypes": "text,sticker,chessMove,drawOffer,resign"
},
{
"name": "spectator",
"canRead": true,
"canSend": true,
"canModerate": false,
"allowedMessageTypes": "text,sticker"
}
],
"messageSettings": {
"dataObjectName": "ChessGameMessage",
"messageTypes": ["text", "sticker", "system"],
"enableReplyTo": false,
"enableReaction": true,
"enableForwarded": false,
"customMessageTypes": [
{
"name": "chessMove",
"description": "A chess move on the board",
"fields": [
{ "name": "from", "fieldType": "String", "required": true },
{ "name": "to", "fieldType": "String", "required": true },
{ "name": "promotion", "fieldType": "String", "required": false }
]
},
{
"name": "drawOffer",
"description": "Offer or respond to a draw",
"fields": [
{ "name": "action", "fieldType": "String", "required": true }
]
},
{
"name": "resign",
"description": "Player resigns the game",
"fields": []
},
{
"name": "gameEvent",
"description": "Server-generated game events (check, checkmate, draw, time)",
"fields": [
{ "name": "event", "fieldType": "String", "required": true },
{ "name": "winner", "fieldType": "String", "required": false },
{ "name": "reason", "fieldType": "String", "required": false }
]
}
]
},
"eventSettings": {
"enableTypingIndicator": true,
"enableRecordingIndicator": false,
"enableReadReceipts": false,
"enableDeliveryReceipts": false,
"enablePresence": true,
"autoBridgeDataEvents": true
},
"historySettings": {
"historyEnabled": true,
"historyLimit": 100
},
"guardrails": {
"maxUsersPerRoom": 50,
"maxRoomsPerUser": 5,
"messageRateLimit": 30,
"maxMessageSize": 4096,
"connectionTimeout": 1800000,
"authCacheTTL": 600
},
"hubLogic": {
"stateScript": "lib.chessEngine.getState(roomId, session)",
"messageHandlers": [
{
"messageType": "chessMove",
"script": "lib.chessEngine.processMove(roomId, session, message, lib)"
},
{
"messageType": "drawOffer",
"script": "lib.chessEngine.handleDrawOffer(roomId, session, message, lib)"
},
{
"messageType": "resign",
"script": "lib.chessEngine.handleResign(roomId, session, message, lib)"
}
],
"scheduledActions": [
{
"name": "clockTick",
"intervalMs": 1000,
"keepAlive": false,
"script": "lib.chessEngine.clockTick(roomId, lib)"
}
]
}
}
Service Library: chessEngine
These functions live in the service's library folder. The MScript hooks call them with the full context.
lib.chessEngine.getState(roomId, session) — Called on join and on hub:requestState:
const { getRedisData } = require("common");
async function getState(roomId, session) {
const raw = await getRedisData(`chess:state:${roomId}`);
if (!raw) return null;
const state = JSON.parse(raw);
return {
board: state.board,
turn: state.turn,
moveCount: state.moveCount,
whitePlayer: state.whitePlayer,
blackPlayer: state.blackPlayer,
whiteClock: state.whiteClock,
blackClock: state.blackClock,
status: state.status,
lastMove: state.lastMove,
capturedPieces: state.capturedPieces,
isCheck: state.isCheck,
legalMoves: state.turn === session.userId ? state.legalMoves : undefined
};
}
The legalMoves field is only sent to the player whose turn it is, preventing the opponent from seeing available moves.
lib.chessEngine.processMove(roomId, session, message, lib) — The core move handler:
const { getRedisData, setRedisData } = require("common");
const { createChessMove, updateChessGameById } = require("dbLayer");
async function processMove(roomId, session, message, lib) {
const raw = await getRedisData(`chess:state:${roomId}`);
if (!raw) return { accept: false, rejectReason: "Game not initialized" };
const state = JSON.parse(raw);
if (state.status !== "active") {
return { accept: false, rejectReason: "Game is not active" };
}
if (state.turn !== session.userId) {
return { accept: false, rejectReason: "Not your turn" };
}
const { from, to, promotion } = message.content;
const moveResult = validateAndApplyMove(state, from, to, promotion);
if (!moveResult.valid) {
return { accept: false, rejectReason: moveResult.reason };
}
const newState = moveResult.newState;
// Persist move to ChessMove DataObject for replay/analysis
await createChessMove({
chessGameId: roomId,
moveNumber: newState.moveCount,
playerId: session.userId,
from,
to,
piece: moveResult.piece,
captured: moveResult.captured || null,
promotion: promotion || null,
notation: moveResult.notation,
fen: newState.fen,
timestamp: new Date().toISOString()
});
// Save updated state to Redis
await setRedisData(`chess:state:${roomId}`, JSON.stringify(newState));
// Build the response
const serverMessages = [];
if (newState.isCheckmate) {
newState.status = "completed";
serverMessages.push({
type: "gameEvent",
content: { event: "checkmate", winner: session.userId, reason: "checkmate" },
to: "all"
});
await updateChessGameById(roomId, {
status: "completed",
result: "checkmate",
winnerId: session.userId
});
} else if (newState.isStalemate) {
newState.status = "completed";
serverMessages.push({
type: "gameEvent",
content: { event: "draw", reason: "stalemate" },
to: "all"
});
await updateChessGameById(roomId, {
status: "completed",
result: "draw",
reason: "stalemate"
});
} else if (newState.isCheck) {
serverMessages.push({
type: "gameEvent",
content: { event: "check" },
to: "all",
persist: false
});
}
// Update state in Redis with final status
await setRedisData(`chess:state:${roomId}`, JSON.stringify(newState));
return {
accept: true,
broadcast: true,
broadcastTo: "all",
modifiedContent: {
from,
to,
promotion: promotion || null,
piece: moveResult.piece,
captured: moveResult.captured || null,
notation: moveResult.notation,
fen: newState.fen,
isCheck: newState.isCheck
},
serverMessages
};
}
function validateAndApplyMove(state, from, to, promotion) {
// This is where the actual chess rules engine lives.
// Uses a chess library (or custom implementation) to:
// 1. Validate the move is legal for the current board position
// 2. Apply the move and compute the new board state
// 3. Detect check, checkmate, stalemate, draw conditions
// 4. Compute legal moves for the next player
// Returns: { valid, reason?, newState, piece, captured, notation }
}
lib.chessEngine.handleDrawOffer(roomId, session, message, lib):
async function handleDrawOffer(roomId, session, message, lib) {
const raw = await getRedisData(`chess:state:${roomId}`);
const state = JSON.parse(raw);
const { action } = message.content;
if (action === "offer") {
state.drawOffer = { from: session.userId, timestamp: Date.now() };
await setRedisData(`chess:state:${roomId}`, JSON.stringify(state));
return {
accept: true,
broadcast: true,
broadcastTo: "all",
serverMessages: [{
type: "gameEvent",
content: { event: "drawOffered", by: session.userId },
to: "all",
persist: false
}]
};
}
if (action === "accept" && state.drawOffer && state.drawOffer.from !== session.userId) {
state.status = "completed";
await setRedisData(`chess:state:${roomId}`, JSON.stringify(state));
await updateChessGameById(roomId, { status: "completed", result: "draw", reason: "agreement" });
return {
accept: true,
broadcast: false,
serverMessages: [{
type: "gameEvent",
content: { event: "draw", reason: "agreement" },
to: "all"
}]
};
}
if (action === "decline") {
state.drawOffer = null;
await setRedisData(`chess:state:${roomId}`, JSON.stringify(state));
return {
accept: true,
broadcast: false,
serverMessages: [{
type: "gameEvent",
content: { event: "drawDeclined" },
to: "all",
persist: false
}]
};
}
return { accept: false, rejectReason: "Invalid draw action" };
}
lib.chessEngine.handleResign(roomId, session, message, lib):
async function handleResign(roomId, session, message, lib) {
const raw = await getRedisData(`chess:state:${roomId}`);
const state = JSON.parse(raw);
const winnerId = state.whitePlayer === session.userId
? state.blackPlayer
: state.whitePlayer;
state.status = "completed";
await setRedisData(`chess:state:${roomId}`, JSON.stringify(state));
await updateChessGameById(roomId, {
status: "completed",
result: "resignation",
winnerId
});
return {
accept: true,
broadcast: false,
serverMessages: [{
type: "gameEvent",
content: { event: "resignation", winner: winnerId, resigned: session.userId },
to: "all"
}]
};
}
lib.chessEngine.clockTick(roomId, lib) — Scheduled action running every second:
async function clockTick(roomId, lib) {
const raw = await getRedisData(`chess:state:${roomId}`);
if (!raw) return { serverMessages: [] };
const state = JSON.parse(raw);
if (state.status !== "active") return { serverMessages: [] };
const activeColor = state.turn === state.whitePlayer ? "white" : "black";
if (activeColor === "white") {
state.whiteClock = Math.max(0, state.whiteClock - 1);
} else {
state.blackClock = Math.max(0, state.blackClock - 1);
}
await setRedisData(`chess:state:${roomId}`, JSON.stringify(state));
// Time expired
if (state.whiteClock <= 0 || state.blackClock <= 0) {
const loser = state.whiteClock <= 0 ? state.whitePlayer : state.blackPlayer;
const winner = loser === state.whitePlayer ? state.blackPlayer : state.whitePlayer;
state.status = "completed";
await setRedisData(`chess:state:${roomId}`, JSON.stringify(state));
await updateChessGameById(roomId, {
status: "completed",
result: "timeout",
winnerId: winner
});
return {
serverMessages: [
{
type: "gameEvent",
content: { event: "timeout", winner, loser },
to: "all"
}
]
};
}
// Broadcast clock update every second (ephemeral, not persisted)
return {
serverMessages: [
{
type: "gameEvent",
content: {
event: "clockUpdate",
whiteClock: state.whiteClock,
blackClock: state.blackClock
},
to: "all",
persist: false
}
]
};
}
Frontend Integration
import { io } from "socket.io-client";
const socket = io(`${baseUrl}/chess-api/hub/chessGame`, {
auth: { token: `Bearer ${accessToken}` },
transports: ["websocket"],
});
// ── Join a game room ──────────────────────────────────────────────────
socket.emit("hub:join", { roomId: gameId });
socket.on("hub:joined", ({ roomId, hubRole }) => {
isPlayer = hubRole === "player";
isSpectator = hubRole === "spectator";
});
// ── Receive board state (on join and on demand) ──────────────────────
socket.on("hub:state", ({ roomId, state }) => {
renderBoard(state.board);
setClocks(state.whiteClock, state.blackClock);
setTurnIndicator(state.turn);
if (state.legalMoves) highlightLegalMoves(state.legalMoves);
setPlayerInfo(state.whitePlayer, state.blackPlayer);
setCapturedPieces(state.capturedPieces);
});
// ── Receive all messages (chat + moves + game events) ────────────────
socket.on("hub:messageArrived", ({ sender, message }) => {
switch (message.messageType) {
case "chessMove":
animateMove(message.content.from, message.content.to);
updateBoardFromFen(message.content.fen);
addToMoveList(message.content.notation);
if (message.content.isCheck) flashCheck();
break;
case "gameEvent":
handleGameEvent(message.content);
break;
case "text":
case "sticker":
appendChatMessage(sender, message);
break;
}
});
// ── Handle rejected moves ────────────────────────────────────────────
socket.on("hub:rejected", ({ messageType, reason }) => {
if (messageType === "chessMove") {
shakeBoard();
showToast(`Invalid move: ${reason}`);
}
});
// ── Make a move ──────────────────────────────────────────────────────
function onPieceDrop(from, to, promotion) {
socket.emit("hub:send", {
roomId: gameId,
messageType: "chessMove",
content: { from, to, promotion }
});
}
// ── Chat messages (standard hub flow, no interception) ───────────────
function sendChatMessage(text) {
socket.emit("hub:send", {
roomId: gameId,
messageType: "text",
content: { body: text }
});
}
// ── Game actions ─────────────────────────────────────────────────────
function offerDraw() {
socket.emit("hub:send", {
roomId: gameId,
messageType: "drawOffer",
content: { action: "offer" }
});
}
function acceptDraw() {
socket.emit("hub:send", {
roomId: gameId,
messageType: "drawOffer",
content: { action: "accept" }
});
}
function resign() {
socket.emit("hub:send", {
roomId: gameId,
messageType: "resign",
content: {}
});
}
// ── Handle game events ───────────────────────────────────────────────
function handleGameEvent(content) {
switch (content.event) {
case "check":
flashCheck();
break;
case "checkmate":
showGameOver(`Checkmate! ${getPlayerName(content.winner)} wins.`);
break;
case "draw":
showGameOver(`Draw by ${content.reason}.`);
break;
case "resignation":
showGameOver(`${getPlayerName(content.resigned)} resigned. ${getPlayerName(content.winner)} wins.`);
break;
case "timeout":
showGameOver(`Time's up! ${getPlayerName(content.winner)} wins on time.`);
break;
case "clockUpdate":
setClocks(content.whiteClock, content.blackClock);
break;
case "drawOffered":
if (content.by !== myUserId) showDrawOfferDialog();
break;
case "drawDeclined":
showToast("Draw offer declined.");
break;
}
}
// ── Resync after reconnect ───────────────────────────────────────────
socket.on("connect", () => {
socket.emit("hub:join", { roomId: gameId });
});
socket.on("hub:requestState", { roomId: gameId });
// ── Chat history on join ─────────────────────────────────────────────
socket.on("hub:history", ({ messages }) => {
for (const msg of messages.reverse()) {
if (msg.messageType === "text" || msg.messageType === "sticker") {
appendChatMessage(msg.sender, msg);
}
}
});
Architecture Summary
Frontend (Chess Board + Chat Panel)
│
│ Single Socket.IO connection
│
▼
RealtimeHub: chessGame (Socket.IO namespace)
│
├── text, sticker messages ──────► Standard flow (persist + broadcast)
│
├── chessMove ──────────────────► HubLogic interception
│ │ │
│ │ Per-room queue (sequential) │
│ │ ▼
│ │ lib.chessEngine.processMove()
│ │ │
│ │ ┌───────────┼───────────┐
│ │ │ │ │
│ │ Read state Validate Write state
│ │ from Redis move to Redis
│ │ │
│ │ ┌───────────┼───────────┐
│ │ │ │ │
│ │ Save ChessMove Return Generate
│ │ to DataObject result serverMessages
│ │ │
│ ◄────────────────────────────────┘
│ │
│ ├── accept: true ──► broadcast chessMove to all players
│ ├── accept: false ─► hub:rejected to sender only
│ └── serverMessages ► gameEvent (check, checkmate) to all
│
├── drawOffer, resign ──────────► HubLogic interception (same pattern)
│
└── clockTick (every 1s) ───────► Scheduled action
│
└── lib.chessEngine.clockTick()
│
├── Decrement active player's clock in Redis
├── If time expired → gameEvent: timeout
└── clockUpdate → all (persist: false)
Using External Libraries: chess.js
Instead of writing a chess engine from scratch, you can use the chess.js npm library for move validation, legal move generation, and game-over detection. This example shows how to integrate an external npm package into the service and call it from the HubLogic MScript hooks.
Step 1: Add chess.js to the service's npm packages
In Service Settings, add the package to nodejsPackages:
{
"serviceSettings": {
"serviceBasics": {
"name": "chess",
"nodejsPackages": [
{
"packageName": "chess.js",
"version": "^1.0.0",
"defaultImportConst": "chessjs"
}
]
}
}
}
This adds "chess.js": "^1.0.0" to the service's package.json and makes it available for require("chess.js") in library functions.
Step 2: Rewrite the service library to use chess.js
The entire custom validateAndApplyMove function from the previous example is replaced by chess.js calls:
lib.chessEngine.getState(roomId, session):
const { Chess } = require("chess.js");
const { getRedisData } = require("common");
async function getState(roomId, session) {
const fen = await getRedisData(`chess:fen:${roomId}`);
const meta = JSON.parse(await getRedisData(`chess:meta:${roomId}`) || "{}");
if (!fen) return null;
const game = new Chess(fen);
const turnColor = game.turn(); // 'w' or 'b'
const turnUserId = turnColor === "w" ? meta.whitePlayer : meta.blackPlayer;
return {
fen,
board: game.board(),
turn: turnUserId,
turnColor,
moveCount: game.moveNumber(),
isCheck: game.isCheck(),
isGameOver: game.isGameOver(),
whitePlayer: meta.whitePlayer,
blackPlayer: meta.blackPlayer,
whiteClock: meta.whiteClock,
blackClock: meta.blackClock,
status: meta.status,
capturedPieces: meta.capturedPieces || { w: [], b: [] },
legalMoves: turnUserId === session.userId
? game.moves({ verbose: true }).map(m => ({ from: m.from, to: m.to, promotion: m.promotion }))
: undefined
};
}
lib.chessEngine.processMove(roomId, session, message, lib):
const { Chess } = require("chess.js");
const { getRedisData, setRedisData } = require("common");
const { createChessMove, updateChessGameById } = require("dbLayer");
async function processMove(roomId, session, message, lib) {
const fen = await getRedisData(`chess:fen:${roomId}`);
const meta = JSON.parse(await getRedisData(`chess:meta:${roomId}`) || "{}");
if (!fen) return { accept: false, rejectReason: "Game not initialized" };
if (meta.status !== "active") {
return { accept: false, rejectReason: "Game is not active" };
}
const game = new Chess(fen);
// Verify it's the sender's turn
const expectedPlayer = game.turn() === "w" ? meta.whitePlayer : meta.blackPlayer;
if (expectedPlayer !== session.userId) {
return { accept: false, rejectReason: "Not your turn" };
}
// Attempt the move using chess.js — it validates everything:
// legal squares, pins, checks, en passant, castling, promotion
const { from, to, promotion } = message.content;
let moveResult;
try {
moveResult = game.move({ from, to, promotion });
} catch (err) {
return { accept: false, rejectReason: "Illegal move" };
}
if (!moveResult) {
return { accept: false, rejectReason: "Illegal move" };
}
const newFen = game.fen();
// Track captured pieces
if (moveResult.captured) {
const capturedBy = moveResult.color; // 'w' or 'b'
meta.capturedPieces = meta.capturedPieces || { w: [], b: [] };
meta.capturedPieces[capturedBy].push(moveResult.captured);
}
// Persist the move to ChessMove DataObject
await createChessMove({
chessGameId: roomId,
moveNumber: game.moveNumber(),
playerId: session.userId,
from: moveResult.from,
to: moveResult.to,
piece: moveResult.piece,
captured: moveResult.captured || null,
promotion: moveResult.promotion || null,
notation: moveResult.san,
fen: newFen,
timestamp: new Date().toISOString()
});
// Save new FEN and meta to Redis
await setRedisData(`chess:fen:${roomId}`, newFen);
// Build server messages based on game state
const serverMessages = [];
if (game.isCheckmate()) {
meta.status = "completed";
serverMessages.push({
type: "gameEvent",
content: { event: "checkmate", winner: session.userId, reason: "checkmate" },
to: "all"
});
await updateChessGameById(roomId, {
status: "completed", result: "checkmate", winnerId: session.userId
});
} else if (game.isStalemate()) {
meta.status = "completed";
serverMessages.push({
type: "gameEvent",
content: { event: "draw", reason: "stalemate" },
to: "all"
});
await updateChessGameById(roomId, {
status: "completed", result: "draw", reason: "stalemate"
});
} else if (game.isDraw()) {
meta.status = "completed";
const reason = game.isInsufficientMaterial()
? "insufficient material"
: game.isThreefoldRepetition()
? "threefold repetition"
: "50-move rule";
serverMessages.push({
type: "gameEvent",
content: { event: "draw", reason },
to: "all"
});
await updateChessGameById(roomId, {
status: "completed", result: "draw", reason
});
} else if (game.isCheck()) {
serverMessages.push({
type: "gameEvent",
content: { event: "check" },
to: "all",
persist: false
});
}
await setRedisData(`chess:meta:${roomId}`, JSON.stringify(meta));
return {
accept: true,
broadcast: true,
broadcastTo: "all",
modifiedContent: {
from: moveResult.from,
to: moveResult.to,
piece: moveResult.piece,
captured: moveResult.captured || null,
promotion: moveResult.promotion || null,
notation: moveResult.san,
fen: newFen,
isCheck: game.isCheck(),
flags: moveResult.flags
},
serverMessages
};
}
What chess.js handles automatically:
- All legal move generation (pins, discovered checks, en passant, castling rights)
- Move validation —
game.move()throws if the move is illegal - Check, checkmate, stalemate, draw detection (50-move rule, insufficient material, threefold repetition)
- FEN generation after each move
- SAN notation (
e4,Nxf3+,O-O,e8=Q) - Turn management (
game.turn()returns'w'or'b')
What you still handle in your lib functions:
- State storage (Redis for live FEN, DB for move history)
- Player identity mapping (userId to color)
- Clock management (chess.js doesn't handle time controls)
- Game lifecycle (creating games, matching players, handling resignations/draw offers)
- Persisting moves to the ChessMove DataObject for replay
Step 3: Initialize game state when a game is created
When a new ChessGame DataObject is created (via Business API), a post-create action seeds the Redis state:
const { Chess } = require("chess.js");
const { setRedisData } = require("common");
async function initializeChessGame(gameId, whitePlayerId, blackPlayerId, timeControlSeconds) {
const game = new Chess(); // standard starting position
await setRedisData(`chess:fen:${gameId}`, game.fen());
await setRedisData(`chess:meta:${gameId}`, JSON.stringify({
whitePlayer: whitePlayerId,
blackPlayer: blackPlayerId,
whiteClock: timeControlSeconds,
blackClock: timeControlSeconds,
status: "active",
capturedPieces: { w: [], b: [] }
}));
}
This pattern — npm package in service settings, require in lib function, call from MScript — works for any external library. The same approach applies to game engines, physics simulations, math libraries, AI/ML clients, or domain-specific validators.
Best Practices
-
Select only the message types you need. Don't enable all 9 built-in types if your app only sends text and images. Fewer types = smaller API surface and simpler client code.
-
Use custom message types for structured app data. Chess moves, bids, poll votes, form submissions -- anything with a defined schema. The framework validates fields before broadcast and persistence.
-
Keep custom message types light. Store large payloads (media, files) as URLs pointing to the Bucket Service. The
contentJSONB column should stay under a few KB. -
Enable**
system**** message type for auditable hubs.** When combined withautoBridgeDataEvents, the hub auto-generates system messages for membership changes and room updates -- giving users a visible activity log. -
Use standard event toggles instead of custom events. Typing indicators, read receipts, and presence are so common they deserve the built-in treatment. Only use custom events for truly app-specific signals.
-
Leave**
autoBridgeDataEvents**** on.** It's free and keeps connected clients in sync with REST/API changes. Only disable it if you have a specific reason (e.g., the hub's DataObjects don't use Kafka). -
Use Redis adapter in production. The
memoryadapter only works with a single service instance. Two or more instances behind a load balancer require Redis. -
Set conservative guardrails. Start tight:
messageRateLimit: 10-30for chat,60-120for games. SetmaxUsersPerRoombased on actual expected sizes, not theoretical maximums. -
Order authSources by privilege. Place the highest-privilege source first (e.g., owner before moderator before member). The pipeline stops at the first match, so ordering determines which role a user receives when they match multiple sources.
-
Use roomEligibility to gate the UI. If a room can toggle chat on/off, use
roomEligibilityso the frontend can check the same field to show/hide the chat widget -- and the backend enforces the same rule at join time. -
Keep HubLogic scripts thin. The MScript in
messageHandlersandscheduledActionsshould be a single function call likelib.chess.processMove(roomId, session, message, lib). Put all business logic in service library functions where you have full JavaScript, imports, and testability. -
Store fast state in Redis, history in DB. For games and stateful hubs, use Redis for the live state (board position, clocks, scores) and persist significant events to a DataObject (moves, bids, transactions) for replay and analysis. The
stateScriptreads from Redis; the message handler writes to both. -
Use
persist: falsefor ephemeral server messages. Clock ticks, typing-like indicators, and transient notifications don't need to be stored. Setpersist: falseon serverMessages that are only relevant in real time.
Last updated Feb 27, 2026
Built with Documentation.AI