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
+25
View File
@@ -77,6 +77,31 @@ describe("buildApp", () => {
expect(emptyListResponse.json<Project[]>()).toEqual([]);
});
it("serves local session proxy routes through machine-scoped aliases", async () => {
const response = await app.inject({ method: "GET", url: `/api/machines/local/sessions?cwd=${encodeURIComponent(projectDir)}` });
expect(response.statusCode).toBe(502);
expect(response.json()).toHaveProperty("error");
});
it("serves local projects and workspaces through machine-scoped aliases", async () => {
const addResponse = await app.inject({
method: "POST",
url: "/api/machines/local/projects",
payload: { name: "Machine Local", path: projectDir, create: true },
});
expect(addResponse.statusCode).toBe(200);
const project = addResponse.json<Project>();
const listResponse = await app.inject({ method: "GET", url: "/api/machines/local/projects" });
expect(listResponse.statusCode).toBe(200);
expect(listResponse.json<Project[]>()).toEqual([project]);
const workspacesResponse = await app.inject({ method: "GET", url: `/api/machines/local/projects/${project.id}/workspaces` });
expect(workspacesResponse.statusCode).toBe(200);
expect(workspacesResponse.json<Workspace[]>()).toEqual([expect.objectContaining({ projectId: project.id, path: projectDir })]);
});
it("serves the PI WEB plugin manifest and plugin assets", async () => {
const manifestResponse = await app.inject({ method: "GET", url: "/pi-web-plugins/manifest.json" });
expect(manifestResponse.statusCode).toBe(200);
+58 -44
View File
@@ -27,6 +27,56 @@ export interface AppDependencies {
logger?: FastifyServerOptions["logger"];
}
function registerLocalProjectRoutes(app: FastifyInstance, projects: ProjectService, workspaces: WorkspaceService, prefix: string): void {
app.get(`${prefix}/projects`, async () => projects.list());
app.post<{ Body: { name?: string; path: string; create?: boolean } }>(`${prefix}/projects`, async (request, reply) => {
try {
return await projects.add(request.body);
} catch (error) {
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
}
});
app.delete<{ Params: { projectId: string } }>(`${prefix}/projects/:projectId`, async (request, reply) => {
try {
await projects.close(request.params.projectId);
return { closed: true };
} catch (error) {
return reply.code(404).send({ error: error instanceof Error ? error.message : String(error) });
}
});
app.get<{ Querystring: { q?: string } }>(`${prefix}/project-directories`, async (request, reply) => {
try {
return await listDirectorySuggestions(request.query.q ?? "");
} catch (error) {
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
}
});
app.get<{ Params: { projectId: string } }>(`${prefix}/projects/:projectId/workspaces`, async (request, reply) => {
try {
const project = await projects.requireProject(request.params.projectId);
return await workspaces.list(project);
} catch (error) {
return reply.code(404).send({ error: error instanceof Error ? error.message : String(error) });
}
});
}
function registerLocalFileSuggestionRoutes(app: FastifyInstance, prefix: string): void {
app.get<{ Querystring: { cwd?: string; q?: string; kind?: "tracked" | "untracked" | "other"; mode?: "file" | "path" } }>(`${prefix}/files`, async (request, reply) => {
if (request.query.cwd === undefined || request.query.cwd === "") return reply.code(400).send({ error: "cwd query parameter is required" });
try {
if (request.query.mode === "path") return await listPathSuggestions(request.query.cwd, request.query.q ?? "");
return await listFileSuggestions(request.query.cwd, request.query.q ?? "", request.query.kind);
} catch (error) {
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
}
});
}
export async function buildApp(deps: AppDependencies = {}): Promise<FastifyInstance> {
const app = Fastify({ logger: deps.logger ?? true });
await app.register(fastifyWebsocket);
@@ -48,56 +98,20 @@ export async function buildApp(deps: AppDependencies = {}): Promise<FastifyInsta
registerMachineRoutes(app, machines);
app.get("/api/projects", async () => projects.list());
app.post<{ Body: { name?: string; path: string; create?: boolean } }>("/api/projects", async (request, reply) => {
try {
return await projects.add(request.body);
} catch (error) {
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
}
});
app.delete<{ Params: { projectId: string } }>("/api/projects/:projectId", async (request, reply) => {
try {
await projects.close(request.params.projectId);
return { closed: true };
} catch (error) {
return reply.code(404).send({ error: error instanceof Error ? error.message : String(error) });
}
});
app.get<{ Querystring: { q?: string } }>("/api/project-directories", async (request, reply) => {
try {
return await listDirectorySuggestions(request.query.q ?? "");
} catch (error) {
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
}
});
app.get<{ Params: { projectId: string } }>("/api/projects/:projectId/workspaces", async (request, reply) => {
try {
const project = await projects.requireProject(request.params.projectId);
return await workspaces.list(project);
} catch (error) {
return reply.code(404).send({ error: error instanceof Error ? error.message : String(error) });
}
});
registerLocalProjectRoutes(app, projects, workspaces, "/api");
registerLocalProjectRoutes(app, projects, workspaces, "/api/machines/local");
registerSessionProxyRoutes(app);
registerSessionProxyRoutes(app, undefined, "/api/machines/local");
registerWorkspaceExplorerRoutes(app, projects, workspaces);
registerWorkspaceExplorerRoutes(app, projects, workspaces, "/api/machines/local");
registerGitRoutes(app, projects, workspaces);
registerGitRoutes(app, projects, workspaces, "/api/machines/local");
registerTerminalProxyRoutes(app, projects, workspaces);
registerTerminalProxyRoutes(app, projects, workspaces, undefined, "/api/machines/local");
app.get<{ Querystring: { cwd?: string; q?: string; kind?: "tracked" | "untracked" | "other"; mode?: "file" | "path" } }>("/api/files", async (request, reply) => {
if (request.query.cwd === undefined || request.query.cwd === "") return reply.code(400).send({ error: "cwd query parameter is required" });
try {
if (request.query.mode === "path") return await listPathSuggestions(request.query.cwd, request.query.q ?? "");
return await listFileSuggestions(request.query.cwd, request.query.q ?? "", request.query.kind);
} catch (error) {
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
}
});
registerLocalFileSuggestionRoutes(app, "/api");
registerLocalFileSuggestionRoutes(app, "/api/machines/local");
const packagedClientDist = join(dirname(fileURLToPath(import.meta.url)), "..", "client");
const clientDist = deps.clientDist ?? (existsSync(packagedClientDist) ? packagedClientDist : join(process.cwd(), "dist", "client"));
+3 -3
View File
@@ -4,8 +4,8 @@ import type { WorkspaceService } from "./workspaces/workspaceService.js";
import { resolveWorkspaceContext } from "./workspaces/workspaceContext.js";
import { gitDiff, gitStatus } from "./git/gitService.js";
export function registerGitRoutes(app: FastifyInstance, projects: ProjectService, workspaces: WorkspaceService): void {
app.get<{ Params: { projectId: string; workspaceId: string } }>("/api/projects/:projectId/workspaces/:workspaceId/git/status", async (request, reply) => {
export function registerGitRoutes(app: FastifyInstance, projects: ProjectService, workspaces: WorkspaceService, prefix = "/api"): void {
app.get<{ Params: { projectId: string; workspaceId: string } }>(`${prefix}/projects/:projectId/workspaces/:workspaceId/git/status`, async (request, reply) => {
try {
const context = await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId);
return await gitStatus(context.root);
@@ -14,7 +14,7 @@ export function registerGitRoutes(app: FastifyInstance, projects: ProjectService
}
});
app.get<{ Params: { projectId: string; workspaceId: string }; Querystring: { path?: string; staged?: string } }>("/api/projects/:projectId/workspaces/:workspaceId/git/diff", async (request, reply) => {
app.get<{ Params: { projectId: string; workspaceId: string }; Querystring: { path?: string; staged?: string } }>(`${prefix}/projects/:projectId/workspaces/:workspaceId/git/diff`, async (request, reply) => {
try {
const context = await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId);
return await gitDiff(context.root, { ...(request.query.path === undefined ? {} : { path: request.query.path }), staged: request.query.staged === "true" });
@@ -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;
}
+10 -10
View File
@@ -6,8 +6,8 @@ import type { WorkspaceService } from "./workspaces/workspaceService.js";
import { terminalSizeQuery } from "./terminals/terminalSize.js";
import { bridgeSockets } from "./webSocketBridge.js";
export function registerTerminalProxyRoutes(app: FastifyInstance, projects: ProjectService, workspaces: WorkspaceService, daemon = new SessionDaemonClient()): void {
app.get<{ Params: { projectId: string; workspaceId: string } }>("/api/projects/:projectId/workspaces/:workspaceId/terminals", async (request, reply) => {
export function registerTerminalProxyRoutes(app: FastifyInstance, projects: ProjectService, workspaces: WorkspaceService, daemon = new SessionDaemonClient(), prefix = "/api"): void {
app.get<{ Params: { projectId: string; workspaceId: string } }>(`${prefix}/projects/:projectId/workspaces/:workspaceId/terminals`, async (request, reply) => {
try {
const context = await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId);
return await proxyJson(daemon, "GET", `/terminals?cwd=${encodeURIComponent(context.root)}`, undefined, reply);
@@ -17,7 +17,7 @@ export function registerTerminalProxyRoutes(app: FastifyInstance, projects: Proj
}
});
app.post<{ Params: { projectId: string; workspaceId: string }; Body: { name?: string; cols?: number; rows?: number } }>("/api/projects/:projectId/workspaces/:workspaceId/terminals", async (request, reply) => {
app.post<{ Params: { projectId: string; workspaceId: string }; Body: { name?: string; cols?: number; rows?: number } }>(`${prefix}/projects/:projectId/workspaces/:workspaceId/terminals`, async (request, reply) => {
try {
const context = await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId);
return await proxyJson(daemon, "POST", "/terminals", { ...request.body, cwd: context.root }, reply);
@@ -27,7 +27,7 @@ export function registerTerminalProxyRoutes(app: FastifyInstance, projects: Proj
}
});
app.post<{ Params: { projectId: string; workspaceId: string; terminalId: string } }>("/api/projects/:projectId/workspaces/:workspaceId/terminals/:terminalId/continue", async (request, reply) => {
app.post<{ Params: { projectId: string; workspaceId: string; terminalId: string } }>(`${prefix}/projects/:projectId/workspaces/:workspaceId/terminals/:terminalId/continue`, async (request, reply) => {
try {
await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId);
return await proxyJson(daemon, "POST", `/terminals/${encodeURIComponent(request.params.terminalId)}/continue`, undefined, reply);
@@ -37,7 +37,7 @@ export function registerTerminalProxyRoutes(app: FastifyInstance, projects: Proj
}
});
app.delete<{ Params: { projectId: string; workspaceId: string; terminalId: string } }>("/api/projects/:projectId/workspaces/:workspaceId/terminals/:terminalId", async (request, reply) => {
app.delete<{ Params: { projectId: string; workspaceId: string; terminalId: string } }>(`${prefix}/projects/:projectId/workspaces/:workspaceId/terminals/:terminalId`, async (request, reply) => {
try {
await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId);
return await proxyJson(daemon, "DELETE", `/terminals/${encodeURIComponent(request.params.terminalId)}`, undefined, reply);
@@ -47,7 +47,7 @@ export function registerTerminalProxyRoutes(app: FastifyInstance, projects: Proj
}
});
app.post<{ Params: { projectId: string; workspaceId: string }; Body: TerminalCommandRunRequest }>("/api/projects/:projectId/workspaces/:workspaceId/terminal-command-runs", async (request, reply) => {
app.post<{ Params: { projectId: string; workspaceId: string }; Body: TerminalCommandRunRequest }>(`${prefix}/projects/:projectId/workspaces/:workspaceId/terminal-command-runs`, async (request, reply) => {
try {
const context = await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId);
return await proxyJson(daemon, "POST", "/terminal-command-runs", {
@@ -65,7 +65,7 @@ export function registerTerminalProxyRoutes(app: FastifyInstance, projects: Proj
}
});
app.get<{ Querystring: TerminalCommandRunQuery }>("/api/terminal-command-runs", async (request, reply) => {
app.get<{ Querystring: TerminalCommandRunQuery }>(`${prefix}/terminal-command-runs`, async (request, reply) => {
try {
return await proxyJson(daemon, "GET", `/terminal-command-runs${terminalCommandRunQuery(request.query)}`, undefined, reply);
} catch (error) {
@@ -74,7 +74,7 @@ export function registerTerminalProxyRoutes(app: FastifyInstance, projects: Proj
}
});
app.post<{ Params: { runId: string } }>("/api/terminal-command-runs/:runId/cancel", async (request, reply) => {
app.post<{ Params: { runId: string } }>(`${prefix}/terminal-command-runs/:runId/cancel`, async (request, reply) => {
try {
return await proxyJson(daemon, "POST", `/terminal-command-runs/${encodeURIComponent(request.params.runId)}/cancel`, undefined, reply);
} catch (error) {
@@ -83,7 +83,7 @@ export function registerTerminalProxyRoutes(app: FastifyInstance, projects: Proj
}
});
app.get<{ Params: { runId: string } }>("/api/terminal-command-runs/:runId", async (request, reply) => {
app.get<{ Params: { runId: string } }>(`${prefix}/terminal-command-runs/:runId`, async (request, reply) => {
try {
return await proxyJson(daemon, "GET", `/terminal-command-runs/${encodeURIComponent(request.params.runId)}`, undefined, reply);
} catch (error) {
@@ -92,7 +92,7 @@ export function registerTerminalProxyRoutes(app: FastifyInstance, projects: Proj
}
});
app.get<{ Params: { projectId: string; workspaceId: string; terminalId: string }; Querystring: { cols?: string; rows?: string } }>("/api/projects/:projectId/workspaces/:workspaceId/terminals/:terminalId/socket", { websocket: true }, async (socket, request) => {
app.get<{ Params: { projectId: string; workspaceId: string; terminalId: string }; Querystring: { cols?: string; rows?: string } }>(`${prefix}/projects/:projectId/workspaces/:workspaceId/terminals/:terminalId/socket`, { websocket: true }, async (socket, request) => {
try {
await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId);
const sizeQuery = terminalSizeQuery(request.query.cols, request.query.rows);
+4 -4
View File
@@ -6,8 +6,8 @@ import { listWorkspaceTree } from "./workspaces/fileTreeService.js";
import { readWorkspaceFile } from "./workspaces/fileContentService.js";
import { readWorkspaceImagePreview } from "./workspaces/imagePreviewService.js";
export function registerWorkspaceExplorerRoutes(app: FastifyInstance, projects: ProjectService, workspaces: WorkspaceService): void {
app.get<{ Params: { projectId: string; workspaceId: string }; Querystring: { path?: string } }>("/api/projects/:projectId/workspaces/:workspaceId/tree", async (request, reply) => {
export function registerWorkspaceExplorerRoutes(app: FastifyInstance, projects: ProjectService, workspaces: WorkspaceService, prefix = "/api"): void {
app.get<{ Params: { projectId: string; workspaceId: string }; Querystring: { path?: string } }>(`${prefix}/projects/:projectId/workspaces/:workspaceId/tree`, async (request, reply) => {
try {
const context = await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId);
return await listWorkspaceTree(context.root, request.query.path);
@@ -16,7 +16,7 @@ export function registerWorkspaceExplorerRoutes(app: FastifyInstance, projects:
}
});
app.get<{ Params: { projectId: string; workspaceId: string }; Querystring: { path?: string } }>("/api/projects/:projectId/workspaces/:workspaceId/file", async (request, reply) => {
app.get<{ Params: { projectId: string; workspaceId: string }; Querystring: { path?: string } }>(`${prefix}/projects/:projectId/workspaces/:workspaceId/file`, async (request, reply) => {
try {
const context = await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId);
return await readWorkspaceFile(context.root, request.query.path);
@@ -25,7 +25,7 @@ export function registerWorkspaceExplorerRoutes(app: FastifyInstance, projects:
}
});
app.get<{ Params: { projectId: string; workspaceId: string }; Querystring: { path?: string } }>("/api/projects/:projectId/workspaces/:workspaceId/file/preview", async (request, reply) => {
app.get<{ Params: { projectId: string; workspaceId: string }; Querystring: { path?: string } }>(`${prefix}/projects/:projectId/workspaces/:workspaceId/file/preview`, async (request, reply) => {
try {
const context = await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId);
const preview = await readWorkspaceImagePreview(context.root, request.query.path);