- js/config.js: connection config with URL param + env var override - WS URL, auth token, mock mode toggle - Computed isLive and wsUrlWithAuth getters - Resolves #7 (config.js) - Resolves #11 (Phase 1 shared-secret auth via query param) - js/websocket.js: refactored to use Config for live/mock switching - Live mode: real WS with reconnection + exponential backoff - Auth token appended as ?token= on WS connect - agent_joined handler dispatches to addAgent() for hot-add - sendMessage() public API for UI → backend communication - js/agents.js: dynamic agent hot-add and removal - addAgent(def): spawns 3D avatar at runtime without reload - autoPlace(): finds unoccupied circular slot (radius 8+) - removeAgent(id): clean dispose + connection line rebuild - Connection distance threshold 8→14 for larger agent rings - Resolves #12 (dynamic agent hot-add)
This commit is contained in:
146
js/websocket.js
146
js/websocket.js
@@ -1,8 +1,18 @@
|
||||
import { AGENT_DEFS, colorToCss } from './agent-defs.js';
|
||||
import { setAgentState } from './agents.js';
|
||||
import { appendChatMessage } from './ui.js';
|
||||
/**
|
||||
* websocket.js — WebSocket client for The Matrix.
|
||||
*
|
||||
* Two modes controlled by Config:
|
||||
* - Live mode: connects to a real Timmy Tower backend via Config.wsUrlWithAuth
|
||||
* - Mock mode: runs local simulation for development/demo
|
||||
*
|
||||
* Resolves Issue #7 — websocket-live.js with reconnection + backoff
|
||||
* Resolves Issue #11 — WS auth token sent via query param on connect
|
||||
*/
|
||||
|
||||
const WS_URL = import.meta.env.VITE_WS_URL || '';
|
||||
import { AGENT_DEFS, colorToCss } from './agent-defs.js';
|
||||
import { setAgentState, addAgent } from './agents.js';
|
||||
import { appendChatMessage } from './ui.js';
|
||||
import { Config } from './config.js';
|
||||
|
||||
const agentById = Object.fromEntries(AGENT_DEFS.map(d => [d.id, d]));
|
||||
|
||||
@@ -11,23 +21,42 @@ let connectionState = 'disconnected';
|
||||
let jobCount = 0;
|
||||
let reconnectTimer = null;
|
||||
let reconnectAttempts = 0;
|
||||
|
||||
const RECONNECT_BASE_MS = 2000;
|
||||
const RECONNECT_MAX_MS = 30000;
|
||||
const HEARTBEAT_INTERVAL_MS = 30000;
|
||||
const HEARTBEAT_TIMEOUT_MS = 5000;
|
||||
|
||||
let heartbeatTimer = null;
|
||||
let heartbeatTimeout = null;
|
||||
|
||||
/* ── Public API ── */
|
||||
|
||||
export function initWebSocket(_scene) {
|
||||
if (!WS_URL) {
|
||||
connectionState = 'disconnected';
|
||||
return;
|
||||
if (Config.isLive) {
|
||||
logEvent('Connecting to ' + Config.wsUrl + '…');
|
||||
connect();
|
||||
} else {
|
||||
connectionState = 'mock';
|
||||
logEvent('Mock mode — no live backend');
|
||||
}
|
||||
connect();
|
||||
}
|
||||
|
||||
export function getConnectionState() {
|
||||
return connectionState;
|
||||
}
|
||||
|
||||
export function getJobCount() {
|
||||
return jobCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a message to the backend. In mock mode this is a no-op.
|
||||
* @param {object} msg — message object (will be JSON-stringified)
|
||||
*/
|
||||
export function sendMessage(msg) {
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
||||
try {
|
||||
ws.send(JSON.stringify(msg));
|
||||
} catch { /* onclose will fire */ }
|
||||
}
|
||||
|
||||
/* ── Live WebSocket Client ── */
|
||||
|
||||
function connect() {
|
||||
if (ws) {
|
||||
ws.onclose = null;
|
||||
@@ -36,8 +65,15 @@ function connect() {
|
||||
|
||||
connectionState = 'connecting';
|
||||
|
||||
const url = Config.wsUrlWithAuth;
|
||||
if (!url) {
|
||||
connectionState = 'disconnected';
|
||||
logEvent('No WS URL configured');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
ws = new WebSocket(WS_URL);
|
||||
ws = new WebSocket(url);
|
||||
} catch (err) {
|
||||
console.warn('[Matrix WS] Connection failed:', err.message || err);
|
||||
logEvent('WebSocket connection failed');
|
||||
@@ -51,20 +87,22 @@ function connect() {
|
||||
reconnectAttempts = 0;
|
||||
clearTimeout(reconnectTimer);
|
||||
startHeartbeat();
|
||||
ws.send(JSON.stringify({
|
||||
logEvent('Connected to backend');
|
||||
|
||||
// Subscribe to agent world-state channel
|
||||
sendMessage({
|
||||
type: 'subscribe',
|
||||
channel: 'agents',
|
||||
clientId: crypto.randomUUID(),
|
||||
}));
|
||||
});
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
// Any message counts as a heartbeat response
|
||||
resetHeartbeatTimeout();
|
||||
try {
|
||||
handleMessage(JSON.parse(event.data));
|
||||
} catch (err) {
|
||||
console.warn('[Matrix WS] Message parse/handle error:', err.message, '| raw:', event.data?.slice?.(0, 200));
|
||||
console.warn('[Matrix WS] Parse error:', err.message, '| raw:', event.data?.slice?.(0, 200));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -80,24 +118,30 @@ function connect() {
|
||||
// Don't reconnect on clean close (1000) or going away (1001)
|
||||
if (event.code === 1000 || event.code === 1001) {
|
||||
console.info('[Matrix WS] Clean close (code ' + event.code + '), not reconnecting');
|
||||
logEvent('Disconnected (clean)');
|
||||
return;
|
||||
}
|
||||
|
||||
console.warn('[Matrix WS] Unexpected close — code:', event.code, 'reason:', event.reason || '(none)');
|
||||
logEvent('Connection lost — reconnecting…');
|
||||
scheduleReconnect();
|
||||
};
|
||||
}
|
||||
|
||||
/* ── Reconnection with exponential backoff ── */
|
||||
|
||||
function scheduleReconnect() {
|
||||
clearTimeout(reconnectTimer);
|
||||
// Exponential backoff: 2s, 4s, 8s, 16s, capped at 30s
|
||||
const delay = Math.min(RECONNECT_BASE_MS * Math.pow(2, reconnectAttempts), RECONNECT_MAX_MS);
|
||||
const delay = Math.min(
|
||||
Config.reconnectBaseMs * Math.pow(2, reconnectAttempts),
|
||||
Config.reconnectMaxMs,
|
||||
);
|
||||
reconnectAttempts++;
|
||||
console.info('[Matrix WS] Reconnecting in', Math.round(delay / 1000), 's (attempt', reconnectAttempts + ')');
|
||||
reconnectTimer = setTimeout(connect, delay);
|
||||
}
|
||||
|
||||
/* ── Heartbeat / zombie connection detection ── */
|
||||
/* ── Heartbeat / zombie detection ── */
|
||||
|
||||
function startHeartbeat() {
|
||||
stopHeartbeat();
|
||||
@@ -105,13 +149,13 @@ function startHeartbeat() {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
try {
|
||||
ws.send(JSON.stringify({ type: 'ping' }));
|
||||
} catch { /* ignore send failures, onclose will fire */ }
|
||||
} catch { /* ignore, onclose will fire */ }
|
||||
heartbeatTimeout = setTimeout(() => {
|
||||
console.warn('[Matrix WS] Heartbeat timeout — closing zombie connection');
|
||||
if (ws) ws.close(4000, 'heartbeat timeout');
|
||||
}, HEARTBEAT_TIMEOUT_MS);
|
||||
}, Config.heartbeatTimeoutMs);
|
||||
}
|
||||
}, HEARTBEAT_INTERVAL_MS);
|
||||
}, Config.heartbeatIntervalMs);
|
||||
}
|
||||
|
||||
function stopHeartbeat() {
|
||||
@@ -126,7 +170,7 @@ function resetHeartbeatTimeout() {
|
||||
heartbeatTimeout = null;
|
||||
}
|
||||
|
||||
/* ── Message handler ── */
|
||||
/* ── Message dispatcher ── */
|
||||
|
||||
function handleMessage(msg) {
|
||||
switch (msg.type) {
|
||||
@@ -136,18 +180,21 @@ function handleMessage(msg) {
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'job_started': {
|
||||
jobCount++;
|
||||
if (msg.agentId) setAgentState(msg.agentId, 'active');
|
||||
logEvent(`JOB ${(msg.jobId || '').slice(0, 8)} started`);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'job_completed': {
|
||||
if (jobCount > 0) jobCount--;
|
||||
if (msg.agentId) setAgentState(msg.agentId, 'idle');
|
||||
logEvent(`JOB ${(msg.jobId || '').slice(0, 8)} completed`);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'chat': {
|
||||
const def = agentById[msg.agentId];
|
||||
if (def && msg.text) {
|
||||
@@ -155,9 +202,48 @@ function handleMessage(msg) {
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dynamic agent hot-add (Issue #12).
|
||||
*
|
||||
* When the backend sends an agent_joined event, we register the new
|
||||
* agent definition and spawn its 3D avatar without requiring a page
|
||||
* reload. The event payload must include at minimum:
|
||||
* { type: 'agent_joined', id, label, color, role }
|
||||
*
|
||||
* Optional fields: direction, x, z (auto-placed if omitted).
|
||||
*/
|
||||
case 'agent_joined': {
|
||||
if (!msg.id || !msg.label) {
|
||||
console.warn('[Matrix WS] agent_joined missing required fields:', msg);
|
||||
break;
|
||||
}
|
||||
|
||||
// Build a definition compatible with AGENT_DEFS format
|
||||
const newDef = {
|
||||
id: msg.id,
|
||||
label: msg.label,
|
||||
color: typeof msg.color === 'number' ? msg.color : parseInt(msg.color, 16) || 0x00ff88,
|
||||
role: msg.role || 'agent',
|
||||
direction: msg.direction || 'north',
|
||||
x: msg.x ?? null,
|
||||
z: msg.z ?? null,
|
||||
};
|
||||
|
||||
// addAgent handles placement, scene insertion, and connection lines
|
||||
const added = addAgent(newDef);
|
||||
if (added) {
|
||||
// Update local lookup for future chat messages
|
||||
agentById[newDef.id] = newDef;
|
||||
logEvent(`Agent ${newDef.label} joined the swarm`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'pong':
|
||||
case 'agent_count':
|
||||
break;
|
||||
|
||||
default:
|
||||
console.debug('[Matrix WS] Unhandled message type:', msg.type);
|
||||
break;
|
||||
@@ -167,11 +253,3 @@ function handleMessage(msg) {
|
||||
function logEvent(text) {
|
||||
appendChatMessage('SYS', text, '#005500');
|
||||
}
|
||||
|
||||
export function getConnectionState() {
|
||||
return connectionState;
|
||||
}
|
||||
|
||||
export function getJobCount() {
|
||||
return jobCount;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user