import type { PluginApiClient } from "@termix/plugin-sdk/frontend"; import type { C2STunnelPreset, TunnelConnectRequest, TunnelConnection, TunnelStatus, } from "../shared/types"; export type TunnelStatusMap = Record; let client: PluginApiClient | null = null; /** Set from activate with app.api, cleared on deactivate. */ export function setTunnelsApi(api: PluginApiClient | null): void { client = api; } function api(): PluginApiClient { if (!client) throw new Error("The tunnels plugin is not active"); return client; } /** * The server's own reason for a failed call, or the fallback. The routes * always answer { error: string }. */ export function apiErrorMessage(error: unknown, fallback: string): string { const data = (error as { response?: { data?: { error?: unknown } } }) ?.response?.data; if (typeof data?.error === "string" && data.error) return data.error; return fallback; } // Tunnel status is a process-local, in-memory view, so reading it is always // safe. connectTunnel/disconnectTunnel/cancelTunnel always target the local // embedded backend: they address a host by numeric id, and a synced host has // a different numeric id in each database (only its syncId matches), so // routing them to a remote backend would need a local-to-remote id lookup // that does not exist yet. export async function getTunnelStatuses(): Promise { const response = await api().get("/status"); return response.data || {}; } /** * Local statuses win a name collision with remote ones, since every control * targets the local backend. */ export function mergeTunnelStatuses( local: TunnelStatusMap, remote: TunnelStatusMap, ): TunnelStatusMap { return { ...remote, ...local }; } export interface ServerSentEvent { event: string; data: string; } /** Splits a text/event-stream body into events. Returns what is left over. */ export function parseServerSentEvents( buffer: string, onEvent: (event: ServerSentEvent) => void, ): string { const blocks = buffer.split(/\r?\n\r?\n/); const rest = blocks.pop() ?? ""; for (const block of blocks) { let event = "message"; const data: string[] = []; for (const line of block.split(/\r?\n/)) { if (line.startsWith(":")) continue; if (line.startsWith("event:")) event = line.slice(6).trim(); else if (line.startsWith("data:")) data.push(line.slice(5).trimStart()); } if (data.length > 0) onEvent({ event, data: data.join("\n") }); } return rest; } function streamUrl(): string | null { const base = (client as unknown as { defaults?: { baseURL?: string } }) ?.defaults?.baseURL; if (base === undefined) return null; return `${base.replace(/\/$/, "")}/plugin-api/tunnels/status/stream`; } export interface SubscribeOptions { /** Replaces the default fetch, for tests. */ fetchImpl?: typeof fetch; /** Statuses from a connected remote server, polled and merged in. */ fetchRemote?: () => Promise; pollIntervalMs?: number; } /** * Live tunnel statuses over the plugin's event stream, reconnecting after a * drop. Falls back to polling when the stream URL cannot be worked out. */ export function subscribeTunnelStatuses( onStatuses: (statuses: TunnelStatusMap) => void, onError?: () => void, options: SubscribeOptions = {}, ): () => void { const controller = new AbortController(); const fetchImpl = options.fetchImpl ?? fetch; const pollIntervalMs = options.pollIntervalMs ?? 5000; let latestLocal: TunnelStatusMap = {}; let latestRemote: TunnelStatusMap = {}; const timers = new Set>(); const emit = () => onStatuses(mergeTunnelStatuses(latestLocal, latestRemote)); const wait = (ms: number) => new Promise((resolve) => { const timer = setTimeout(() => { timers.delete(timer); resolve(); }, ms); timers.add(timer); }); const url = streamUrl(); void (async () => { while (!controller.signal.aborted) { if (!url) { try { latestLocal = await getTunnelStatuses(); emit(); } catch { onError?.(); } await wait(pollIntervalMs); continue; } const headers: Record = { Accept: "text/event-stream", }; if (typeof window !== "undefined" && window.electronAPI?.isElectron) { headers["X-Electron-App"] = "true"; const jwt = localStorage.getItem("jwt"); if (jwt) headers.Authorization = `Bearer ${jwt}`; } try { const response = await fetchImpl(url, { credentials: "include", headers, signal: controller.signal, }); if (!response.ok || !response.body) throw new Error("stream failed"); const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = ""; while (!controller.signal.aborted) { const { value, done } = await reader.read(); if (done) break; buffer = parseServerSentEvents( buffer + decoder.decode(value, { stream: true }), (event) => { if (event.event !== "statuses") return; try { latestLocal = JSON.parse(event.data) as TunnelStatusMap; emit(); } catch { onError?.(); } }, ); } if (!controller.signal.aborted) onError?.(); } catch { if (!controller.signal.aborted) onError?.(); } if (!controller.signal.aborted) await wait(1000); } })(); if (options.fetchRemote) { const fetchRemote = options.fetchRemote; void (async () => { while (!controller.signal.aborted) { try { latestRemote = await fetchRemote(); emit(); } catch { // A remote that is down just contributes nothing. } await wait(pollIntervalMs); } })(); } return () => { controller.abort(); for (const timer of timers) clearTimeout(timer); timers.clear(); }; } export async function connectTunnel( request: TunnelConnectRequest, ): Promise { await api().post("/connect", request); } export async function disconnectTunnel(tunnelName: string): Promise { await api().post("/disconnect", { tunnelName }); } export async function cancelTunnel(tunnelName: string): Promise { await api().post("/cancel", { tunnelName }); } export async function getC2STunnelPresets(): Promise { const response = await api().get("/presets"); return response.data || []; } export async function createC2STunnelPreset(data: { name: string; config: TunnelConnection[]; platform?: string; computerName?: string; }): Promise { const response = await api().post("/presets", data); return response.data; } export async function updateC2STunnelPreset( id: number, data: Partial<{ name: string; config: TunnelConnection[]; platform: string; computerName: string; }>, ): Promise { const response = await api().put(`/presets/${id}`, data); return response.data; } export async function deleteC2STunnelPreset(id: number): Promise { await api().delete(`/presets/${id}`); }