Repository metrics
- Stars
- (3 個のスター)
- PR merge metrics
- (PR metrics pending)
説明
See #113
Overview
This playbook provides step-by-step instructions for adding WebSocket-based real-time collaboration to the RERUM API. The implementation enables multiple users to work on the same JSON documents simultaneously with Operational Transformation (OT) for conflict resolution.
Architecture Decisions
- WebSocket Library: Socket.IO (mature, handles reconnection, fallback support)
- Conflict Resolution: Operational Transformation (OT) - Google Docs style
- Update Granularity: Document-level (broadcast full document on changes)
- Cluster Support: Redis adapter for PM2 cluster mode
- Authentication: Auth0 Bearer token validation
Table of Contents
- Prerequisites
- Redis Installation on RHEL
- Package Dependencies
- Socket.IO Server Setup
- PM2 Configuration for Sticky Sessions
- WebSocket Authentication
- Document Room Management
- Real-Time Change Broadcasting
- Operational Transformation Implementation
- Integration with Existing Endpoints
- Client-Side Implementation Guide
- Testing Strategy
- Deployment Checklist
- Troubleshooting
1. Prerequisites
Required Infrastructure
- RHEL VM with Node.js 24 LTS
- PM2 process manager (already installed)
- MongoDB (already configured)
- Auth0 account (already configured)
- Ports 80/443 open (already configured)
New Requirements
- Redis server (installation instructions below)
- Additional npm packages (listed in Section 3)
Estimated Implementation Time
- Redis setup: 30 minutes
- Socket.IO integration: 2-4 hours
- OT implementation: 4-8 hours
- Testing & debugging: 2-4 hours
- Total: 1-2 days
2. Redis Installation on RHEL
Step 2.1: Install Redis
# For RHEL 8/9 with dnf
sudo dnf install redis -y
# OR for older RHEL versions with yum
sudo yum install epel-release -y
sudo yum install redis -y
Step 2.2: Configure Redis
# Edit Redis configuration
sudo vi /etc/redis/redis.conf
Make these changes in redis.conf:
# Bind to localhost only (secure for same-server access)
bind 127.0.0.1
# Set a password (IMPORTANT for security)
requirepass YOUR_SECURE_REDIS_PASSWORD
# Enable persistence (optional but recommended)
appendonly yes
# Set max memory (adjust based on your VM resources)
maxmemory 256mb
maxmemory-policy allkeys-lru
Step 2.3: Start and Enable Redis
# Start Redis
sudo systemctl start redis
# Enable Redis to start on boot
sudo systemctl enable redis
# Verify Redis is running
sudo systemctl status redis
# Test Redis connection
redis-cli -a YOUR_SECURE_REDIS_PASSWORD ping
# Should return: PONG
Step 2.4: Add Redis Environment Variables
Add to your .env file:
# Redis Configuration
REDIS_HOST=127.0.0.1
REDIS_PORT=6379
REDIS_PASSWORD=YOUR_SECURE_REDIS_PASSWORD
3. Package Dependencies
Step 3.1: Install Required Packages
cd /path/to/rerum_server_nodejs
# Socket.IO server
npm install socket.io@4.7.5
# Redis adapter for Socket.IO (cluster mode support)
npm install @socket.io/redis-adapter@8.3.0
# Redis client
npm install redis@4.6.14
# JSON patch for operational transformation
npm install fast-json-patch@3.1.1
# Optional: For more sophisticated OT
npm install ot-json1@1.0.2
Step 3.2: Update package.json
Your package.json dependencies should now include:
{
\"dependencies\": {
\"socket.io\": \"^4.7.5\",
\"@socket.io/redis-adapter\": \"^8.3.0\",
\"redis\": \"^4.6.14\",
\"fast-json-patch\": \"^3.1.1\",
\"ot-json1\": \"^1.0.2\"
}
}
4. Socket.IO Server Setup
Step 4.1: Create WebSocket Module
Create a new file websocket/index.js:
#!/usr/bin/env node
/**
* WebSocket server setup for RERUM real-time collaboration
* Uses Socket.IO with Redis adapter for PM2 cluster mode support
*/
import { Server } from 'socket.io'
import { createAdapter } from '@socket.io/redis-adapter'
import { createClient } from 'redis'
import dotenv from 'dotenv'
dotenv.config()
let io = null
let pubClient = null
let subClient = null
/**
* Initialize Socket.IO server with Redis adapter
* @param {http.Server} httpServer - The HTTP server instance
* @returns {Server} Socket.IO server instance
*/
async function initializeWebSocket(httpServer) {
// Create Socket.IO server
io = new Server(httpServer, {
cors: {
origin: \"*\",
methods: [\"GET\", \"POST\", \"PUT\", \"PATCH\", \"DELETE\"],
allowedHeaders: [\"Authorization\", \"Content-Type\"],
credentials: true
},
// Connection settings
pingTimeout: 60000,
pingInterval: 25000,
// Transport settings
transports: ['websocket', 'polling'],
// Allow upgrades from polling to websocket
allowUpgrades: true
})
// Set up Redis adapter for cluster mode
if (process.env.REDIS_HOST) {
try {
pubClient = createClient({
socket: {
host: process.env.REDIS_HOST,
port: parseInt(process.env.REDIS_PORT) || 6379
},
password: process.env.REDIS_PASSWORD
})
subClient = pubClient.duplicate()
await Promise.all([pubClient.connect(), subClient.connect()])
io.adapter(createAdapter(pubClient, subClient))
console.log('Socket.IO Redis adapter connected successfully')
} catch (error) {
console.error('Failed to connect Redis adapter:', error)
console.log('Falling back to in-memory adapter (single process mode)')
}
} else {
console.log('Redis not configured - using in-memory adapter (single process only)')
}
// Set up connection handling
setupConnectionHandlers(io)
return io
}
/**
* Set up Socket.IO connection handlers
* @param {Server} io - Socket.IO server instance
*/
function setupConnectionHandlers(io) {
io.on('connection', (socket) => {
console.log(`Client connected: ${socket.id}`)
// Document room management
socket.on('document:join', (documentId) => {
socket.join(`doc:${documentId}`)
console.log(`Client ${socket.id} joined document: ${documentId}`)
// Notify others in the room
socket.to(`doc:${documentId}`).emit('document:user-joined', {
socketId: socket.id,
documentId: documentId,
timestamp: new Date().toISOString()
})
})
socket.on('document:leave', (documentId) => {
socket.leave(`doc:${documentId}`)
console.log(`Client ${socket.id} left document: ${documentId}`)
// Notify others in the room
socket.to(`doc:${documentId}`).emit('document:user-left', {
socketId: socket.id,
documentId: documentId,
timestamp: new Date().toISOString()
})
})
// Handle client-initiated changes (for OT)
socket.on('document:local-change', (data) => {
// This will be handled by the OT system
// See Section 9 for OT implementation
})
socket.on('disconnect', (reason) => {
console.log(`Client disconnected: ${socket.id}, reason: ${reason}`)
})
socket.on('error', (error) => {
console.error(`Socket error for ${socket.id}:`, error)
})
})
}
/**
* Broadcast document change to all clients in a document room
* @param {string} documentId - The document ID
* @param {Object} document - The updated document
* @param {string} operation - The operation type (create, update, overwrite, etc.)
* @param {string} excludeSocketId - Socket ID to exclude from broadcast (optional)
*/
function broadcastDocumentChange(documentId, document, operation, excludeSocketId = null) {
if (!io) {
console.warn('Socket.IO not initialized - cannot broadcast')
return
}
const payload = {
documentId: documentId,
document: document,
operation: operation,
timestamp: new Date().toISOString(),
version: document.__rerum?.isOverwritten || document.__rerum?.createdAt
}
if (excludeSocketId) {
// Broadcast to all in room except the sender
io.to(`doc:${documentId}`).except(excludeSocketId).emit('document:change', payload)
} else {
// Broadcast to all in room
io.to(`doc:${documentId}`).emit('document:change', payload)
}
console.log(`Broadcasted ${operation} for document ${documentId}`)
}
/**
* Broadcast conflict notification
* @param {string} documentId - The document ID
* @param {Object} currentVersion - The current version of the document
* @param {Object} attemptedChange - The change that caused the conflict
* @param {string} targetSocketId - Socket ID to send conflict notification to
*/
function broadcastConflict(documentId, currentVersion, attemptedChange, targetSocketId) {
if (!io) return
const payload = {
documentId: documentId,
currentVersion: currentVersion,
attemptedChange: attemptedChange,
timestamp: new Date().toISOString(),
message: 'Document was modified by another user. Please review and retry.'
}
if (targetSocketId) {
io.to(targetSocketId).emit('document:conflict', payload)
} else {
io.to(`doc:${documentId}`).emit('document:conflict', payload)
}
}
/**
* Get Socket.IO server instance
* @returns {Server|null} Socket.IO server instance
*/
function getIO() {
return io
}
/**
* Graceful shutdown
*/
async function shutdown() {
if (io) {
io.close()
}
if (pubClient) {
await pubClient.quit()
}
if (subClient) {
await subClient.quit()
}
}
export {
initializeWebSocket,
broadcastDocumentChange,
broadcastConflict,
getIO,
shutdown
}
Step 4.2: Modify bin/rerum_v1.js
Update the server startup file to initialize WebSocket:
#!/usr/bin/env node
/**
* Module dependencies.
*/
import app from '../app.js'
import debug from 'debug'
debug('rerum_server_nodejs:server')
import http from \"http\"
import dotenv from \"dotenv\"
dotenv.config()
// Import WebSocket module
import { initializeWebSocket, shutdown } from '../websocket/index.js'
/**
* Get port from environment and store in Express.
*/
const port = process.env.PORT ?? 3001
app.set('port', port)
/**
* Create HTTP server.
*/
const server = http.createServer(app)
/**
* Initialize WebSocket server
*/
initializeWebSocket(server).then(() => {
console.log('WebSocket server initialized')
}).catch(err => {
console.error('Failed to initialize WebSocket:', err)
})
/**
* Listen on provided port, on all network interfaces.
*/
server.listen(port)
server.on('error', onError)
server.on('listening', onListening)
/**
* Control the keep alive header
*/
server.keepAliveTimeout = 8 * 1000
server.headersTimeout = 8.5 * 1000
/**
* Graceful shutdown handling
*/
process.on('SIGTERM', async () => {
console.log('SIGTERM received, shutting down gracefully')
await shutdown()
server.close(() => {
console.log('Server closed')
process.exit(0)
})
})
process.on('SIGINT', async () => {
console.log('SIGINT received, shutting down gracefully')
await shutdown()
server.close(() => {
console.log('Server closed')
process.exit(0)
})
})
/**
* Event listener for HTTP server \"error\" event.
*/
function onError(error) {
if (error.syscall !== 'listen') {
throw error
}
const bind = `Port ${port}`
switch (error.code) {
case 'EACCES':
console.error(bind + ' requires elevated privileges')
process.exit(1)
case 'EADDRINUSE':
console.error(bind + ' is already in use')
process.exit(1)
default:
throw error
}
}
/**
* Event listener for HTTP server \"listening\" event.
*/
function onListening() {
console.log(\"LISTENING ON \" + port)
console.log(\"WebSocket server available at ws://localhost:\" + port)
const addr = server.address()
const bind = typeof addr === 'string'
? 'pipe ' + addr
: 'port ' + addr.port
debug('Listening on ' + bind)
}
5. PM2 Configuration for Sticky Sessions
Step 5.1: Create PM2 Ecosystem File
Create ecosystem.config.cjs in the project root:
module.exports = {
apps: [{
name: 'rerum-api',
script: './bin/rerum_v1.js',
// Cluster mode settings
instances: 'max', // Or specify number: 4
exec_mode: 'cluster',
// Environment
env: {
NODE_ENV: 'production',
PORT: 3001
},
// Logging
log_date_format: 'YYYY-MM-DD HH:mm:ss Z',
error_file: './logs/error.log',
out_file: './logs/out.log',
merge_logs: true,
// Restart settings
max_memory_restart: '1G',
restart_delay: 4000,
// Watch settings (disable in production)
watch: false,
// Graceful shutdown
kill_timeout: 5000,
listen_timeout: 10000,
// Instance variance for port binding
increment_var: 'PORT',
// Node.js flags
node_args: '--max-old-space-size=1024'
}]
}
Step 5.2: Configure Nginx for Sticky Sessions (if using Nginx as reverse proxy)
If you're using Nginx in front of PM2, add sticky session support:
# /etc/nginx/conf.d/rerum.conf
upstream rerum_backend {
ip_hash; # Enables sticky sessions based on client IP
server 127.0.0.1:3001;
server 127.0.0.1:3002;
server 127.0.0.1:3003;
server 127.0.0.1:3004;
keepalive 64;
}
server {
listen 80;
listen 443 ssl;
server_name store.rerum.io;
# SSL configuration
ssl_certificate /path/to/cert.pem;
ssl_certificate_key /path/to/key.pem;
# WebSocket support
location /socket.io/ {
proxy_pass http://rerum_backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection \"upgrade\";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# WebSocket specific timeouts
proxy_connect_timeout 7d;
proxy_send_timeout 7d;
proxy_read_timeout 7d;
}
# Regular HTTP requests
location / {
proxy_pass http://rerum_backend;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Connection \"\";
# Keep-alive
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
}
}
Step 5.3: Alternative - PM2 with @socket.io/sticky
If you prefer not to use Nginx sticky sessions, you can use PM2's sticky cluster module:
npm install @socket.io/sticky
npm install @socket.io/cluster-adapter
Then modify bin/rerum_v1.js for sticky cluster:
// Alternative approach using @socket.io/sticky
import cluster from 'cluster'
import http from 'http'
import { setupMaster, setupWorker } from '@socket.io/sticky'
import { createAdapter, setupPrimary } from '@socket.io/cluster-adapter'
if (cluster.isPrimary) {
console.log(`Primary ${process.pid} is running`)
const httpServer = http.createServer()
setupMaster(httpServer, {
loadBalancingMethod: 'least-connection'
})
setupPrimary()
httpServer.listen(3001)
for (let i = 0; i < numCPUs; i++) {
cluster.fork()
}
} else {
// Worker process
const httpServer = http.createServer(app)
const io = new Server(httpServer)
io.adapter(createAdapter())
setupWorker(io)
// ... rest of setup
}
6. WebSocket Authentication
Step 6.1: Create Authentication Middleware
Create websocket/auth.js:
#!/usr/bin/env node
/**
* WebSocket authentication middleware
* Validates Auth0 Bearer tokens for WebSocket connections
*/
import dotenv from 'dotenv'
dotenv.config()
// JWT verification (you may need to adjust based on your Auth0 setup)
import { auth } from 'express-oauth2-jwt-bearer'
/**
* Validate Auth0 token from WebSocket handshake
* @param {Object} socket - Socket.IO socket instance
* @param {Function} next - Next middleware function
*/
async function authenticateSocket(socket, next) {
try {
const token = extractToken(socket)
if (!token) {
// Allow read-only connections without auth
// Or reject: return next(new Error('Authentication required'))
socket.data.authenticated = false
socket.data.agent = null
console.log(`Unauthenticated connection: ${socket.id}`)
return next()
}
// Verify token with Auth0
const decoded = await verifyAuth0Token(token)
if (decoded) {
socket.data.authenticated = true
socket.data.agent = decoded.sub || decoded.azp
socket.data.token = token
console.log(`Authenticated connection: ${socket.id}, agent: ${socket.data.agent}`)
return next()
} else {
return next(new Error('Invalid token'))
}
} catch (error) {
console.error('Socket authentication error:', error)
return next(new Error('Authentication failed'))
}
}
/**
* Extract Bearer token from socket handshake
* @param {Object} socket - Socket.IO socket instance
* @returns {string|null} Token or null
*/
function extractToken(socket) {
// Try Authorization header
const authHeader = socket.handshake.headers.authorization
if (authHeader && authHeader.startsWith('Bearer ')) {
return authHeader.substring(7)
}
// Try query parameter (for environments where headers aren't supported)
if (socket.handshake.auth && socket.handshake.auth.token) {
return socket.handshake.auth.token
}
// Try query string
if (socket.handshake.query && socket.handshake.query.token) {
return socket.handshake.query.token
}
return null
}
/**
* Verify Auth0 token
* This is a simplified version - adjust based on your Auth0 configuration
* @param {string} token - JWT token
* @returns {Object|null} Decoded token or null
*/
async function verifyAuth0Token(token) {
try {
// Use jose or jsonwebtoken for verification
// This example uses a simple fetch to Auth0 userinfo endpoint
// For production, use proper JWT verification
const response = await fetch(`${process.env.ISSUER_BASE_URL}/userinfo`, {
headers: {
'Authorization': `Bearer ${token}`
}
})
if (response.ok) {
return await response.json()
}
// Alternative: Verify JWT locally
// const { verify } = await import('jsonwebtoken')
// const jwksClient = require('jwks-rsa')
// ... implement proper JWT verification
return null
} catch (error) {
console.error('Token verification error:', error)
return null
}
}
/**
* Require authentication for write operations
* @param {Object} socket - Socket.IO socket instance
* @returns {boolean} Whether socket is authenticated
*/
function requireAuth(socket) {
if (!socket.data.authenticated) {
socket.emit('error', {
code: 'UNAUTHORIZED',
message: 'Authentication required for this operation'
})
return false
}
return true
}
/**
* Check if socket's agent matches document generator
* @param {Object} socket - Socket.IO socket instance
* @param {Object} document - RERUM document
* @returns {boolean} Whether agent is the generator
*/
function isGenerator(socket, document) {
const socketAgent = socket.data.agent
const docGenerator = document?.__rerum?.generatedBy
return socketAgent && docGenerator && socketAgent === docGenerator
}
export {
authenticateSocket,
requireAuth,
isGenerator,
extractToken
}
Step 6.2: Apply Authentication Middleware
Update websocket/index.js to use authentication:
import { authenticateSocket, requireAuth } from './auth.js'
function setupConnectionHandlers(io) {
// Apply authentication middleware
io.use(authenticateSocket)
io.on('connection', (socket) => {
console.log(`Client connected: ${socket.id}, authenticated: ${socket.data.authenticated}`)
// ... rest of handlers
// Example: Protected operation
socket.on('document:request-edit', (data) => {
if (!requireAuth(socket)) return
// Process edit request...
})
})
}
7. Document Room Management
Step 7.1: Create Room Manager
Create websocket/rooms.js:
#!/usr/bin/env node
/**
* Document room management for real-time collaboration
*/
import { getIO } from './index.js'
// Track active rooms and their metadata
const roomMetadata = new Map()
/**
* Join a document room
* @param {Object} socket - Socket.IO socket instance
* @param {string} documentId - The document ID to join
* @param {Object} options - Join options
*/
function joinDocumentRoom(socket, documentId, options = {}) {
const roomId = `doc:${documentId}`
// Join the room
socket.join(roomId)
// Track room metadata
if (!roomMetadata.has(roomId)) {
roomMetadata.set(roomId, {
documentId: documentId,
clients: new Set(),
createdAt: new Date().toISOString()
})
}
const room = roomMetadata.get(roomId)
room.clients.add(socket.id)
// Store document ID on socket for cleanup
if (!socket.data.rooms) {
socket.data.rooms = new Set()
}
socket.data.rooms.add(documentId)
console.log(`Socket ${socket.id} joined room ${roomId}. Total clients: ${room.clients.size}`)
return {
roomId: roomId,
clientCount: room.clients.size
}
}
/**
* Leave a document room
* @param {Object} socket - Socket.IO socket instance
* @param {string} documentId - The document ID to leave
*/
function leaveDocumentRoom(socket, documentId) {
const roomId = `doc:${documentId}`
// Leave the room
socket.leave(roomId)
// Update metadata
if (roomMetadata.has(roomId)) {
const room = roomMetadata.get(roomId)
room.clients.delete(socket.id)
// Clean up empty rooms
if (room.clients.size === 0) {
roomMetadata.delete(roomId)
console.log(`Room ${roomId} deleted (no clients)`)
}
}
// Update socket data
if (socket.data.rooms) {
socket.data.rooms.delete(documentId)
}
console.log(`Socket ${socket.id} left room ${roomId}`)
}
/**
* Leave all document rooms (on disconnect)
* @param {Object} socket - Socket.IO socket instance
*/
function leaveAllRooms(socket) {
if (socket.data.rooms) {
for (const documentId of socket.data.rooms) {
leaveDocumentRoom(socket, documentId)
// Notify others
const io = getIO()
if (io) {
io.to(`doc:${documentId}`).emit('document:user-left', {
socketId: socket.id,
documentId: documentId,
timestamp: new Date().toISOString()
})
}
}
}
}
/**
* Get clients in a document room
* @param {string} documentId - The document ID
* @returns {Array} Array of socket IDs
*/
function getDocumentClients(documentId) {
const roomId = `doc:${documentId}`
const room = roomMetadata.get(roomId)
return room ? Array.from(room.clients) : []
}
/**
* Get room metadata
* @param {string} documentId - The document ID
* @returns {Object|null} Room metadata
*/
function getRoomMetadata(documentId) {
const roomId = `doc:${documentId}`
return roomMetadata.get(roomId) || null
}
/**
* Check if a document has active collaborators
* @param {string} documentId - The document ID
* @returns {boolean} Whether document has active clients
*/
function hasActiveCollaborators(documentId) {
const roomId = `doc:${documentId}`
const room = roomMetadata.get(roomId)
return room && room.clients.size > 0
}
/**
* Get all active document rooms
* @returns {Array} Array of document IDs with active rooms
*/
function getActiveDocuments() {
return Array.from(roomMetadata.keys()).map(roomId => roomId.replace('doc:', ''))
}
export {
joinDocumentRoom,
leaveDocumentRoom,
leaveAllRooms,
getDocumentClients,
getRoomMetadata,
hasActiveCollaborators,
getActiveDocuments
}
8. Real-Time Change Broadcasting
Step 8.1: Create Broadcast Service
Create websocket/broadcast.js:
#!/usr/bin/env node
/**
* Real-time change broadcasting service
* Handles document change notifications across WebSocket clients
*/
import { getIO } from './index.js'
import { hasActiveCollaborators, getDocumentClients } from './rooms.js'
/**
* Operation types for RERUM
*/
const OperationType = {
CREATE: 'create',
UPDATE: 'update',
OVERWRITE: 'overwrite',
PATCH: 'patch',
SET: 'set',
UNSET: 'unset',
DELETE: 'delete',
RELEASE: 'release'
}
/**
* Broadcast a document change to all subscribers
* @param {Object} params - Broadcast parameters
* @param {string} params.documentId - The document ID
* @param {Object} params.document - The updated document
* @param {string} params.operation - The operation type
* @param {string} params.agentId - The agent who made the change
* @param {string} params.excludeSocketId - Socket to exclude from broadcast
* @param {Object} params.previousState - Previous document state (for OT)
*/
function broadcastChange({
documentId,
document,
operation,
agentId = null,
excludeSocketId = null,
previousState = null
}) {
const io = getIO()
if (!io) {
console.warn('Socket.IO not initialized')
return { broadcasted: false, reason: 'io_not_initialized' }
}
// Check if anyone is listening
if (!hasActiveCollaborators(documentId)) {
return { broadcasted: false, reason: 'no_collaborators' }
}
const payload = {
type: 'document:change',
documentId: documentId,
document: document,
operation: operation,
agentId: agentId,
version: extractVersion(document),
timestamp: new Date().toISOString(),
// Include previous state for OT conflict resolution
previousVersion: previousState ? extractVersion(previousState) : null
}
const roomId = `doc:${documentId}`
if (excludeSocketId) {
io.to(roomId).except(excludeSocketId).emit('document:change', payload)
} else {
io.to(roomId).emit('document:change', payload)
}
const clientCount = getDocumentClients(documentId).length
console.log(`Broadcasted ${operation} to ${clientCount} clients for document ${documentId}`)
return {
broadcasted: true,
clientCount: clientCount,
operation: operation
}
}
/**
* Broadcast a conflict notification
* @param {Object} params - Conflict parameters
* @param {string} params.documentId - The document ID
* @param {Object} params.currentDocument - Current document state in DB
* @param {Object} params.attemptedChange - The change that was attempted
* @param {string} params.targetSocketId - Specific socket to notify
* @param {string} params.conflictType - Type of conflict (version_mismatch, etc.)
*/
function broadcastConflict({
documentId,
currentDocument,
attemptedChange,
targetSocketId = null,
conflictType = 'version_mismatch'
}) {
const io = getIO()
if (!io) return
const payload = {
type: 'document:conflict',
documentId: documentId,
currentDocument: currentDocument,
currentVersion: extractVersion(currentDocument),
attemptedChange: attemptedChange,
conflictType: conflictType,
timestamp: new Date().toISOString(),
message: getConflictMessage(conflictType)
}
if (targetSocketId) {
io.to(targetSocketId).emit('document:conflict', payload)
} else {
io.to(`doc:${documentId}`).emit('document:conflict', payload)
}
console.log(`Broadcasted conflict (${conflictType}) for document ${documentId}`)
}
/**
* Broadcast document deletion
* @param {string} documentId - The document ID
* @param {string} agentId - The agent who deleted
*/
function broadcastDeletion(documentId, agentId) {
const io = getIO()
if (!io) return
const payload = {
type: 'document:deleted',
documentId: documentId,
agentId: agentId,
timestamp: new Date().toISOString()
}
io.to(`doc:${documentId}`).emit('document:deleted', payload)
console.log(`Broadcasted deletion for document ${documentId}`)
}
/**
* Extract version identifier from document
* @param {Object} document - RERUM document
* @returns {string} Version identifier
*/
function extractVersion(document) {
if (!document || !document.__rerum) return null
// For overwrites, use isOverwritten timestamp
if (document.__rerum.isOverwritten) {
return document.__rerum.isOverwritten
}
// For versioned updates, use the document ID
return document['@id'] || document.id || document._id
}
/**
* Get human-readable conflict message
* @param {string} conflictType - Type of conflict
* @returns {string} Message
*/
function getConflictMessage(conflictType) {
const messages = {
'version_mismatch': 'The document was modified by another user. Your changes could not be applied.',
'concurrent_edit': 'Another user is editing this document. Changes may conflict.',
'deleted': 'This document has been deleted.',
'released': 'This document has been released and cannot be modified.',
'unauthorized': 'You are not authorized to modify this document.'
}
return messages[conflictType] || 'A conflict occurred.'
}
export {
broadcastChange,
broadcastConflict,
broadcastDeletion,
OperationType,
extractVersion
}
9. Operational Transformation Implementation
Step 9.1: Create OT Manager
Create websocket/ot.js:
#!/usr/bin/env node
/**
* Operational Transformation (OT) Manager for RERUM
* Handles conflict resolution for concurrent document edits
*
* This implementation uses JSON Patch (RFC 6902) operations
* which are well-suited for JSON document transformation
*/
import * as jsonpatch from 'fast-json-patch'
/**
* OT Document state tracker
* Tracks pending operations and document versions
*/
class OTDocument {
constructor(documentId, initialState) {
this.documentId = documentId
this.serverState = JSON.parse(JSON.stringify(initialState))
this.version = 0
this.pendingOperations = []
this.history = []
}
/**
* Apply an operation to the server state
* @param {Array} patches - JSON Patch operations
* @param {string} agentId - Agent applying the operation
* @returns {Object} Result with new state and version
*/
applyOperation(patches, agentId) {
try {
// Validate patches
const errors = jsonpatch.validate(patches, this.serverState)
if (errors) {
return {
success: false,
error: 'Invalid patch operations',
details: errors
}
}
// Apply patches
const result = jsonpatch.applyPatch(
this.serverState,
patches,
true, // validate
true // mutate in place
)
this.version++
// Store in history
this.history.push({
version: this.version,
patches: patches,
agentId: agentId,
timestamp: new Date().toISOString()
})
// Trim history if too long
if (this.history.length > 100) {
this.history = this.history.slice(-50)
}
return {
success: true,
state: this.serverState,
version: this.version
}
} catch (error) {
return {
success: false,
error: error.message
}
}
}
/**
* Transform an operation against concurrent operations
* @param {Array} clientPatches - Client's patches
* @param {number} clientVersion - Client's base version
* @returns {Object} Transformed patches
*/
transformOperation(clientPatches, clientVersion) {
if (clientVersion === this.version) {
// No transformation needed
return { patches: clientPatches, transformed: false }
}
// Get operations that happened since client's version
const concurrentOps = this.history.filter(h => h.version > clientVersion)
if (concurrentOps.length === 0) {
return { patches: clientPatches, transformed: false }
}
// Transform client patches against each concurrent operation
let transformedPatches = clientPatches
for (const op of concurrentOps) {
transformedPatches = this.transformPatches(transformedPatches, op.patches)
}
return {
patches: transformedPatches,
transformed: true,
transformedAgainst: concurrentOps.length
}
}
/**
* Transform patches A against patches B
* This is a simplified transformation - for production use consider ot-json1
* @param {Array} patchesA - Patches to transform
* @param {Array} patchesB - Patches to transform against
* @returns {Array} Transformed patches
*/
transformPatches(patchesA, patchesB) {
// Simple transformation: adjust paths based on array operations
return patchesA.map(patchA => {
let transformed = { ...patchA }
for (const patchB of patchesB) {
// If B added/removed array elements, adjust A's indices
if (patchB.op === 'add' && patchA.path.startsWith(patchB.path)) {
// Shift indices after the added element
transformed = this.adjustArrayIndex(transformed, patchB.path, 1)
} else if (patchB.op === 'remove' && patchA.path.startsWith(patchB.path)) {
// Shift indices after the removed element
transformed = this.adjustArrayIndex(transformed, patchB.path, -1)
}
}
return transformed
})
}
/**
* Adjust array indices in a patch path
* @param {Object} patch - The patch to adjust
* @param {string} basePath - The base path of the array operation
* @param {number} delta - Amount to adjust (+1 for add, -1 for remove)
* @returns {Object} Adjusted patch
*/
adjustArrayIndex(patch, basePath, delta) {
const pathParts = patch.path.split('/')
const basePathParts = basePath.split('/')
// Find the array index in the path
for (let i = 0; i < pathParts.length; i++) {
const index = parseInt(pathParts[i])
if (!isNaN(index) && i === basePathParts.length - 1) {
const baseIndex = parseInt(basePathParts[basePathParts.length - 1])
if (index >= baseIndex) {
pathParts[i] = String(index + delta)
}
}
}
return { ...patch, path: pathParts.join('/') }
}
/**
* Get current state
* @returns {Object} Current document state
*/
getState() {
return JSON.parse(JSON.stringify(this.serverState))
}
/**
* Get current version
* @returns {number} Current version
*/
getVersion() {
return this.version
}
}
/**
* OT Manager - manages multiple documents
*/
class OTManager {
constructor() {
this.documents = new Map()
}
/**
* Initialize or get an OT document
* @param {string} documentId - Document ID
* @param {Object} initialState - Initial document state
* @returns {OTDocument} OT document instance
*/
getOrCreateDocument(documentId, initialState) {
if (!this.documents.has(documentId)) {
this.documents.set(documentId, new OTDocument(documentId, initialState))
}
return this.documents.get(documentId)
}
/**
* Apply a change from a client
* @param {string} documentId - Document ID
* @param {Array} patches - JSON Patch operations
* @param {number} clientVersion - Client's base version
* @param {string} agentId - Agent making the change
* @returns {Object} Result
*/
applyChange(documentId, patches, clientVersion, agentId) {
const doc = this.documents.get(documentId)
if (!doc) {
return { success: false, error: 'Document not found in OT manager' }
}
// Transform if needed
const { patches: transformedPatches, transformed } = doc.transformOperation(patches, clientVersion)
// Apply the (potentially transformed) patches
const result = doc.applyOperation(transformedPatches, agentId)
if (result.success) {
return {
success: true,
state: result.state,
version: result.version,
transformed: transformed,
appliedPatches: transformedPatches
}
}
return result
}
/**
* Update document state from external source (e.g., direct API call)
* @param {string} documentId - Document ID
* @param {Object} newState - New document state
*/
updateDocument(documentId, newState) {
const doc = this.documents.get(documentId)
if (doc) {
// Generate patches from old to new state
const patches = jsonpatch.compare(doc.serverState, newState)
if (patches.length > 0) {
doc.applyOperation(patches, 'external')
}
}
}
/**
* Remove document from OT manager
* @param {string} documentId - Document ID
*/
removeDocument(documentId) {
this.documents.delete(documentId)
}
/**
* Check if document is being tracked
* @param {string} documentId - Document ID
* @returns {boolean}
*/
hasDocument(documentId) {
return this.documents.has(documentId)
}
}
// Singleton instance
const otManager = new OTManager()
/**
* Generate JSON Patch from two document states
* @param {Object} oldDoc - Old document state
* @param {Object} newDoc - New document state
* @returns {Array} JSON Patch operations
*/
function generatePatch(oldDoc, newDoc) {
return jsonpatch.compare(oldDoc, newDoc)
}
/**
* Apply JSON Patch to a document
* @param {Object} doc - Document to patch
* @param {Array} patches - JSON Patch operations
* @returns {Object} Patched document
*/
function applyPatch(doc, patches) {
const cloned = JSON.parse(JSON.stringify(doc))
jsonpatch.applyPatch(cloned, patches)
return cloned
}
export {
OTManager,
OTDocument,
otManager,
generatePatch,
applyPatch
}
10. Integration with Existing Endpoints
Step 10.1: Modify controllers/overwrite.js
Add WebSocket broadcasting to the overwrite controller:
// Add at the top of the file
import { broadcastChange, broadcastConflict, OperationType } from '../websocket/broadcast.js'
import { otManager } from '../websocket/ot.js'
// Inside the overwrite function, after successful overwrite:
// (After line: res.json(newObject))
// Add this block:
// Broadcast change to WebSocket clients
const broadcastResult = broadcastChange({
documentId: id,
document: newObject,
operation: OperationType.OVERWRITE,
agentId: agentRequestingOverwrite,
excludeSocketId: req.headers['x-socket-id'], // Allow clients to exclude themselves
previousState: originalObject
})
// Update OT manager
if (otManager.hasDocument(id)) {
otManager.updateDocument(id, newObject)
}
// For conflict responses (409), add:
// (Inside the version mismatch block)
// Broadcast conflict notification
if (req.headers['x-socket-id']) {
broadcastConflict({
documentId: id,
currentDocument: originalObject,
attemptedChange: objectReceived,
targetSocketId: req.headers['x-socket-id'],
conflictType: 'version_mismatch'
})
}
Step 10.2: Modify controllers/putUpdate.js
Add WebSocket broadcasting to versioned updates:
// Add at the top
import { broadcastChange, OperationType } from '../websocket/broadcast.js'
// After successful update (after res.json()):
broadcastChange({
documentId: originalId,
document: newObject,
operation: OperationType.UPDATE,
agentId: getAgentClaim(req),
excludeSocketId: req.headers['x-socket-id']
})
// Also broadcast to the NEW document ID since it changed
broadcastChange({
documentId: newObject._id,
document: newObject,
operation: OperationType.UPDATE,
agentId: getAgentClaim(req),
excludeSocketId: req.headers['x-socket-id']
})
Step 10.3: Modify controllers/delete.js
Add WebSocket broadcasting for deletions:
// Add at the top
import { broadcastDeletion } from '../websocket/broadcast.js'
import { otManager } from '../websocket/ot.js'
// After successful deletion:
broadcastDeletion(id, agentRequestingDelete)
// Remove from OT manager
otManager.removeDocument(id)
Step 10.4: Create WebSocket Header Middleware
Create middleware to handle WebSocket-related headers in websocket/middleware.js:
#!/usr/bin/env node
/**
* Express middleware for WebSocket integration
*/
/**
* Middleware to extract WebSocket client ID from request headers
* Clients should include X-Socket-ID header with their socket.id
*/
function extractSocketId(req, res, next) {
req.socketId = req.headers['x-socket-id'] || null
next()
}
/**
* Middleware to add WebSocket info to response
* Useful for clients to know if real-time sync is available
*/
function addWebSocketInfo(req, res, next) {
res.set('X-WebSocket-Available', 'true')
res.set('X-WebSocket-Endpoint', '/socket.io/')
next()
}
export { extractSocketId, addWebSocketInfo }
Add to app.js:
import { extractSocketId, addWebSocketInfo } from './websocket/middleware.js'
// Add after other middleware
app.use(extractSocketId)
app.use(addWebSocketInfo)
11. Client-Side Implementation Guide
Step 11.1: Basic Client Setup
// client-example.js
import { io } from 'socket.io-client'
class RerumRealtimeClient {
constructor(serverUrl, options = {}) {
this.serverUrl = serverUrl
this.socket = null
this.token = options.token || null
this.documentId = null
this.localVersion = 0
this.pendingChanges = []
this.callbacks = {
onConnect: () => {},
onDisconnect: () => {},
onChange: () => {},
onConflict: () => {},
onError: () => {}
}
}
/**
* Connect to the WebSocket server
*/
connect() {
this.socket = io(this.serverUrl, {
transports: ['websocket', 'polling'],
auth: {
token: this.token
},
reconnection: true,
reconnectionAttempts: 5,
reconnectionDelay: 1000
})
this.socket.on('connect', () => {
console.log('Connected to RERUM WebSocket')
this.callbacks.onConnect(this.socket.id)
})
this.socket.on('disconnect', (reason) => {
console.log('Disconnected:', reason)
this.callbacks.onDisconnect(reason)
})
this.socket.on('document:change', (data) => {
console.log('Document changed:', data)
this.localVersion = data.version
this.callbacks.onChange(data)
})
this.socket.on('document:conflict', (data) => {
console.log('Conflict detected:', data)
this.callbacks.onConflict(data)
})
this.socket.on('document:deleted', (data) => {
console.log('Document deleted:', data)
this.callbacks.onChange({ ...data, operation: 'delete' })
})
this.socket.on('error', (error) => {
console.error('Socket error:', error)
this.callbacks.onError(error)
})
return this
}
/**
* Join a document room for real-time updates
* @param {string} documentId - The document ID to join
*/
joinDocument(documentId) {
this.documentId = documentId
this.socket.emit('document:join', documentId)
return this
}
/**
* Leave a document room
* @param {string} documentId - The document ID to leave
*/
leaveDocument(documentId) {
this.socket.emit('document:leave', documentId || this.documentId)
if (documentId === this.documentId) {
this.documentId = null
}
return this
}
/**
* Make an API request with socket ID for broadcast exclusion
* @param {string} endpoint - API endpoint
* @param {Object} options - Fetch options
*/
async apiRequest(endpoint, options = {}) {
const headers = {
'Content-Type': 'application/json',
...options.headers
}
// Add socket ID to exclude self from broadcasts
if (this.socket && this.socket.id) {
headers['X-Socket-ID'] = this.socket.id
}
// Add auth token
if (this.token) {
headers['Authorization'] = `Bearer ${this.token}`
}
const response = await fetch(`${this.serverUrl}${endpoint}`, {
...options,
headers
})
return response
}
/**
* Overwrite a document with optimistic locking
* @param {Object} document - The document to save
* @param {string} expectedVersion - Expected version for optimistic locking
*/
async overwrite(document, expectedVersion = null) {
const headers = {}
if (expectedVersion) {
headers['If-Overwritten-Version'] = expectedVersion
}
const response = await this.apiRequest('/v1/api/overwrite', {
method: 'PUT',
headers,
body: JSON.stringify(document)
})
if (response.status === 409) {
// Conflict - another user modified the document
const conflictData = await response.json()
this.callbacks.onConflict({
type: 'version_mismatch',
currentDocument: conflictData.currentVersion,
attemptedChange: document
})
return { success: false, conflict: true, data: conflictData }
}
if (!response.ok) {
throw new Error(`API error: ${response.status}`)
}
const result = await response.json()
this.localVersion = result.__rerum?.isOverwritten
return { success: true, data: result }
}
/**
* Set event callbacks
* @param {Object} callbacks - Callback functions
*/
on(callbacks) {
Object.assign(this.callbacks, callbacks)
return this
}
/**
* Disconnect from the server
*/
disconnect() {
if (this.socket) {
this.socket.disconnect()
}
}
/**
* Get the socket ID (useful for API requests)
*/
getSocketId() {
return this.socket?.id
}
}
export default RerumRealtimeClient
Step 11.2: Usage Example
// Example usage in a client application
import RerumRealtimeClient from './rerum-realtime-client.js'
// Initialize client
const client = new RerumRealtimeClient('https://store.rerum.io', {
token: 'your-auth0-access-token'
})
// Set up event handlers
client.on({
onConnect: (socketId) => {
console.log('Connected with ID:', socketId)
// Join the document you're editing
client.joinDocument('abcdef1234567890')
},
onDisconnect: (reason) => {
console.log('Disconnected:', reason)
// Show offline indicator
},
onChange: (data) => {
console.log('Document updated:', data)
// Update your local state/UI with the new document
updateLocalDocument(data.document)
},
onConflict: (data) => {
console.log('Conflict!', data)
// Handle conflict - show dialog, auto-merge, etc.
handleConflict(data)
},
onError: (error) => {
console.error('Error:', error)
}
})
// Connect
client.connect()
// Later, when saving changes:
async function saveDocument(document) {
const result = await client.overwrite(document, document.__rerum?.isOverwritten)
if (result.conflict) {
// Handle conflict
showConflictDialog(result.data.currentVersion, document)
} else {
// Success
console.log('Saved:', result.data)
}
}
// When done editing:
client.leaveDocument('abcdef1234567890')
client.disconnect()
Step 11.3: React Hook Example
// useRerumRealtime.js
import { useEffect, useState, useCallback, useRef } from 'react'
import RerumRealtimeClient from './rerum-realtime-client.js'
export function useRerumRealtime(serverUrl, token) {
const clientRef = useRef(null)
const [connected, setConnected] = useState(false)
const [document, setDocument] = useState(null)
const [conflict, setConflict] = useState(null)
useEffect(() => {
const client = new RerumRealtimeClient(serverUrl, { token })
client.on({
onConnect: () => setConnected(true),
onDisconnect: () => setConnected(false),
onChange: (data) => setDocument(data.document),
onConflict: (data) => setConflict(data)
})
client.connect()
clientRef.current = client
return () => {
client.disconnect()
}
}, [serverUrl, token])
const joinDocument = useCallback((docId) => {
clientRef.current?.joinDocument(docId)
}, [])
const leaveDocument = useCallback((docId) => {
clientRef.current?.leaveDocument(docId)
}, [])
const saveDocument = useCallback(async (doc) => {
return clientRef.current?.overwrite(doc, doc.__rerum?.isOverwritten)
}, [])
const clearConflict = useCallback(() => {
setConflict(null)
}, [])
return {
connected,
document,
conflict,
joinDocument,
leaveDocument,
saveDocument,
clearConflict,
socketId: clientRef.current?.getSocketId()
}
}
12. Testing Strategy
Step 12.1: Unit Tests
Create __tests__/websocket.test.js:
import { jest } from '@jest/globals'
import { OTDocument, OTManager, generatePatch, applyPatch } from '../websocket/ot.js'
describe('Operational Transformation', () => {
describe('OTDocument', () => {
test('should apply simple patches', () => {
const doc = new OTDocument('test-1', { name: 'Original' })
const result = doc.applyOperation(
[{ op: 'replace', path: '/name', value: 'Updated' }],
'agent-1'
)
expect(result.success).toBe(true)
expect(result.state.name).toBe('Updated')
expect(result.version).toBe(1)
})
test('should transform concurrent operations', () => {
const doc = new OTDocument('test-2', { items: ['a', 'b', 'c'] })
// First operation: add 'd' at index 3
doc.applyOperation(
[{ op: 'add', path: '/items/3', value: 'd' }],
'agent-1'
)
// Second operation: client at version 0 tries to add 'e' at index 3
const transformed = doc.transformOperation(
[{ op: 'add', path: '/items/3', value: 'e' }],
0 // client was at version 0
)
expect(transformed.transformed).toBe(true)
// The path should be adjusted to account for the first insert
expect(transformed.patches[0].path).toBe('/items/4')
})
})
describe('generatePatch', () => {
test('should generate correct patches', () => {
const oldDoc = { name: 'Old', count: 1 }
const newDoc = { name: 'New', count: 1, extra: true }
const patches = generatePatch(oldDoc, newDoc)
expect(patches).toContainEqual({ op: 'replace', path: '/name', value: 'New' })
expect(patches).toContainEqual({ op: 'add', path: '/extra', value: true })
})
})
})
Step 12.2: Integration Tests
Create __tests__/websocket-integration.test.js:
import { jest } from '@jest/globals'
import { createServer } from 'http'
import { Server } from 'socket.io'
import { io as Client } from 'socket.io-client'
describe('WebSocket Integration', () => {
let httpServer, ioServer, clientSocket
beforeAll((done) => {
httpServer = createServer()
ioServer = new Server(httpServer)
httpServer.listen(() => {
const port = httpServer.address().port
clientSocket = Client(`http://localhost:${port}`)
clientSocket.on('connect', done)
})
})
afterAll(() => {
ioServer.close()
clientSocket.close()
httpServer.close()
})
test('should join document room', (done) => {
ioServer.on('connection', (socket) => {
socket.on('document:join', (docId) => {
expect(docId).toBe('test-doc-123')
done()
})
})
clientSocket.emit('document:join', 'test-doc-123')
})
test('should receive document changes', (done) => {
clientSocket.on('document:change', (data) => {
expect(data.documentId).toBe('test-doc-123')
expect(data.document.name).toBe('Updated')
done()
})
// Simulate server broadcasting a change
ioServer.emit('document:change', {
documentId: 'test-doc-123',
document: { name: 'Updated' },
operation: 'overwrite'
})
})
})
Step 12.3: Manual Testing Checklist
## Manual Testing Checklist
### Connection Tests
- [ ] Client can connect to WebSocket server
- [ ] Client reconnects automatically after disconnect
- [ ] Authentication works with valid Auth0 token
- [ ] Unauthenticated clients are handled appropriately
### Document Room Tests
- [ ] Client can join a document room
- [ ] Client can leave a document room
- [ ] Multiple clients can join the same room
- [ ] Client leaving room notifies others
### Real-Time Updates
- [ ] Changes via /overwrite are broadcast to room
- [ ] Changes via /update are broadcast to room
- [ ] Changes via /patch are broadcast to room
- [ ] Deletions are broadcast to room
- [ ] Sender is excluded from their own broadcasts
### Conflict Handling
- [ ] 409 conflict is detected on version mismatch
- [ ] Conflict notification is sent to client
- [ ] Current document state is provided in conflict
### Cluster Mode (PM2)
- [ ] Multiple instances can communicate via Redis
- [ ] Sticky sessions work correctly
- [ ] Broadcasts reach all relevant clients across instances
13. Deployment Checklist
Pre-Deployment
## Pre-Deployment Checklist
### Environment Configuration
- [ ] Redis installed and running on RHEL VM
- [ ] Redis password configured in .env
- [ ] All new npm packages installed
- [ ] PM2 ecosystem.config.cjs created
### Code Changes
- [ ] websocket/ directory created with all modules
- [ ] bin/rerum_v1.js updated for WebSocket init
- [ ] Controllers updated to broadcast changes
- [ ] Middleware added to app.js
### Nginx Configuration (if applicable)
- [ ] WebSocket location block added
- [ ] Sticky sessions configured
- [ ] Proxy timeouts adjusted for long-lived connections
### Testing
- [ ] Unit tests passing
- [ ] Integration tests passing
- [ ] Manual testing completed
- [ ] Load testing performed
Deployment Steps
# 1. Stop current PM2 processes
pm2 stop all
# 2. Pull latest code
git pull origin main
# 3. Install dependencies
npm install
# 4. Verify Redis is running
sudo systemctl status redis
redis-cli -a $REDIS_PASSWORD ping
# 5. Run tests
npm run runtest
# 6. Start with new PM2 config
pm2 start ecosystem.config.cjs
# 7. Verify processes are running
pm2 status
pm2 logs
# 8. Test WebSocket connection
# Use a WebSocket client to connect to wss://store.rerum.io/socket.io/
# 9. Monitor for errors
pm2 logs --err
tail -f /var/log/nginx/error.log
Rollback Plan
# If issues occur:
# 1. Stop PM2
pm2 stop all
# 2. Revert to previous version
git checkout HEAD~1
# 3. Restart without WebSocket
pm2 start ./bin/rerum_v1.js -i max --name rerum-api
# 4. Verify
pm2 status
curl https://store.rerum.io/v1/api
14. Troubleshooting
Common Issues
WebSocket Connection Fails
Symptom: Clients can't establish WebSocket connection
Check:
-
Nginx WebSocket configuration
nginx -t grep -A 20 \"location /socket.io\" /etc/nginx/conf.d/rerum.conf -
Firewall rules
sudo firewall-cmd --list-all -
PM2 process status
pm2 status pm2 logs
Redis Connection Errors
Symptom: \"Failed to connect Redis adapter\" in logs
Check:
-
Redis service status
sudo systemctl status redis -
Redis connectivity
redis-cli -a $REDIS_PASSWORD ping -
Environment variables
cat .env | grep REDIS
Broadcasts Not Reaching All Clients
Symptom: Changes made by one client not visible to others
Check:
-
Redis adapter connected
pm2 logs | grep \"Redis adapter\" -
Clients in same room
// Add debug logging to rooms.js console.log('Room clients:', getDocumentClients(documentId)) -
Sticky sessions working
# Multiple requests should hit same worker for i in {1..10}; do curl -s https://store.rerum.io/v1/api | grep -o \"worker-[0-9]*\"; done
High Memory Usage
Symptom: PM2 processes using excessive memory
Check:
- Memory limits in ecosystem.config.cjs
- History size in OT manager
- Room cleanup on disconnect
Fix:
// Add to websocket/ot.js
// Periodic cleanup of inactive documents
setInterval(() => {
for (const [docId, doc] of otManager.documents) {
if (!hasActiveCollaborators(docId)) {
otManager.removeDocument(docId)
}
}
}, 300000) // Every 5 minutes
Appendix A: Environment Variables Reference
# Existing RERUM variables
RERUM_API_VERSION=1.1.0
RERUM_BASE=https://store.rerum.io
RERUM_PREFIX=https://store.rerum.io/v1/
RERUM_ID_PREFIX=https://store.rerum.io/v1/id/
MONGO_CONNECTION_STRING=mongodb://...
MONGODBNAME=rerum
MONGODBCOLLECTION=objects
# Auth0 variables
AUDIENCE=...
ISSUER_BASE_URL=https://...auth0.com/
CLIENTID=...
RERUMSECRET=...
# NEW: Redis variables
REDIS_HOST=127.0.0.1
REDIS_PORT=6379
REDIS_PASSWORD=your-secure-password
# NEW: WebSocket variables (optional)
WS_PING_TIMEOUT=60000
WS_PING_INTERVAL=25000
Appendix B: API Changes Summary
New Headers
| Header | Direction | Purpose |
|---|---|---|
X-Socket-ID |
Request | Client's socket ID for broadcast exclusion |
X-WebSocket-Available |
Response | Indicates WebSocket support |
X-WebSocket-Endpoint |
Response | WebSocket connection endpoint |
New WebSocket Events
| Event | Direction | Payload |
|---|---|---|
document:join |
Client → Server | documentId |
document:leave |
Client → Server | documentId |
document:change |
Server → Client | { documentId, document, operation, timestamp, version } |
document:conflict |
Server → Client | { documentId, currentDocument, attemptedChange, conflictType } |
document:deleted |
Server → Client | { documentId, agentId, timestamp } |
document:user-joined |
Server → Client | { socketId, documentId, timestamp } |
document:user-left |
Server → Client | { socketId, documentId, timestamp } |
Appendix C: Security Considerations
- Token Validation: Always validate Auth0 tokens on WebSocket connections
- Rate Limiting: Consider implementing rate limiting on WebSocket events
- Input Validation: Validate all document IDs and payloads
- Redis Security: Use password authentication, bind to localhost only
- TLS: Ensure all WebSocket connections use WSS (TLS)
- Room Authorization: Verify client has permission to join document rooms
Next Steps
After implementing this playbook:
- Monitor Performance: Track WebSocket connection counts, message rates
- Add Metrics: Integrate with your monitoring solution (Prometheus, etc.)
- Consider Presence: If needed later, add user presence features
- Optimize OT: For complex documents, consider using
ot-json1library - Add Cursor Sync: For collaborative editing, sync cursor positions
Document Version: 1.0 Last Updated: July 2025 Author: E1 (Emergent AI) " Observation: Create successful: /app/docs/WEBSOCKET_IMPLEMENTATION_PLAYBOOK.md