Files
Termix/src/ui/lib/plugin-transport.ts
T

197 lines
6.5 KiB
TypeScript

/**
* How a plugin frontend reaches its own backend.
*
* Every plugin serves HTTP under /plugin-api/<id>/ and WebSockets under
* /plugin-ws/<id>/<path> on the main backend. No plugin has a port of its own,
* so these two helpers are all a plugin frontend needs, in every deployment:
*
* - web, same origin, optionally under a base path;
* - Docker behind nginx, which proxies both prefixes;
* - the Vite dev server, which proxies through /__termix_api/30001;
* - Electron with its embedded local backend;
* - Electron pointed at a remote Termix server, for hosts whose connection
* origin resolves there.
*
* A7 re-exports both through @termix-ssh/plugin-sdk/frontend. They live here for
* now because plugin frontends are still bundled with the shell.
*/
import type { AxiosInstance } from "axios";
import { authApi, createRemoteOriginApiInstance } from "@/main-axios";
import { getBasePath } from "@/lib/base-path";
import { isElectron } from "@/lib/electron";
import { getDeviceId } from "@/lib/device-id";
import { websocketAuthProtocols } from "@/lib/ws-auth";
import {
buildOriginWsUrl,
type ConnectionOrigin,
type WebSocketConnectionTarget,
} from "@/lib/connection-origin";
/** The backend port. Everything plugin-facing rides the main server. */
const BACKEND_PORT = 30001;
function pluginApiPath(pluginId: string, path = ""): string {
const suffix = path && !path.startsWith("/") ? `/${path}` : path;
return `/plugin-api/${pluginId}${suffix}`;
}
/**
* An axios client rooted at this plugin's /plugin-api mount point.
*
* Built on the shared authenticated instance, so a plugin inherits the same
* interceptors as core: auth headers, session expiry handling and error
* reporting all behave identically.
*/
export function createPluginApi(pluginId: string): AxiosInstance {
const prefix = pluginApiPath(pluginId);
// A thin proxy rather than a new axios instance: the shared one resolves its
// baseURL per deployment already, and duplicating that here is how the two
// drift apart.
return new Proxy(authApi, {
get(target, property, receiver) {
const value = Reflect.get(target, property, receiver);
if (typeof value !== "function") return value;
if (
property === "get" ||
property === "delete" ||
property === "head" ||
property === "options" ||
property === "post" ||
property === "put" ||
property === "patch" ||
property === "postForm" ||
property === "putForm" ||
property === "patchForm"
) {
return (url: string, ...rest: unknown[]) =>
(value as (...args: unknown[]) => unknown).call(
target,
`${prefix}${url.startsWith("/") ? url : `/${url}`}`,
...rest,
);
}
return value.bind(target);
},
}) as AxiosInstance;
}
/**
* A raw fetch on this plugin's /plugin-api mount point, for a response the
* caller reads as a stream (server-sent events). axios buffers the whole
* body, so it cannot. Sends the same auth the shared client does.
*/
export function pluginFetch(
pluginId: string,
path: string,
init: RequestInit = {},
): Promise<Response> {
const base = (authApi.defaults.baseURL ?? "").replace(/\/+$/, "");
const headers = new Headers(init.headers);
const deviceId = getDeviceId();
if (deviceId) headers.set("X-Termix-Device-ID", deviceId);
if (isElectron()) {
headers.set("X-Electron-App", "true");
const jwt = localStorage.getItem("jwt");
if (jwt) headers.set("Authorization", `Bearer ${jwt}`);
}
return fetch(`${base}${pluginApiPath(pluginId, path)}`, {
credentials: "include",
...init,
headers,
});
}
const remotePluginApis = new Map<string, AxiosInstance>();
/**
* The same client pointed at the connected remote server, for a desktop app
* host whose connection origin is "remote". Outside Electron, and for
* "local", it is the ordinary client.
*/
export function pluginApiFor(
pluginId: string,
origin: ConnectionOrigin | undefined,
local: AxiosInstance,
): AxiosInstance {
if (origin !== "remote" || !isElectron()) return local;
let client = remotePluginApis.get(pluginId);
if (!client) {
client = createRemoteOriginApiInstance(pluginApiPath(pluginId));
remotePluginApis.set(pluginId, client);
}
return client;
}
/**
* A one minute socket token for the web app. The HttpOnly cookie alone fails
* behind a proxy that rewrites Host. Null for a guest or an older server, and
* the socket then falls back to the cookie.
*/
async function fetchSocketTicket(): Promise<string | null> {
const base = (authApi.defaults.baseURL ?? "").replace(/\/+$/, "");
try {
const response = await fetch(`${base}/users/ws-ticket`, {
method: "POST",
credentials: "include",
});
if (!response.ok) return null;
const data = (await response.json()) as { ticket?: unknown };
return typeof data.ticket === "string" ? data.ticket : null;
} catch {
return null;
}
}
/**
* The WebSocket URL for /plugin-ws/<id>/<path>, plus the subprotocols that
* carry the JWT.
*
* In Electron the right backend depends on where the host actually lives, so
* pass the resolved origin; without one it targets the embedded local backend,
* which is what a plugin with no per-host notion of origin wants.
*/
export async function pluginWsUrl(
pluginId: string,
path: string,
options: { origin?: ConnectionOrigin } = {},
): Promise<WebSocketConnectionTarget | null> {
const suffix = path.startsWith("/") ? path : `/${path}`;
const route = `/plugin-ws/${pluginId}${suffix}`;
if (isElectron()) {
return buildOriginWsUrl({
origin: options.origin ?? "local",
localPort: BACKEND_PORT,
localPath: route,
remotePath: route,
});
}
const token = (await fetchSocketTicket()) ?? localStorage.getItem("jwt");
const protocols = websocketAuthProtocols(token);
// Dev without a configured API host goes through Vite's proxy, which is
// reached on the page's own origin rather than the backend port. Vite moves
// to another port when 5173 is taken, so the port says nothing here.
const devProxy =
process.env.NODE_ENV === "development" && !import.meta.env.VITE_API_HOST;
const wsProtocol = window.location.protocol === "https:" ? "wss" : "ws";
if (devProxy) {
return {
url: `${wsProtocol}://${window.location.host}/__termix_api/${BACKEND_PORT}${route}`,
protocols,
};
}
return {
url: `${wsProtocol}://${window.location.host}${getBasePath()}${route}`,
protocols,
};
}