/** * Backend store - manages WebSocket connection and REST API communication * with the Python backend server running on localhost. * * The backend port defaults to 8081 but can be updated at runtime via * `setPort()`. The WebSocket connects to /ws/control for real-time push * of transcriptions, previews, and state changes. */ export type ConnectionState = "connecting" | "connected" | "disconnected" | "error"; export type AppState = "initializing" | "ready" | "transcribing" | "reloading" | "error"; interface BackendState { port: number; connectionState: ConnectionState; appState: AppState; stateMessage: string; deviceInfo: string; wsConnection: WebSocket | null; version: string; lastError: string; } let state = $state({ port: 8081, connectionState: "disconnected", appState: "initializing", stateMessage: "Connecting to backend...", deviceInfo: "", wsConnection: null, version: "1.4.0", lastError: "", }); let reconnectTimer: ReturnType | null = null; let reconnectAttempts = 0; const MAX_RECONNECT_DELAY_MS = 30_000; const BASE_RECONNECT_DELAY_MS = 1_000; // ── URL helpers ────────────────────────────────────────────────────── function apiUrl(path: string): string { const normalised = path.startsWith("/") ? path : `/${path}`; return `http://localhost:${state.port}${normalised}`; } async function apiFetch(path: string, options?: RequestInit): Promise { const url = apiUrl(path); const method = options?.method?.toUpperCase() ?? "GET"; const headers = new Headers(options?.headers); if (method !== "GET" && !headers.has("Content-Type")) { headers.set("Content-Type", "application/json"); } return fetch(url, { ...options, headers }); } // ── WebSocket management ───────────────────────────────────────────── function connectWebSocket() { // Tear down any existing connection disconnect(); state.connectionState = "connecting"; reconnectAttempts = 0; _openSocket(); } function _openSocket() { const wsUrl = `ws://localhost:${state.port}/ws/control`; try { const ws = new WebSocket(wsUrl); ws.onopen = () => { state.connectionState = "connected"; state.lastError = ""; reconnectAttempts = 0; if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; } }; ws.onmessage = (event) => { try { const data = JSON.parse(event.data); handleWebSocketMessage(data); } catch { // ignore parse errors } }; ws.onclose = () => { state.wsConnection = null; if (state.connectionState !== "disconnected") { state.connectionState = "error"; state.stateMessage = "Disconnected from backend"; _scheduleReconnect(); } }; ws.onerror = () => { state.lastError = "WebSocket error"; // onclose fires after this, which handles reconnect }; state.wsConnection = ws; } catch { state.connectionState = "error"; state.stateMessage = "Failed to connect"; _scheduleReconnect(); } } function _scheduleReconnect() { if (reconnectTimer) return; const delay = Math.min( BASE_RECONNECT_DELAY_MS * Math.pow(2, reconnectAttempts), MAX_RECONNECT_DELAY_MS, ); reconnectAttempts++; reconnectTimer = setTimeout(() => { reconnectTimer = null; if (state.connectionState !== "disconnected") { state.connectionState = "connecting"; _openSocket(); } }, delay); } function disconnect() { if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; } state.connectionState = "disconnected"; if (state.wsConnection) { const ws = state.wsConnection; ws.onclose = null; ws.onerror = null; ws.close(); state.wsConnection = null; } } // ── WebSocket message handling ─────────────────────────────────────── function handleWebSocketMessage(data: Record) { // Handle state changes locally if (data.type === "state_changed") { if (data.state) { state.appState = data.state as AppState; } if (data.message) { state.stateMessage = data.message as string; } } if (data.type === "error") { state.lastError = (data.message as string) ?? "Unknown error"; } // Dispatch to window for other stores (transcriptions, etc.) if (data.type === "transcription") { window.dispatchEvent( new CustomEvent("backend:transcription", { detail: data }) ); } else if (data.type === "preview") { window.dispatchEvent( new CustomEvent("backend:preview", { detail: data }) ); } else if (data.type === "credits_low") { window.dispatchEvent( new CustomEvent("backend:credits_low", { detail: data }) ); } } // ── Port management ────────────────────────────────────────────────── function setPort(newPort: number) { if (newPort === state.port) return; state.port = newPort; // Reconnect with new port if we had a connection if (state.connectionState !== "disconnected") { connectWebSocket(); } } // ── Typed REST helpers ─────────────────────────────────────────────── async function apiGet(path: string): Promise { const resp = await apiFetch(path); if (!resp.ok) throw new Error(`GET ${path} failed: ${resp.status}`); return resp.json(); } async function apiPost( path: string, body?: unknown ): Promise { const resp = await apiFetch(path, { method: "POST", body: body !== undefined ? JSON.stringify(body) : undefined, }); if (!resp.ok) throw new Error(`POST ${path} failed: ${resp.status}`); return resp.json(); } async function apiPut( path: string, body?: unknown ): Promise { const resp = await apiFetch(path, { method: "PUT", body: body !== undefined ? JSON.stringify(body) : undefined, }); if (!resp.ok) throw new Error(`PUT ${path} failed: ${resp.status}`); return resp.json(); } // ── Public API ─────────────────────────────────────────────────────── export const backendStore = { get port() { return state.port; }, get connectionState() { return state.connectionState; }, get connected() { return state.connectionState === "connected"; }, get appState() { return state.appState; }, get stateMessage() { return state.stateMessage; }, get deviceInfo() { return state.deviceInfo; }, get version() { return state.version; }, get lastError() { return state.lastError; }, get apiBaseUrl() { return `http://localhost:${state.port}`; }, get wsUrl() { return `ws://localhost:${state.port}/ws/control`; }, setPort, connect: connectWebSocket, disconnect, apiUrl, apiFetch, apiGet, apiPost, apiPut, };