feat: serve plugin http and websockets from one mount instead of ports

This commit is contained in:
LukeGus committed 2026-09-22 16:39:41 -05:00
1 parent c496a093ca
commit 8431570ff7
102 files changed
+2894 -2308

No files matched your search

+126
View File
@@ -0,0 +1,126 @@
/**
* 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/plugin-sdk/frontend. They live here for
* now because plugin frontends are still bundled with the shell.
*/
import type { AxiosInstance } from "axios";
import { authApi } from "@/main-axios";
import { getBasePath } from "@/lib/base-path";
import { isElectron } from "@/lib/electron";
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;
export 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"
) {
return (url: string, ...rest: unknown[]) =>
(value as (...args: unknown[]) => unknown).call(
target,
`${prefix}${url.startsWith("/") ? url : `/${url}`}`,
...rest,
);
}
return value.bind(target);
},
}) as AxiosInstance;
}
/**
* 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 = 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.
const devProxy =
process.env.NODE_ENV === "development" &&
!import.meta.env.VITE_API_HOST &&
(window.location.port === "3000" || window.location.port === "5173");
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,
};
}
+2 -2
View File
@@ -1,5 +1,5 @@
import type { AxiosInstance } from "axios";
import { getRemoteStatsApi } from "@/main-axios";
import { getRemoteCoreApi } from "@/main-axios";
import { isElectron } from "@/lib/electron";
import type { SSHHost } from "@/types/index";
@@ -77,7 +77,7 @@ export async function getConnectedRemoteApi(): Promise<AxiosInstance | null> {
const config = (await window.electronAPI?.invoke?.(
"get-remote-sync-config",
)) as { serverUrl?: string } | null;
return config?.serverUrl ? getRemoteStatsApi() : null;
return config?.serverUrl ? getRemoteCoreApi() : null;
} catch {
return null;
}