forked from bots-garden/ori
| ✨ Introduce new feature(s): ACP web client — Go backend (agent, bridge, httpserver, mockagent) + React SPA (Zed-like agent panel), tests, quality gate PASS | 1 | /** |
| 2 | * WebSocket client for the ori backend: parses server messages into store | |
| 3 | * events and reconnects automatically with a capped backoff. The server | |
| 4 | * replays the session history on every connection, and the reducer resets on | |
| 5 | * "hello", so a reconnection rebuilds the exact same thread. | |
| 6 | */ | |
| 7 | ||
| 8 | import type { ClientMessage, ServerMessage } from "./protocol"; | |
| 9 | import type { ChatEvent } from "./reducer"; | |
| 10 | ||
| 11 | /** What the UI needs from the connection. */ | |
| 12 | export interface OriSocket { | |
| 13 | send(message: ClientMessage): void; | |
| 14 | close(): void; | |
| 15 | } | |
| 16 | ||
| 17 | interface ConnectOptions { | |
| 18 | url: string; | |
| 19 | dispatch: (event: ChatEvent) => void; | |
| 20 | /** Injectable WebSocket constructor, for tests. Defaults to the browser's. */ | |
| 21 | webSocketImpl?: typeof WebSocket; | |
| 22 | /** First reconnection delay in ms; doubles up to 16x. Defaults to 500. */ | |
| 23 | initialRetryMs?: number; | |
| 24 | } | |
| 25 | ||
| 26 | /** | |
| 27 | * connectSocket opens the connection and keeps it alive until close(). | |
| 28 | * | |
| 29 | * @example | |
| 30 | * const socket = connectSocket({ url: wsUrl(), dispatch }); | |
| 31 | * socket.send({ type: "prompt", text: "hello" }); | |
| 32 | */ | |
| 33 | export function connectSocket(options: ConnectOptions): OriSocket { | |
| 34 | const Impl = options.webSocketImpl ?? WebSocket; | |
| 35 | const initialRetryMs = options.initialRetryMs ?? 500; | |
| 36 | ||
| 37 | let socket: WebSocket | null = null; | |
| 38 | let retryMs = initialRetryMs; | |
| 39 | let closedByUser = false; | |
| 40 | ||
| 41 | const open = () => { | |
| 42 | socket = new Impl(options.url); | |
| 43 | options.dispatch({ type: "connection", status: "connecting" }); | |
| 44 | ||
| 45 | socket.onopen = () => { | |
| 46 | retryMs = initialRetryMs; | |
| 47 | }; | |
| 48 | socket.onmessage = (event: MessageEvent) => { | |
| 49 | let message: ServerMessage; | |
| 50 | try { | |
| 51 | message = JSON.parse(String(event.data)) as ServerMessage; | |
| 52 | } catch { | |
| 53 | return; // a malformed frame must not kill the connection handling | |
| 54 | } | |
| 55 | options.dispatch({ type: "server", message }); | |
| 56 | }; | |
| 57 | socket.onclose = () => { | |
| 58 | if (closedByUser) { | |
| 59 | return; | |
| 60 | } | |
| 61 | options.dispatch({ type: "connection", status: "offline" }); | |
| 62 | setTimeout(open, retryMs); | |
| 63 | retryMs = Math.min(retryMs * 2, initialRetryMs * 16); | |
| 64 | }; | |
| 65 | }; | |
| 66 | open(); | |
| 67 | ||
| 68 | return { | |
| 69 | send(message: ClientMessage) { | |
| 70 | if (socket && socket.readyState === Impl.OPEN) { | |
| 71 | socket.send(JSON.stringify(message)); | |
| 72 | } | |
| 73 | }, | |
| 74 | close() { | |
| 75 | closedByUser = true; | |
| 76 | socket?.close(); | |
| 77 | }, | |
| 78 | }; | |
| 79 | } | |
| 80 | ||
| 81 | /** wsUrl derives the backend WebSocket URL from the page's own origin. */ | |
| 82 | export function wsUrl(): string { | |
| 83 | const scheme = window.location.protocol === "https:" ? "wss" : "ws"; | |
| 84 | return `${scheme}://${window.location.host}/ws`; | |
| 85 | } |