import { defineStore } from "pinia"; import type { AppPrinterState, AppTelemetryMessage } from "@continuum/ui"; /** * Live printer state from `continuum-backend`'s `/ws/telemetry`. * * The message shape is the backend's, mirrored in `@continuum/ui`'s types * from `continuum-schemas`. This file used to declare its own * (`printerId`, `progressPct`, a closed state enum, a `data` envelope) and * no backend ever sent it. */ export type TelemetryLink = "disconnected" | "connecting" | "connected"; interface TelemetryState { link: TelemetryLink; /** * How many printers the server says this user can see, from its `ready` * message; null until that arrives. This is what lets a view tell "not * connected yet" (null) from "connected and there is genuinely nothing" * (0) from "connected, printers exist, none has reported" (> 0 with an * empty `printers`). */ visiblePrinters: number | null; /** Latest push per printer, keyed by `printer.id`. */ printers: Record; } /** * Backoff, not a fixed interval, and the reason is that a browser cannot see * why an upgrade failed. A rejected credential and an unreachable server look * identical from here - no status code, just `onerror` then `onclose` - so a * socket whose token the server will never accept would otherwise reconnect * every 3s for as long as the tab is open. Capped, because the other failure * is a server that is merely restarting and should be picked up promptly. */ const RECONNECT_DELAY_MS = 1_000; const RECONNECT_MAX_MS = 30_000; let retryDelay = RECONNECT_DELAY_MS; // Held outside the reactive state: a WebSocket and a timer handle have no // business being wrapped in proxies or serialised by devtools. let socket: WebSocket | null = null; let retryTimer: ReturnType | undefined; // Whether anyone wants a connection. Without it `disconnect()` would close // the socket and the close handler would cheerfully reconnect it. let wanted = false; export const useTelemetryStore = defineStore("telemetry", { state: (): TelemetryState => ({ link: "disconnected", visiblePrinters: null, printers: {}, }), getters: { connected: (state) => state.link === "connected", printerList: (state) => Object.values(state.printers), }, actions: { connect() { wanted = true; if (socket) return; // Read here, at every attempt, never captured by the store: supabase-js // refreshes the session about hourly (see `useApiFetch`), so a // reconnect that replayed the first connection's token would work // until the first time it was needed. const token = useSupabaseSession().value?.access_token; // No session means no connection, rather than an empty credential the // server can only reject. Deliberately no retry timer either: the // caller connects when a session appears (app.vue watches for it), and // polling for a sign-in would be a loop with nothing to wait on. if (!token) { this.link = "disconnected"; return; } const { wsBase } = useRuntimeConfig().public; // The token travels in the WebSocket subprotocol list, not the URL: a // bearer credential in a query string lands in access logs, proxy // logs and error breadcrumbs. "bearer" must be first because the // server echoes back the first offered protocol, and echoing the token // would put it in a response header. const ws = new WebSocket(`${wsBase}/ws/telemetry`, ["bearer", token]); socket = ws; this.link = "connecting"; ws.onopen = () => { if (socket !== ws) return; this.link = "connected"; // Reset only on a socket that actually opened. Resetting in // `connect()` would make every backoff step start over, and the // delay would never grow past the first one. retryDelay = RECONNECT_DELAY_MS; }; ws.onmessage = (event) => { if (socket !== ws) return; let message: AppTelemetryMessage; try { message = JSON.parse(event.data) as AppTelemetryMessage; } catch { // Ignore malformed frames rather than crashing the UI. return; } // Unknown `type`s fall through and are ignored, so the backend can // add a message kind without breaking consoles that predate it. if (message.type === "ready") { this.visiblePrinters = message.visiblePrinters; } else if (message.type === "printer.telemetry") { this.printers[message.printer.id] = message.printer; } }; ws.onclose = () => { // A socket we already replaced or abandoned must not clobber the // state of its successor. if (socket !== ws) return; socket = null; this.link = "disconnected"; // `printers` is kept across the gap: a stale reading beats a blank // card, and `lastSeenAt` on the row says how stale. `visiblePrinters` // is reset because the next `ready` re-states it. this.visiblePrinters = null; if (wanted) { retryTimer = setTimeout(() => this.connect(), retryDelay); retryDelay = Math.min(retryDelay * 2, RECONNECT_MAX_MS); } }; ws.onerror = () => { ws.close(); }; }, disconnect() { wanted = false; clearTimeout(retryTimer); // A deliberate disconnect is not a failure: signing back in should // connect at once rather than inheriting the previous session's // accumulated delay. retryDelay = RECONNECT_DELAY_MS; const ws = socket; socket = null; ws?.close(); this.link = "disconnected"; this.visiblePrinters = null; }, }, });