🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
371 lines
11 KiB
JavaScript
Executable File
371 lines
11 KiB
JavaScript
Executable File
#!/usr/bin/env node
|
|
|
|
/**
|
|
* RSpade Realtime WebSocket Server
|
|
*
|
|
* Dumb relay: validates HMAC tokens, routes messages by site_id + topic + filter.
|
|
* Zero business logic, zero database access. PHP is the authority.
|
|
*
|
|
* Usage: node system/bin/realtime-server.js
|
|
*
|
|
* Requires .env: APP_KEY, REDIS_HOST, REDIS_PORT, REDIS_PASSWORD,
|
|
* REALTIME_WS_PORT (default 6200)
|
|
*/
|
|
|
|
const { WebSocketServer } = require('ws');
|
|
const { createClient } = require('redis');
|
|
const crypto = require('crypto');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Configuration
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// Load .env from project root
|
|
const env_path = path.resolve(__dirname, '../../.env');
|
|
if (fs.existsSync(env_path)) {
|
|
const env_content = fs.readFileSync(env_path, 'utf8');
|
|
for (const line of env_content.split('\n')) {
|
|
const trimmed = line.trim();
|
|
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
const eq = trimmed.indexOf('=');
|
|
if (eq === -1) continue;
|
|
const key = trimmed.substring(0, eq);
|
|
let value = trimmed.substring(eq + 1);
|
|
// Strip surrounding quotes
|
|
if ((value.startsWith('"') && value.endsWith('"')) ||
|
|
(value.startsWith("'") && value.endsWith("'"))) {
|
|
value = value.slice(1, -1);
|
|
}
|
|
if (!process.env[key]) {
|
|
process.env[key] = value;
|
|
}
|
|
}
|
|
}
|
|
|
|
const WS_PORT = parseInt(process.env.REALTIME_WS_PORT || '6200', 10);
|
|
const APP_KEY = process.env.APP_KEY || '';
|
|
const REDIS_HOST = process.env.REDIS_HOST || '127.0.0.1';
|
|
const REDIS_PORT = parseInt(process.env.REDIS_PORT || '6379', 10);
|
|
const REDIS_PASSWORD = process.env.REDIS_PASSWORD === 'null' ? undefined : process.env.REDIS_PASSWORD;
|
|
const REDIS_PREFIX = 'rsx_rt';
|
|
|
|
const HEARTBEAT_INTERVAL = 30000; // 30 seconds
|
|
const PONG_TIMEOUT = 10000; // 10 seconds to respond
|
|
const AUTH_TIMEOUT = 5000; // 5 seconds to authenticate after connect
|
|
|
|
if (!APP_KEY) {
|
|
console.error('[realtime] APP_KEY not set in .env');
|
|
process.exit(1);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// State
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// ws → { user_id, site_id, session_id, authenticated, subscriptions: Map<sub_id, {topic, filter}>, alive }
|
|
const connections = new Map();
|
|
|
|
// "site_id:topic:filter_hash" → { topic, data, ts }
|
|
const last_messages = new Map();
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Token validation
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function validate_token(token_string) {
|
|
const dot = token_string.indexOf('.');
|
|
if (dot === -1) return null;
|
|
|
|
const payload_b64 = token_string.substring(0, dot);
|
|
const signature = token_string.substring(dot + 1);
|
|
|
|
let json;
|
|
try {
|
|
json = Buffer.from(payload_b64, 'base64').toString('utf8');
|
|
} catch {
|
|
return null;
|
|
}
|
|
|
|
// Verify HMAC
|
|
const expected = crypto.createHmac('sha256', APP_KEY).update(json).digest('hex');
|
|
if (!crypto.timingSafeEqual(Buffer.from(signature, 'hex'), Buffer.from(expected, 'hex'))) {
|
|
return null;
|
|
}
|
|
|
|
let payload;
|
|
try {
|
|
payload = JSON.parse(json);
|
|
} catch {
|
|
return null;
|
|
}
|
|
|
|
// Check expiry
|
|
if (payload.exp && payload.exp < Math.floor(Date.now() / 1000)) {
|
|
return null;
|
|
}
|
|
|
|
return payload;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Filter matching
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function matches_filter(subscription_filter, message_data) {
|
|
if (!subscription_filter || Object.keys(subscription_filter).length === 0) return true;
|
|
for (const key in subscription_filter) {
|
|
if (String(message_data[key]) !== String(subscription_filter[key])) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function filter_hash(filter) {
|
|
if (!filter || Object.keys(filter).length === 0) return '_';
|
|
const sorted = Object.keys(filter).sort().map(k => k + '=' + filter[k]).join('&');
|
|
return crypto.createHash('md5').update(sorted).digest('hex').substring(0, 12);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// WebSocket Server
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const wss = new WebSocketServer({ port: WS_PORT });
|
|
|
|
wss.on('listening', () => {
|
|
console.log(`[realtime] WebSocket server listening on port ${WS_PORT}`);
|
|
});
|
|
|
|
wss.on('connection', (ws) => {
|
|
const conn = {
|
|
user_id: null,
|
|
site_id: null,
|
|
session_id: null,
|
|
authenticated: false,
|
|
subscriptions: new Map(),
|
|
alive: true,
|
|
};
|
|
connections.set(ws, conn);
|
|
|
|
// Require authentication within timeout
|
|
const auth_timer = setTimeout(() => {
|
|
if (!conn.authenticated) {
|
|
ws.close(4001, 'Authentication timeout');
|
|
}
|
|
}, AUTH_TIMEOUT);
|
|
|
|
ws.on('pong', () => {
|
|
conn.alive = true;
|
|
});
|
|
|
|
ws.on('message', (raw) => {
|
|
let msg;
|
|
try {
|
|
msg = JSON.parse(raw.toString());
|
|
} catch {
|
|
return;
|
|
}
|
|
|
|
if (msg.type === 'auth') {
|
|
handle_auth(ws, conn, msg, auth_timer);
|
|
} else if (!conn.authenticated) {
|
|
ws.close(4001, 'Not authenticated');
|
|
} else if (msg.type === 'subscribe') {
|
|
handle_subscribe(ws, conn, msg);
|
|
} else if (msg.type === 'unsubscribe') {
|
|
handle_unsubscribe(conn, msg);
|
|
}
|
|
});
|
|
|
|
ws.on('close', () => {
|
|
clearTimeout(auth_timer);
|
|
connections.delete(ws);
|
|
});
|
|
|
|
ws.on('error', () => {
|
|
clearTimeout(auth_timer);
|
|
connections.delete(ws);
|
|
});
|
|
});
|
|
|
|
function handle_auth(ws, conn, msg, auth_timer) {
|
|
if (conn.authenticated) return;
|
|
|
|
const payload = validate_token(msg.token || '');
|
|
if (!payload || !payload.user_id || payload.site_id === undefined) {
|
|
ws.close(4003, 'Invalid token');
|
|
return;
|
|
}
|
|
|
|
conn.user_id = payload.user_id;
|
|
conn.site_id = payload.site_id;
|
|
conn.session_id = payload.session_id;
|
|
conn.authenticated = true;
|
|
clearTimeout(auth_timer);
|
|
|
|
ws.send(JSON.stringify({ type: 'auth_ok' }));
|
|
}
|
|
|
|
function handle_subscribe(ws, conn, msg) {
|
|
const payload = validate_token(msg.token || '');
|
|
if (!payload || !payload.topic) {
|
|
ws.send(JSON.stringify({ type: 'error', message: 'Invalid subscribe token' }));
|
|
return;
|
|
}
|
|
|
|
// Site ID must match connection
|
|
if (payload.site_id !== conn.site_id) {
|
|
ws.send(JSON.stringify({ type: 'error', message: 'Site mismatch' }));
|
|
return;
|
|
}
|
|
|
|
const sub_id = msg.sub_id;
|
|
const topic = payload.topic;
|
|
const filter = payload.filter || {};
|
|
|
|
conn.subscriptions.set(sub_id, { topic, filter });
|
|
|
|
ws.send(JSON.stringify({ type: 'subscribed', sub_id }));
|
|
|
|
// Replay last message if available
|
|
const lm_key = `${conn.site_id}:${topic}:${filter_hash(filter)}`;
|
|
const last = last_messages.get(lm_key);
|
|
if (last) {
|
|
ws.send(JSON.stringify({
|
|
type: 'message',
|
|
topic: last.topic,
|
|
data: last.data,
|
|
ts: last.ts,
|
|
replay: true,
|
|
}));
|
|
}
|
|
}
|
|
|
|
function handle_unsubscribe(conn, msg) {
|
|
conn.subscriptions.delete(msg.sub_id);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Heartbeat - ping every 30s, kill unresponsive connections
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const heartbeat = setInterval(() => {
|
|
for (const [ws, conn] of connections) {
|
|
if (!conn.alive) {
|
|
ws.terminate();
|
|
connections.delete(ws);
|
|
continue;
|
|
}
|
|
conn.alive = false;
|
|
ws.ping();
|
|
}
|
|
}, HEARTBEAT_INTERVAL);
|
|
|
|
wss.on('close', () => {
|
|
clearInterval(heartbeat);
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Redis subscriber - receive messages from PHP
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async function start_redis() {
|
|
const subscriber = createClient({
|
|
socket: {
|
|
host: REDIS_HOST,
|
|
port: REDIS_PORT,
|
|
},
|
|
password: REDIS_PASSWORD,
|
|
});
|
|
|
|
subscriber.on('error', (err) => {
|
|
console.error('[realtime] Redis error:', err.message);
|
|
});
|
|
|
|
await subscriber.connect();
|
|
console.log(`[realtime] Connected to Redis at ${REDIS_HOST}:${REDIS_PORT}`);
|
|
|
|
// Subscribe to pattern rsx_rt:*
|
|
await subscriber.pSubscribe(`${REDIS_PREFIX}:*`, (message, channel) => {
|
|
let msg;
|
|
try {
|
|
msg = JSON.parse(message);
|
|
} catch {
|
|
return;
|
|
}
|
|
|
|
const topic = msg.topic;
|
|
const data = msg.data || {};
|
|
const site_id = msg.site_id;
|
|
const ts = msg.ts || Math.floor(Date.now() / 1000);
|
|
|
|
// Store as last message for replay
|
|
// Store both with filter hash and without (wildcard subscribers get unfiltered last msg)
|
|
const lm_key_unfiltered = `${site_id}:${topic}:_`;
|
|
last_messages.set(lm_key_unfiltered, { topic, data, ts });
|
|
|
|
// Also store with data-derived filter hashes for common filter patterns
|
|
// (Subscribers with specific filters will match against their own filter hash)
|
|
if (data.id !== undefined) {
|
|
const lm_key_id = `${site_id}:${topic}:${filter_hash({ id: data.id })}`;
|
|
last_messages.set(lm_key_id, { topic, data, ts });
|
|
}
|
|
|
|
// Route to matching WebSocket connections
|
|
for (const [ws, conn] of connections) {
|
|
if (!conn.authenticated) continue;
|
|
if (conn.site_id !== site_id) continue;
|
|
|
|
for (const [sub_id, sub] of conn.subscriptions) {
|
|
if (sub.topic !== topic) continue;
|
|
if (!matches_filter(sub.filter, data)) continue;
|
|
|
|
ws.send(JSON.stringify({
|
|
type: 'message',
|
|
topic,
|
|
data,
|
|
ts,
|
|
}));
|
|
break; // Only send once per connection even if multiple subs match
|
|
}
|
|
}
|
|
});
|
|
|
|
return subscriber;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Startup
|
|
// ---------------------------------------------------------------------------
|
|
|
|
start_redis().catch((err) => {
|
|
console.error('[realtime] Failed to connect to Redis:', err.message);
|
|
process.exit(1);
|
|
});
|
|
|
|
// Graceful shutdown
|
|
process.on('SIGTERM', () => {
|
|
console.log('[realtime] Shutting down...');
|
|
wss.close();
|
|
process.exit(0);
|
|
});
|
|
|
|
process.on('SIGINT', () => {
|
|
console.log('[realtime] Shutting down...');
|
|
wss.close();
|
|
process.exit(0);
|
|
});
|
|
|
|
// Log stats periodically
|
|
setInterval(() => {
|
|
const conn_count = connections.size;
|
|
let sub_count = 0;
|
|
for (const [, conn] of connections) {
|
|
sub_count += conn.subscriptions.size;
|
|
}
|
|
if (conn_count > 0) {
|
|
console.log(`[realtime] Connections: ${conn_count}, Subscriptions: ${sub_count}, Cached messages: ${last_messages.size}`);
|
|
}
|
|
}, 60000);
|