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

+46 -33
View File
@@ -45,14 +45,14 @@ interface SSHSession {
const activeSessions = new Map<string, SSHSession>();
const CONSOLE_PORT = 30009;
/**
* Created by startConsoleServer(), not at module load.
*
* Importing this file used to bind the port as a side effect, which meant the
* Importing this file used to bind a port as a side effect, which meant the
* plugin could not be disabled and re-enabled: the module stays in the ESM
* cache, so the second activate() got a server that had already been closed.
* There is no port now -- core routes /plugin-ws/docker/console here -- but
* the same lifecycle applies to the server object itself.
*/
let wss: WebSocketServer | null = null;
@@ -60,40 +60,46 @@ function handleConnection(ws: WebSocket, req: import("http").IncomingMessage) {
void onConsoleConnection(ws, req);
}
/** Starts the console WebSocket server. Call from the plugin's activate(). */
/** Builds the console WebSocket server. Call from the plugin's activate(). */
export function startConsoleServer(): Promise<void> {
if (wss) return Promise.resolve();
return new Promise((resolve, reject) => {
const server = new WebSocketServer({
host: "127.0.0.1",
port: CONSOLE_PORT,
maxPayload: MAX_WS_MESSAGE_BYTES,
const server = new WebSocketServer({
noServer: true,
maxPayload: MAX_WS_MESSAGE_BYTES,
});
// One bad socket must not take the server down.
server.on("error", (error) => {
sshLogger.error("Docker console WebSocket server error", error, {
operation: "wss_error",
});
});
server.on("connection", handleConnection);
const onStartupError = (error: Error) => {
server.off("listening", onListening);
wss = null;
reject(error);
};
wss = server;
return Promise.resolve();
}
const onListening = () => {
server.off("error", onStartupError);
// Past startup an error is logged rather than fatal: one bad socket
// must not take the server down.
server.on("error", (error) => {
sshLogger.error("Docker console WebSocket server error", error, {
operation: "wss_error",
});
});
resolve();
};
server.once("error", onStartupError);
server.once("listening", onListening);
server.on("connection", handleConnection);
wss = server;
/**
* Hands one upgrade to the console server.
*
* Registered as a public ctx.ws route because onConsoleConnection does its own
* per-message authentication, which is where the container and host checks
* live too.
*/
export function handleConsoleUpgrade(
request: import("http").IncomingMessage,
socket: import("stream").Duplex,
head: Buffer,
): void {
const server = wss;
if (!server) {
socket.destroy();
return;
}
server.handleUpgrade(request, socket, head, (ws) => {
server.emit("connection", ws, request);
});
}
@@ -870,8 +876,8 @@ async function onConsoleConnection(
}
/**
* Closes every live console session and the WebSocket server, freeing port
* 30009. Called from the plugin's deactivate().
* Closes every live console session and the WebSocket server. Called from the
* plugin's deactivate().
*
* There is deliberately no process signal handler here. Core owns shutdown:
* gracefulShutdown in src/backend/starter.ts calls shutdownPlugins(), which
@@ -894,5 +900,12 @@ export function closeConsoleServer(): Promise<void> {
if (!server) return Promise.resolve();
server.off("connection", handleConnection);
for (const client of server.clients) {
try {
client.close(1001, "Docker plugin disabled");
} catch {
// Already gone.
}
}
return new Promise((resolve) => server.close(() => resolve()));
}
+28 -37
View File
@@ -1,9 +1,5 @@
import { createServer, type Server } from "node:http";
import express from "express";
import cookieParser from "cookie-parser";
import express, { type Router } from "express";
import type { PluginContext } from "@termix/plugin-sdk/backend";
import { createCorsMiddleware } from "../../../../src/backend/utils/cors-config.js";
import { createCompressionMiddleware } from "../../../../src/backend/utils/compression-config.js";
import { logger } from "../../../../src/backend/utils/logger.js";
import { AuthManager } from "../../../../src/backend/utils/auth-manager.js";
import { registerDockerContainerRoutes } from "./container-routes.js";
@@ -17,25 +13,21 @@ import {
getRequestUserId,
registerDockerSshRoutes,
} from "./routes.js";
import { startConsoleServer, closeConsoleServer } from "./console.js";
import {
closeConsoleServer,
handleConsoleUpgrade,
startConsoleServer,
} from "./console.js";
const PORT = 30007;
let httpServer: Server | null = null;
let authManagerInstance: ReturnType<typeof AuthManager.getInstance> | null =
null;
export async function activate(ctx: PluginContext) {
// An express app rather than the router directly, so the existing
// register*Routes helpers keep the Application they expect. Core mounts it
// at /plugin-api/docker and runs compression, CORS, cookies, auth and body
// parsing in front of it.
const app = express();
app.set("trust proxy", "loopback");
app.use(createCompressionMiddleware());
app.use(createCorsMiddleware(["GET", "POST", "PUT", "DELETE", "OPTIONS"]));
app.use(cookieParser());
authManagerInstance = AuthManager.getInstance();
app.use(authManagerInstance.createAuthMiddleware());
app.use(express.json({ limit: "100mb" }));
app.use(express.urlencoded({ limit: "100mb", extended: true }));
app.use((_req, res, next) => {
res.setHeader("Cache-Control", "no-store");
next();
@@ -50,16 +42,9 @@ export async function activate(ctx: PluginContext) {
dockerTimestampPattern: DOCKER_TIMESTAMP_RE,
});
const server = createServer(app);
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(PORT, "127.0.0.1", () => {
server.off("error", reject);
resolve();
});
});
httpServer = server;
ctx.http.router<Router>({ bodyLimit: "100mb" }).use(app);
authManagerInstance = AuthManager.getInstance();
try {
await authManagerInstance.initialize();
} catch (err) {
@@ -68,21 +53,27 @@ export async function activate(ctx: PluginContext) {
});
}
// The console server is started here rather than on import so disabling the
// plugin frees port 30009 and re-enabling can take it again.
await startConsoleServer();
ctx.log.info(`Docker plugin listening on ${PORT} (console on 30009)`);
// Public because the console authenticates each session over the socket
// itself, where the container and host checks also live.
ctx.ws.upgrade(
"/console",
(request, socket, head) =>
handleConsoleUpgrade(
request as Parameters<typeof handleConsoleUpgrade>[0],
socket as Parameters<typeof handleConsoleUpgrade>[1],
head as Buffer,
),
{ public: true },
);
ctx.log.info(
"Docker mounted at /plugin-api/docker and /plugin-ws/docker/console",
);
}
export async function deactivate() {
await closeConsoleServer();
if (httpServer) {
const server = httpServer;
httpServer = null;
await new Promise<void>((resolve) => server.close(() => resolve()));
}
authManagerInstance = null;
}
@@ -9,11 +9,8 @@ import { WebLinksAddon } from "@xterm/addon-web-links";
import { Button } from "@/components/button.tsx";
import { Select2 } from "@/components/select2";
import { Card, CardContent } from "@/components/card.tsx";
import { getBasePath } from "@/lib/base-path";
import {
resolveConnectionOrigin,
buildOriginWsUrl,
} from "@/lib/connection-origin.ts";
import { resolveConnectionOrigin } from "@/lib/connection-origin.ts";
import { pluginWsUrl } from "@/lib/plugin-transport";
import { Terminal as TerminalIcon, Power, PowerOff } from "lucide-react";
import { toast } from "sonner";
import type { SSHHost } from "@/types";
@@ -299,40 +296,21 @@ function ConsoleTerminalInner({
const isElectronApp = isElectron();
const isDev =
!isElectronApp &&
process.env.NODE_ENV === "development" &&
(window.location.port === "3000" ||
window.location.port === "5173" ||
window.location.port === "");
const origin = isElectronApp
? await resolveConnectionOrigin({
connectionType: "ssh",
connectionOrigin: hostConfig.connectionOrigin,
})
: "local";
let baseWsUrl: string;
let wsProtocols: string[] = [];
if (isDev) {
baseWsUrl = `${window.location.protocol === "https:" ? "wss" : "ws"}://localhost:30009`;
} else if (isElectronApp) {
const origin = await resolveConnectionOrigin({
connectionType: "ssh",
connectionOrigin: hostConfig.connectionOrigin,
});
const resolvedUrl = await buildOriginWsUrl({
origin,
localPort: 30009,
localPath: "/docker/console/",
remotePath: "/docker/console/",
});
if (!resolvedUrl) {
setIsConnecting(false);
toast.error(t("errors.remoteServerRequired"));
return;
}
baseWsUrl = resolvedUrl.url;
wsProtocols = resolvedUrl.protocols;
} else {
baseWsUrl = `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}${getBasePath()}/docker/console/`;
const resolvedUrl = await pluginWsUrl("docker", "/console", { origin });
if (!resolvedUrl) {
setIsConnecting(false);
toast.error(t("errors.remoteServerRequired"));
return;
}
const ws = new WebSocket(baseWsUrl, wsProtocols);
const ws = new WebSocket(resolvedUrl.url, resolvedUrl.protocols);
ws.onopen = () => {
const cols = terminal.cols || 80;