Three issues fixed:
1. Port mismatch: The sidecar reported the OBS port (8080) in the
ready event but the frontend needs the API port (8081). Now reports
the API port so WebSocket/REST connects to the right place.
2. Broadcast from wrong thread: Engine init fires state_changed from
a background thread, but _broadcast_control used get_event_loop()
which returns the wrong loop. Now captures the uvicorn event loop
at startup via on_event("startup").
3. Missed ready state: If the engine finishes before the WebSocket
client connects, the "ready" state_changed was never received.
Added status polling (GET /api/status) on WebSocket connect that
retries every 2s while appState is "initializing".
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
311 lines
8.6 KiB
TypeScript
311 lines
8.6 KiB
TypeScript
/**
|
|
* 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<BackendState>({
|
|
port: 8081,
|
|
connectionState: "disconnected",
|
|
appState: "initializing",
|
|
stateMessage: "Connecting to backend...",
|
|
deviceInfo: "",
|
|
wsConnection: null,
|
|
version: "1.4.0",
|
|
lastError: "",
|
|
});
|
|
|
|
let reconnectTimer: ReturnType<typeof setTimeout> | 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<Response> {
|
|
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 });
|
|
}
|
|
|
|
// ── Status polling ──────────────────────────────────────────────────
|
|
|
|
let statusPollTimer: ReturnType<typeof setTimeout> | null = null;
|
|
|
|
async function pollStatus() {
|
|
try {
|
|
const resp = await fetch(apiUrl("/api/status"));
|
|
if (resp.ok) {
|
|
const data = await resp.json();
|
|
if (data.state) {
|
|
state.appState = data.state as AppState;
|
|
}
|
|
if (data.engine_device) {
|
|
state.deviceInfo = data.engine_device;
|
|
}
|
|
if (data.version) {
|
|
state.version = data.version;
|
|
}
|
|
}
|
|
} catch {
|
|
// API not ready yet, will retry
|
|
}
|
|
|
|
// Keep polling every 2s while still initializing
|
|
if (state.appState === "initializing" && state.connectionState === "connected") {
|
|
statusPollTimer = setTimeout(pollStatus, 2000);
|
|
}
|
|
}
|
|
|
|
// ── 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;
|
|
}
|
|
// Poll status to catch engine ready state that may have been
|
|
// missed (engine can finish before WebSocket connects)
|
|
pollStatus();
|
|
};
|
|
|
|
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 (statusPollTimer) {
|
|
clearTimeout(statusPollTimer);
|
|
statusPollTimer = null;
|
|
}
|
|
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<string, unknown>) {
|
|
// 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<T = unknown>(path: string): Promise<T> {
|
|
const resp = await apiFetch(path);
|
|
if (!resp.ok) throw new Error(`GET ${path} failed: ${resp.status}`);
|
|
return resp.json();
|
|
}
|
|
|
|
async function apiPost<T = unknown>(
|
|
path: string,
|
|
body?: unknown
|
|
): Promise<T> {
|
|
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<T = unknown>(
|
|
path: string,
|
|
body?: unknown
|
|
): Promise<T> {
|
|
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`;
|
|
},
|
|
get obsUrl() {
|
|
// OBS display runs on the web server port (one below the API port)
|
|
const obsPort = state.port > 0 ? state.port - 1 : 8080;
|
|
return `http://localhost:${obsPort}`;
|
|
},
|
|
get syncUrl() {
|
|
return "";
|
|
},
|
|
setPort,
|
|
connect: connectWebSocket,
|
|
disconnect,
|
|
apiUrl,
|
|
apiFetch,
|
|
apiGet,
|
|
apiPost,
|
|
apiPut,
|
|
};
|