/** A client of a host gateway, shared by the app and by hosts reaching their tailnet peers. */ import type { Metadata } from "@ace/channel/protocol"; import type { Event, HostFrame, HostRequest, Listing, People, Project, Request, TabRename, TerminalFrame, WindowState, } from "./protocol"; /** * A watch's events, then `closed` once an accepted watch ends: released, or its channel connection * or a relaying host closed. The owner decides whether to watch again. */ export type Watch = { event(event: Event): void; closed?(error: string): void }; type Pending = { resolve(value: unknown): void; reject(error: Error): void; watch?: Watch; /** The watch was accepted, so a later failure ends it instead of rejecting the request. */ accepted?: boolean; }; export type Status = "connecting" | "open" | "closed"; /** One WebSocket to a host gateway, reconnecting with backoff until closed. */ export class GatewayClient { #socket?: WebSocket; #next = 1; #pending = new Map(); #listeners = new Set<() => void>(); #terminals = new Map void>(); #retry = 250; #timer?: ReturnType; #closed = false; #metadata = new Map(); status: Status = "connecting"; channels: Listing[] = []; projects: Project[] = []; people: People = {}; settingsVersion = 0; /** Called after every connect so watches can be re-established. */ onOpen?: () => void; /** The mounted channel layout owns tab names and acknowledges changes after persisting them. */ onRename?: (request: TabRename) => WindowState | Promise; constructor(readonly url: string, private readonly token?: string) { this.#connect(); } /** Retry now instead of waiting out the backoff, e.g. when a phone wakes the page. */ wake() { if (this.#closed || this.status !== "closed") return; clearTimeout(this.#timer); this.#retry = 250; this.#connect(); } #connect() { if (this.#closed) return; this.#timer = undefined; const socket = new WebSocket( this.url, this.token ? ["ace", `ace-token.${this.token}`] : undefined, ); this.#socket = socket; socket.addEventListener("open", () => { this.#retry = 250; this.#set("open"); this.request({ op: "channels" }).then((channels) => { this.channels = this.#listings(channels); this.#emit(); }, () => {}); this.onOpen?.(); }); socket.addEventListener( "message", (message) => this.#receive(JSON.parse(String(message.data)) as HostFrame, socket), ); socket.addEventListener("close", () => { const ended = [...this.#pending.values()]; this.#pending.clear(); this.#metadata.clear(); this.channels = []; this.#set("closed"); const error = "Disconnected from the host"; for (const pending of ended) { if (pending.accepted) pending.watch?.closed?.(error); else pending.reject(new Error(error)); } if (this.#closed) return; this.#timer = setTimeout(() => this.#connect(), this.#retry); this.#retry = Math.min(this.#retry * 2, 5000); }); } #receive(frame: HostFrame, socket: WebSocket) { if ("rename" in frame) return void this.#rename(frame.rename, socket); if ("projects" in frame) { this.projects = frame.projects; return this.#emit(); } if ("people" in frame) { this.people = frame.people; return this.#emit(); } if ("settings" in frame) { this.settingsVersion++; return this.#emit(); } if ("channels" in frame) { this.channels = this.#listings(frame.channels); return this.#emit(); } if ("terminal" in frame) return this.#terminals.get(frame.terminal)?.(frame); const pending = this.#pending.get(frame.id); if (!pending) return; if ("event" in frame) return pending.watch?.event(frame.event); // A watch keeps its entry so later events still reach it, until a failure ends it. if (pending.watch && frame.ok) { pending.accepted = true; return pending.resolve(frame.value); } this.#pending.delete(frame.id); if (frame.ok) return pending.resolve(frame.value); if (pending.accepted) return pending.watch?.closed?.(frame.error); pending.reject(new Error(frame.error)); } async #rename(request: TabRename, socket: WebSocket) { if (socket !== this.#socket || socket.readyState !== WebSocket.OPEN) return; let result: Extract["result"]; try { if (request.expires <= Date.now()) throw new Error("Tab rename expired"); if (!this.onRename) throw new Error("No channel is open in this window"); result = { ok: true, value: await this.onRename(request) }; } catch (error) { result = { ok: false, error: (error as Error).message }; } // A reply belongs to the connection that received the command, never its replacement. if (socket !== this.#socket || socket.readyState !== WebSocket.OPEN) return; this.request({ op: "tab-result", call: request.call, result }).catch(() => {}); } /** A hosted channel can advance while its workspace's catalog is offline. */ #listings(channels: Listing[]): Listing[] { return channels.map((channel) => { const metadata = this.#metadata.get(channel.id); if (!metadata) return channel; if ((channel.revision || 0) >= metadata.revision) { this.#metadata.delete(channel.id); return channel; } return { ...channel, ...metadata }; }); } #update(channel: string, metadata: Metadata) { const previous = this.#metadata.get(channel); if (previous && previous.revision >= metadata.revision) return; this.#metadata.set(channel, metadata); this.channels = this.channels.map((listing) => listing.id === channel && (listing.revision || 0) < metadata.revision ? { ...listing, ...metadata } : listing ); this.#emit(); } #set(status: Status) { this.status = status; this.#emit(); } #emit() { for (const listener of this.#listeners) listener(); } subscribe = (listener: () => void) => { this.#listeners.add(listener); return () => this.#listeners.delete(listener); }; request( request: HostRequest, watch?: Watch, trace?: string, ): Promise { if (this.status !== "open") return Promise.reject(new Error("Not connected to the host")); const id = this.#next++; const promise = new Promise((resolve, reject) => this.#pending.set(id, { resolve: resolve as (value: unknown) => void, reject, ...(watch ? { watch } : {}), }) ); this.#socket?.send(JSON.stringify({ id, ...(trace ? { trace } : {}), ...request })); return promise; } channel( channel: string, request: Request, watch?: Watch, trace?: string, ): Promise { const observe = watch && { closed: (error: string) => watch.closed?.(error), event: (event: Event) => { if (event.kind === "metadata") { const { name, summary, revision } = event; this.#update(channel, { name, summary, revision }); } watch.event(event); }, }; return this.request({ op: "channel", channel, request }, observe, trace).then((value) => { if (request.op === "rename") this.#update(channel, value as Metadata); return value; }); } /** Receive a terminal's output and exit; returns the unsubscribe. */ terminal(id: string, listener: (frame: TerminalFrame) => void): () => void { this.#terminals.set(id, listener); return () => this.#terminals.delete(id); } close() { this.#closed = true; this.#socket?.close(); } }