feat: add machine-scoped local API aliases

This commit is contained in:
Marc Kassubeck
2026-05-26 13:06:56 +02:00
parent 418216b9b7
commit b5f8810eda
27 changed files with 664 additions and 229 deletions
@@ -0,0 +1,49 @@
import Fastify, { type FastifyInstance } from "fastify";
import fastifyWebsocket from "@fastify/websocket";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { registerSessionProxyRoutes } from "./sessionProxyRoutes";
let app: FastifyInstance;
let daemon: FakeSessionDaemon;
beforeEach(async () => {
app = Fastify({ logger: false });
await app.register(fastifyWebsocket);
daemon = new FakeSessionDaemon();
registerSessionProxyRoutes(app, daemon, "/api/machines/local");
});
afterEach(async () => {
await app.close();
});
describe("machine-scoped session proxy routes", () => {
it("strips the machine prefix before forwarding session requests", async () => {
const response = await app.inject({ method: "GET", url: "/api/machines/local/sessions?cwd=/repo" });
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ ok: true });
expect(daemon.requests).toEqual([{ method: "GET", path: "/sessions?cwd=/repo", body: undefined }]);
});
it("strips the machine prefix before forwarding auth requests", async () => {
const response = await app.inject({ method: "POST", url: "/api/machines/local/auth/api-key", payload: { providerId: "p", key: "k" } });
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ ok: true });
expect(daemon.requests).toEqual([{ method: "POST", path: "/auth/api-key", body: { providerId: "p", key: "k" } }]);
});
});
class FakeSessionDaemon {
readonly requests: { method: string; path: string; body: unknown }[] = [];
request(method: string, path: string, body?: unknown): Promise<{ statusCode: number; headers: Record<string, string>; body: string }> {
this.requests.push({ method, path, body });
return Promise.resolve({ statusCode: 200, headers: { "content-type": "application/json" }, body: JSON.stringify({ ok: true }) });
}
connectWebSocket(): never {
throw new Error("not implemented");
}
}
+20 -13
View File
@@ -2,10 +2,15 @@ import type { FastifyInstance, FastifyReply } from "fastify";
import { WebSocket, type RawData } from "ws";
import { SessionDaemonClient } from "./sessionDaemonClient.js";
export function registerSessionProxyRoutes(app: FastifyInstance, daemon = new SessionDaemonClient()): void {
export interface SessionProxyDaemon {
request(method: string, path: string, body?: unknown): Promise<{ statusCode: number; headers: Record<string, string>; body: string }>;
connectWebSocket(path: string): WebSocket;
}
export function registerSessionProxyRoutes(app: FastifyInstance, daemon: SessionProxyDaemon = new SessionDaemonClient(), prefix = "/api"): void {
const proxy = async (request: { method: string; url: string; body?: unknown }, reply: FastifyReply) => {
try {
const upstream = await daemon.request(request.method, stripApiPrefix(request.url), request.body);
const upstream = await daemon.request(request.method, stripPrefix(request.url, prefix), request.body);
reply.code(upstream.statusCode);
const contentType = upstream.headers["content-type"];
if (contentType !== undefined && contentType !== "") reply.header("content-type", contentType);
@@ -16,29 +21,31 @@ export function registerSessionProxyRoutes(app: FastifyInstance, daemon = new Se
}
};
app.get("/api/sessiond/health", (_request, reply) => proxy({ method: "GET", url: "/api/health" }, reply));
app.get(`${prefix}/sessiond/health`, (_request, reply) => proxy({ method: "GET", url: `${prefix}/health` }, reply));
app.get<{ Params: { sessionId: string } }>("/api/sessions/:sessionId/events", { websocket: true }, (socket, request) => {
app.get<{ Params: { sessionId: string } }>(`${prefix}/sessions/:sessionId/events`, { websocket: true }, (socket, request) => {
bridgeSockets(socket, daemon.connectWebSocket(`/sessions/${request.params.sessionId}/events`));
});
app.get("/api/sessions/events", { websocket: true }, (socket) => {
app.get(`${prefix}/sessions/events`, { websocket: true }, (socket) => {
bridgeSockets(socket, daemon.connectWebSocket("/sessions/events"));
});
app.get("/api/events", { websocket: true }, (socket) => {
app.get(`${prefix}/events`, { websocket: true }, (socket) => {
bridgeSockets(socket, daemon.connectWebSocket("/events"));
});
app.all("/api/activity", (request, reply) => proxy(request, reply));
app.all("/api/auth", (request, reply) => proxy(request, reply));
app.all("/api/auth/*", (request, reply) => proxy(request, reply));
app.all("/api/sessions", (request, reply) => proxy(request, reply));
app.all("/api/sessions/*", (request, reply) => proxy(request, reply));
app.all(`${prefix}/activity`, (request, reply) => proxy(request, reply));
app.all(`${prefix}/auth`, (request, reply) => proxy(request, reply));
app.all(`${prefix}/auth/*`, (request, reply) => proxy(request, reply));
app.all(`${prefix}/sessions`, (request, reply) => proxy(request, reply));
app.all(`${prefix}/sessions/*`, (request, reply) => proxy(request, reply));
}
function stripApiPrefix(url: string): string {
const stripped = url.startsWith("/api") ? url.slice(4) : url;
function stripPrefix(url: string, prefix: string): string {
const path = url.split("?", 1)[0] ?? url;
const query = url.slice(path.length);
const stripped = path.startsWith(prefix) ? `${path.slice(prefix.length)}${query}` : url;
return stripped === "" ? "/" : stripped;
}