/* REST + WebSocket client. * * The dashboard never polls for frames. It asks for state once when a session * starts and then consumes the push stream; REST is only used for commands and * for things that are not per-frame. */ const BASE = '/api'; async function request(path, options = {}) { const res = await fetch(BASE + path, { headers: options.body ? { 'content-type': 'application/json' } : undefined, ...options, }); if (!res.ok) { let detail = res.statusText; try { const body = await res.json(); detail = body.detail || body.error || detail; } catch { /* non-JSON error body */ } const err = new Error(typeof detail === 'string' ? detail : JSON.stringify(detail)); err.status = res.status; throw err; } return res.status === 204 ? null : res.json(); } export const api = { meta: () => request('/meta'), venues: () => request('/venues'), venue: id => request(`/venues/${id}`), scenarios: () => request('/scenarios'), benchmarks: () => request('/benchmarks'), start: payload => request('/simulation/start', { method: 'POST', body: JSON.stringify(payload), }), state: (id, agents = true) => request(`/simulation/${id}/state?agents=${agents}`), control: (id, payload) => request(`/simulation/${id}/control`, { method: 'POST', body: JSON.stringify(payload), }), stop: id => request(`/simulation/${id}`, { method: 'DELETE' }), simulateStrategies: (id, payload = {}) => request(`/simulation/${id}/strategy/simulate`, { method: 'POST', body: JSON.stringify(payload), }), applyStrategy: (id, strategyId) => request(`/simulation/${id}/strategy/apply`, { method: 'POST', body: JSON.stringify({ strategy_id: strategyId }), }), perceptionStatus: () => request('/perception/status'), perceptionAnalyze: (formData) => fetch(`${BASE}/perception/analyze`, { method: 'POST', body: formData }) .then(async r => { const body = await r.json().catch(() => ({})); if (!r.ok) throw new Error(body.detail || 'perception failed'); return body; }), }; /** Auto-reconnecting frame stream. */ export class FrameStream { constructor(sessionId, handlers) { this.sessionId = sessionId; this.handlers = handlers; this.ws = null; this.closed = false; this.retries = 0; this._connect(); } _connect() { if (this.closed) return; const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; const url = `${proto}//${location.host}${BASE}/ws/simulation/${this.sessionId}`; let ws; try { ws = new WebSocket(url); } catch { this._scheduleRetry(); return; } this.ws = ws; ws.onopen = () => { this.retries = 0; this.handlers.onStatus?.('live'); }; ws.onmessage = ev => { let msg; try { msg = JSON.parse(ev.data); } catch { return; } if (msg.type === 'frame') this.handlers.onFrame?.(msg); else if (msg.type === 'strategy') this.handlers.onStrategy?.(msg.payload); else if (msg.type === 'error') this.handlers.onError?.(msg.detail); }; ws.onclose = () => { this.handlers.onStatus?.(this.closed ? 'closed' : 'reconnecting'); this._scheduleRetry(); }; ws.onerror = () => { /* onclose handles recovery */ }; } _scheduleRetry() { if (this.closed) return; this.retries += 1; if (this.retries > 8) { this.handlers.onStatus?.('offline'); return; } setTimeout(() => this._connect(), Math.min(600 * this.retries, 4000)); } close() { this.closed = true; try { this.ws?.close(); } catch { /* already gone */ } } }