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

+1
View File
@@ -18,6 +18,7 @@
"hosts:read",
"credentials:use",
"ssh:connect",
"network:serve",
"ui:surface"
],
"contributes": {
+2 -2
View File
@@ -2,8 +2,8 @@ import type { PluginContext } from "@termix/plugin-sdk/backend";
import { startWebEndpointService, stopWebEndpointService } from "./routes.js";
export async function activate(ctx: PluginContext) {
startWebEndpointService();
ctx.log.info("Web Endpoint router registered at /ssh/tunnel/web-endpoint");
startWebEndpointService(ctx.http.router());
ctx.log.info("Web Endpoint routes mounted at /plugin-api/web-endpoint");
}
export async function deactivate() {
+4 -8
View File
@@ -1,4 +1,4 @@
import express from "express";
import express, { type Router } from "express";
import { Client } from "ssh2";
import { AuthManager } from "../../../../src/backend/utils/auth-manager.js";
import { tunnelLogger } from "../../../../src/backend/utils/logger.js";
@@ -12,10 +12,6 @@ import {
} from "../../../../src/backend/hosts/tunnel/manager.js";
import { forwardOut } from "../../../../src/backend/hosts/tunnel/ssh-primitives.js";
import { buildWebEndpointTunnelName } from "../../../../src/backend/hosts/tunnel/utils.js";
import {
registerWebEndpointRouter,
unregisterWebEndpointRouter,
} from "../../../../src/backend/hosts/tunnel/web-endpoint-dispatch.js";
import type { TunnelConfig, WebEndpoint } from "../../../../src/types/index.js";
/** Matches the spec's ten minutes. */
@@ -302,7 +298,7 @@ export const router = express.Router();
let started = false;
export function startWebEndpointService(): void {
export function startWebEndpointService(mountOn: Router): void {
if (!started) {
// Built on first start rather than at module scope so importing this
// module for a handler unit test does not construct the auth singleton.
@@ -315,9 +311,9 @@ export function startWebEndpointService(): void {
router.post("/open", authenticateJWT, handleWebEndpointOpen);
started = true;
}
registerWebEndpointRouter(router);
mountOn.use(router);
}
export function stopWebEndpointService(): void {
unregisterWebEndpointRouter();
// Unmounted by the runtime when the plugin deactivates.
}
@@ -305,33 +305,28 @@ describe("POST /tunnel/web-endpoint/open", () => {
});
/**
* The client resolves "/tunnel/web-endpoint/open" against a baseURL that
* already ends in /ssh, and nginx proxies /ssh through with the path intact --
* so the server must serve the FULL "/ssh/tunnel/web-endpoint/open" path.
* Serving the unprefixed form 404s every call, and no handler unit test
* notices because they call the handler directly.
* The client path and the server path have to agree.
*
* This was caught only by opening a real tunnel against a deployed build.
* This guards a real bug: the route once served the unprefixed form while the
* client called the full one, so every call 404d and no handler unit test
* noticed, because those call the handler directly. It was caught only by
* opening a tunnel against a deployed build.
*
* Since the plugin migration that path is composed of two halves: the mount
* point in src/backend/hosts/tunnel/index.ts and the route on the plugin's own
* router. Either half drifting breaks the URL, so both are asserted here.
* The shape changed with A4. The route used to be composed of a mount point in
* the tunnel service (/ssh/tunnel/web-endpoint, port 30003) plus "/open" on
* this router. Now core mounts every plugin at /plugin-api/<id>, so the client
* calls /plugin-api/web-endpoint/open and the only half this plugin owns is
* "/open". Asserting that keeps the guard meaningful without re-encoding a
* prefix the plugin no longer controls.
*/
describe("route registration", () => {
it("registers /open on the router the dispatcher forwards to", async () => {
it("registers /open on the router core hands it", async () => {
const routes = await import("../../src/backend/routes.js");
const dispatch =
await import("../../../../src/backend/hosts/tunnel/web-endpoint-dispatch.js");
const registered: unknown[] = [];
vi.spyOn(dispatch, "registerWebEndpointRouter").mockImplementation(
(router) => {
registered.push(router);
},
);
const express = (await import("express")).default;
const mountOn = express.Router();
routes.startWebEndpointService();
routes.startWebEndpointService(mountOn);
expect(registered).toHaveLength(1);
const paths = (
routes.router as unknown as {
stack: Array<{ route?: { path: string; methods: { post?: boolean } } }>;
@@ -339,15 +334,17 @@ describe("route registration", () => {
).stack
.filter((layer) => layer.route?.methods.post)
.map((layer) => layer.route?.path);
expect(paths).toContain("/open");
});
it("is mounted so the full client path resolves", async () => {
it("is reached through the plugin mount rather than a port of its own", async () => {
const source = await readFile(
new URL("../../../../src/backend/hosts/tunnel/index.ts", import.meta.url),
new URL("../../src/backend/index.ts", import.meta.url),
"utf8",
);
expect(source).toContain('app.use("/ssh/tunnel/web-endpoint"');
// ctx.http.router() is what puts this plugin at /plugin-api/web-endpoint.
expect(source).toContain("ctx.http.router()");
});
});