mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
249 lines
7.3 KiB
TypeScript
249 lines
7.3 KiB
TypeScript
import type { PluginApiClient } from "@termix/plugin-sdk/frontend";
|
|
import type {
|
|
C2STunnelPreset,
|
|
TunnelConnectRequest,
|
|
TunnelConnection,
|
|
TunnelStatus,
|
|
} from "../shared/types";
|
|
|
|
export type TunnelStatusMap = Record<string, TunnelStatus>;
|
|
|
|
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<TunnelStatusMap> {
|
|
const response = await api().get<TunnelStatusMap>("/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<TunnelStatusMap>;
|
|
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<ReturnType<typeof setTimeout>>();
|
|
|
|
const emit = () => onStatuses(mergeTunnelStatuses(latestLocal, latestRemote));
|
|
|
|
const wait = (ms: number) =>
|
|
new Promise<void>((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<string, string> = {
|
|
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<void> {
|
|
await api().post("/connect", request);
|
|
}
|
|
|
|
export async function disconnectTunnel(tunnelName: string): Promise<void> {
|
|
await api().post("/disconnect", { tunnelName });
|
|
}
|
|
|
|
export async function cancelTunnel(tunnelName: string): Promise<void> {
|
|
await api().post("/cancel", { tunnelName });
|
|
}
|
|
|
|
export async function getC2STunnelPresets(): Promise<C2STunnelPreset[]> {
|
|
const response = await api().get<C2STunnelPreset[]>("/presets");
|
|
return response.data || [];
|
|
}
|
|
|
|
export async function createC2STunnelPreset(data: {
|
|
name: string;
|
|
config: TunnelConnection[];
|
|
platform?: string;
|
|
computerName?: string;
|
|
}): Promise<C2STunnelPreset> {
|
|
const response = await api().post<C2STunnelPreset>("/presets", data);
|
|
return response.data;
|
|
}
|
|
|
|
export async function updateC2STunnelPreset(
|
|
id: number,
|
|
data: Partial<{
|
|
name: string;
|
|
config: TunnelConnection[];
|
|
platform: string;
|
|
computerName: string;
|
|
}>,
|
|
): Promise<C2STunnelPreset> {
|
|
const response = await api().put<C2STunnelPreset>(`/presets/${id}`, data);
|
|
return response.data;
|
|
}
|
|
|
|
export async function deleteC2STunnelPreset(id: number): Promise<void> {
|
|
await api().delete(`/presets/${id}`);
|
|
}
|