molecule-core/canvas/src/store/socket.ts
Hongming Wang 96b909b8f3 fix: code review findings — token UI, auth hardening, WS dedup
1. Settings panel: wire TokensTab into "API Tokens" tab (was imported
   but not rendered). Rename "API Keys" → "Secrets", add "API Tokens"
   tab. Fix docs link → doc.moleculesai.app/docs/tokens.

2. Referer match hardening: require exact host match or trailing slash
   to prevent evil.com subdomain bypass. Cache CANVAS_PROXY_URL at
   init time instead of per-request os.Getenv.

3. Extract shared deriveWsBaseUrl() to lib/ws-url.ts — eliminates
   duplicate 12-line derivation in socket.ts and TerminalTab.tsx.

4. Token list pagination: add ?limit= and ?offset= params (default
   50, max 200) to GET /workspaces/:id/tokens.

507/507 canvas tests pass, Go build + vet clean.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 10:42:26 -07:00

138 lines
3.5 KiB
TypeScript

import { useCanvasStore } from "./canvas";
import { deriveWsBaseUrl } from "@/lib/ws-url";
// If explicit WS_URL is set, use it as-is (may include custom path).
// Otherwise derive base + append /ws.
export const WS_URL = process.env.NEXT_PUBLIC_WS_URL || (deriveWsBaseUrl() + "/ws");
export interface WSMessage {
event: string;
workspace_id: string;
timestamp: string;
payload: Record<string, unknown>;
}
class ReconnectingSocket {
private ws: WebSocket | null = null;
private attempt = 0;
private url: string;
private lastEventTime = 0;
private healthCheckTimer: ReturnType<typeof setInterval> | null = null;
constructor(url: string) {
this.url = url;
}
connect() {
useCanvasStore.getState().setWsStatus("connecting");
this.ws = new WebSocket(this.url);
this.ws.onopen = () => {
this.attempt = 0;
this.lastEventTime = Date.now();
useCanvasStore.getState().setWsStatus("connected");
this.rehydrate();
this.startHealthCheck();
};
this.ws.onmessage = (event) => {
this.lastEventTime = Date.now();
try {
const msg: WSMessage = JSON.parse(event.data);
useCanvasStore.getState().applyEvent(msg);
} catch {
// Malformed WS message — skip silently
}
};
this.ws.onclose = () => {
this.stopHealthCheck();
useCanvasStore.getState().setWsStatus("connecting");
const delay = Math.min(1000 * 2 ** this.attempt, 30000);
this.attempt++;
setTimeout(() => this.connect(), delay);
};
this.ws.onerror = () => {
// Suppressed — onclose handles reconnection. onerror fires before onclose
// and the Event object doesn't contain useful info (serializes to {}).
};
}
/** Periodically re-fetch state in case WebSocket events were missed (e.g. agent
* status changed while the socket stayed open but no event was emitted). */
private startHealthCheck() {
this.stopHealthCheck();
this.healthCheckTimer = setInterval(() => {
const silenceSec = (Date.now() - this.lastEventTime) / 1000;
// If no events for 30s, re-hydrate to catch missed status changes
if (silenceSec > 30) {
this.rehydrate();
this.lastEventTime = Date.now(); // prevent rapid re-fetches
}
}, 30_000);
}
private stopHealthCheck() {
if (this.healthCheckTimer) {
clearInterval(this.healthCheckTimer);
this.healthCheckTimer = null;
}
}
private async rehydrate() {
try {
const { api } = await import("@/lib/api");
const workspaces = await api.get<WorkspaceData[]>("/workspaces");
useCanvasStore.getState().hydrate(workspaces);
} catch {
// Rehydration failed — will retry on next health check cycle
}
}
disconnect() {
this.stopHealthCheck();
if (this.ws) {
this.ws.close();
this.ws = null;
}
useCanvasStore.getState().setWsStatus("disconnected");
}
}
export interface WorkspaceData {
id: string;
name: string;
role: string;
tier: number;
status: string;
agent_card: Record<string, unknown> | null;
url: string;
parent_id: string | null;
active_tasks: number;
last_error_rate: number;
last_sample_error: string;
uptime_seconds: number;
current_task: string;
runtime: string;
x: number;
y: number;
collapsed: boolean;
}
let socket: ReconnectingSocket | null = null;
export function connectSocket() {
if (!socket) {
socket = new ReconnectingSocket(WS_URL);
}
socket.connect();
}
export function disconnectSocket() {
if (socket) {
socket.disconnect();
socket = null;
}
}