feat: support deploying pi-web under an arbitrary base path

Make all browser-facing API paths and WebSocket URLs relative so that
vite's <base href> can resolve them correctly when pi-web is served
from a subpath (e.g. /ai or /test/ai).

Also introduce PI_WEB_BASE_PATH so the server can generate plugin
module URLs that include the base path.
This commit is contained in:
chenyimeng
2026-07-12 23:27:43 +02:00
committed by Federico Jaramillo Martinez
parent d72b14f40a
commit 27af5a70ad
11 changed files with 77 additions and 68 deletions
+36 -36
View File
@@ -51,7 +51,7 @@ describe("machine-scoped runtime API", () => {
await piWebApi.piWebStatus("remote a"); await piWebApi.piWebStatus("remote a");
expect(fetchMock).toHaveBeenCalledOnce(); expect(fetchMock).toHaveBeenCalledOnce();
expect(fetchCall(fetchMock, 0)[0]).toBe("/api/machines/remote%20a/pi-web/status"); expect(fetchCall(fetchMock, 0)[0]).toBe("api/machines/remote%20a/pi-web/status");
}); });
it("requests an uncached update check through the local status route", async () => { it("requests an uncached update check through the local status route", async () => {
@@ -80,7 +80,7 @@ describe("machine-scoped runtime API", () => {
await machinesApi.runtime("remote a"); await machinesApi.runtime("remote a");
expect(fetchMock).toHaveBeenCalledOnce(); expect(fetchMock).toHaveBeenCalledOnce();
expect(fetchCall(fetchMock, 0)[0]).toBe("/api/machines/remote%20a/runtime"); expect(fetchCall(fetchMock, 0)[0]).toBe("api/machines/remote%20a/runtime");
}); });
}); });
@@ -97,9 +97,9 @@ describe("settings config and plugin APIs", () => {
await expect(pluginsApi.plugins()).resolves.toEqual(piWebPluginsResponse()); await expect(pluginsApi.plugins()).resolves.toEqual(piWebPluginsResponse());
expect(fetchMock.mock.calls.map((call) => call[0])).toEqual([ expect(fetchMock.mock.calls.map((call) => call[0])).toEqual([
"/api/config", "api/config",
"/api/config", "api/config",
"/api/plugins", "api/plugins",
]); ]);
expect(fetchCall(fetchMock, 1)[1]?.method).toBe("PUT"); expect(fetchCall(fetchMock, 1)[1]?.method).toBe("PUT");
expect(JSON.parse(requestBody(fetchCall(fetchMock, 1)[1]))).toEqual({ config: { spawnSessions: true } }); expect(JSON.parse(requestBody(fetchCall(fetchMock, 1)[1]))).toEqual({ config: { spawnSessions: true } });
@@ -117,9 +117,9 @@ describe("settings config and plugin APIs", () => {
await expect(pluginsApi.plugins("remote a")).resolves.toEqual(piWebPluginsResponse()); await expect(pluginsApi.plugins("remote a")).resolves.toEqual(piWebPluginsResponse());
expect(fetchMock.mock.calls.map((call) => call[0])).toEqual([ expect(fetchMock.mock.calls.map((call) => call[0])).toEqual([
"/api/machines/remote%20a/config", "api/machines/remote%20a/config",
"/api/machines/remote%20a/config", "api/machines/remote%20a/config",
"/api/machines/remote%20a/plugins", "api/machines/remote%20a/plugins",
]); ]);
expect(fetchCall(fetchMock, 1)[1]?.method).toBe("PUT"); expect(fetchCall(fetchMock, 1)[1]?.method).toBe("PUT");
expect(JSON.parse(requestBody(fetchCall(fetchMock, 1)[1]))).toEqual({ config: { spawnSessions: true } }); expect(JSON.parse(requestBody(fetchCall(fetchMock, 1)[1]))).toEqual({ config: { spawnSessions: true } });
@@ -144,11 +144,11 @@ describe("Pi package API", () => {
await piPackagesApi.update(); await piPackagesApi.update();
expect(fetchMock.mock.calls.map((call) => call[0])).toEqual([ expect(fetchMock.mock.calls.map((call) => call[0])).toEqual([
"/api/pi-packages", "api/pi-packages",
"/api/pi-packages/install", "api/pi-packages/install",
"/api/pi-packages/remove", "api/pi-packages/remove",
"/api/pi-packages/update", "api/pi-packages/update",
"/api/pi-packages/update", "api/pi-packages/update",
]); ]);
expect(fetchCall(fetchMock, 1)[1]?.method).toBe("POST"); expect(fetchCall(fetchMock, 1)[1]?.method).toBe("POST");
expect(JSON.parse(requestBody(fetchCall(fetchMock, 1)[1]))).toEqual({ source: "npm:@acme/new-tools" }); expect(JSON.parse(requestBody(fetchCall(fetchMock, 1)[1]))).toEqual({ source: "npm:@acme/new-tools" });
@@ -174,11 +174,11 @@ describe("Pi package API", () => {
await piPackagesApi.update(undefined, "remote a"); await piPackagesApi.update(undefined, "remote a");
expect(fetchMock.mock.calls.map((call) => call[0])).toEqual([ expect(fetchMock.mock.calls.map((call) => call[0])).toEqual([
"/api/machines/local/pi-packages", "api/machines/local/pi-packages",
"/api/machines/remote%20a/pi-packages", "api/machines/remote%20a/pi-packages",
"/api/machines/remote%20a/pi-packages/install", "api/machines/remote%20a/pi-packages/install",
"/api/machines/remote%20a/pi-packages/remove", "api/machines/remote%20a/pi-packages/remove",
"/api/machines/remote%20a/pi-packages/update", "api/machines/remote%20a/pi-packages/update",
]); ]);
expect(JSON.parse(requestBody(fetchCall(fetchMock, 2)[1]))).toEqual({ source: "npm:@acme/new-tools" }); expect(JSON.parse(requestBody(fetchCall(fetchMock, 2)[1]))).toEqual({ source: "npm:@acme/new-tools" });
expect(JSON.parse(requestBody(fetchCall(fetchMock, 3)[1]))).toEqual({ source: "../project-tools" }); expect(JSON.parse(requestBody(fetchCall(fetchMock, 3)[1]))).toEqual({ source: "../project-tools" });
@@ -196,10 +196,10 @@ describe("session API compatibility", () => {
await expect(sessionsApi.cleanup({ archiveIdleDays: 7, projectCwds: ["/repo"] }, "remote a")).resolves.toEqual(executed); await expect(sessionsApi.cleanup({ archiveIdleDays: 7, projectCwds: ["/repo"] }, "remote a")).resolves.toEqual(executed);
expect(fetchMock).toHaveBeenCalledTimes(2); expect(fetchMock).toHaveBeenCalledTimes(2);
expect(fetchCall(fetchMock, 0)[0]).toBe("/api/machines/remote%20a/sessions/cleanup/preview"); expect(fetchCall(fetchMock, 0)[0]).toBe("api/machines/remote%20a/sessions/cleanup/preview");
expect(fetchCall(fetchMock, 0)[1]?.method).toBe("POST"); expect(fetchCall(fetchMock, 0)[1]?.method).toBe("POST");
expect(JSON.parse(requestBody(fetchCall(fetchMock, 0)[1]))).toEqual({ archiveIdleDays: 7, deleteArchivedDays: null }); expect(JSON.parse(requestBody(fetchCall(fetchMock, 0)[1]))).toEqual({ archiveIdleDays: 7, deleteArchivedDays: null });
expect(fetchCall(fetchMock, 1)[0]).toBe("/api/machines/remote%20a/sessions/cleanup"); expect(fetchCall(fetchMock, 1)[0]).toBe("api/machines/remote%20a/sessions/cleanup");
expect(fetchCall(fetchMock, 1)[1]?.method).toBe("POST"); expect(fetchCall(fetchMock, 1)[1]?.method).toBe("POST");
expect(JSON.parse(requestBody(fetchCall(fetchMock, 1)[1]))).toEqual({ archiveIdleDays: 7, projectCwds: ["/repo"] }); expect(JSON.parse(requestBody(fetchCall(fetchMock, 1)[1]))).toEqual({ archiveIdleDays: 7, projectCwds: ["/repo"] });
}); });
@@ -213,10 +213,10 @@ describe("session API compatibility", () => {
await expect(sessionsApi.deleteArchivedMany([{ id: "s 1", cwd: "/repo" }], "remote a")).resolves.toEqual(deleted); await expect(sessionsApi.deleteArchivedMany([{ id: "s 1", cwd: "/repo" }], "remote a")).resolves.toEqual(deleted);
expect(fetchMock).toHaveBeenCalledTimes(2); expect(fetchMock).toHaveBeenCalledTimes(2);
expect(fetchCall(fetchMock, 0)[0]).toBe("/api/machines/remote%20a/sessions/bulk/archive"); expect(fetchCall(fetchMock, 0)[0]).toBe("api/machines/remote%20a/sessions/bulk/archive");
expect(fetchCall(fetchMock, 0)[1]?.method).toBe("POST"); expect(fetchCall(fetchMock, 0)[1]?.method).toBe("POST");
expect(JSON.parse(requestBody(fetchCall(fetchMock, 0)[1]))).toEqual({ sessions: [{ id: "s 1", cwd: "/repo" }, { id: "s 2" }] }); expect(JSON.parse(requestBody(fetchCall(fetchMock, 0)[1]))).toEqual({ sessions: [{ id: "s 1", cwd: "/repo" }, { id: "s 2" }] });
expect(fetchCall(fetchMock, 1)[0]).toBe("/api/machines/remote%20a/sessions/bulk/delete-archived"); expect(fetchCall(fetchMock, 1)[0]).toBe("api/machines/remote%20a/sessions/bulk/delete-archived");
expect(fetchCall(fetchMock, 1)[1]?.method).toBe("POST"); expect(fetchCall(fetchMock, 1)[1]?.method).toBe("POST");
expect(JSON.parse(requestBody(fetchCall(fetchMock, 1)[1]))).toEqual({ sessions: [{ id: "s 1", cwd: "/repo" }] }); expect(JSON.parse(requestBody(fetchCall(fetchMock, 1)[1]))).toEqual({ sessions: [{ id: "s 1", cwd: "/repo" }] });
}); });
@@ -228,7 +228,7 @@ describe("session API compatibility", () => {
expect(fetchMock).toHaveBeenCalledOnce(); expect(fetchMock).toHaveBeenCalledOnce();
const [url, init] = fetchCall(fetchMock, 0); const [url, init] = fetchCall(fetchMock, 0);
expect(url).toBe("/api/machines/remote%20a/sessions/s%201/prompt"); expect(url).toBe("api/machines/remote%20a/sessions/s%201/prompt");
expect(JSON.parse(requestBody(init))).toEqual({ text: "hello", streamingBehavior: "followUp" }); expect(JSON.parse(requestBody(init))).toEqual({ text: "hello", streamingBehavior: "followUp" });
}); });
@@ -239,7 +239,7 @@ describe("session API compatibility", () => {
expect(fetchMock).toHaveBeenCalledOnce(); expect(fetchMock).toHaveBeenCalledOnce();
const [url, init] = fetchCall(fetchMock, 0); const [url, init] = fetchCall(fetchMock, 0);
expect(url).toBe("/api/machines/remote%20a/sessions/s%201/prompt"); expect(url).toBe("api/machines/remote%20a/sessions/s%201/prompt");
expect(JSON.parse(requestBody(init))).toEqual({ cwd: "/repo", text: "hello" }); expect(JSON.parse(requestBody(init))).toEqual({ cwd: "/repo", text: "hello" });
}); });
}); });
@@ -251,7 +251,7 @@ describe("machine-scoped file suggestion API", () => {
await filesApi.files("/repo", "README", { projectId: "p 1", workspaceId: "w/1", scope: "tracked", machineId: "remote a", workspaceScoped: true }); await filesApi.files("/repo", "README", { projectId: "p 1", workspaceId: "w/1", scope: "tracked", machineId: "remote a", workspaceScoped: true });
expect(fetchMock).toHaveBeenCalledOnce(); expect(fetchMock).toHaveBeenCalledOnce();
expect(fetchCall(fetchMock, 0)[0]).toBe("/api/machines/remote%20a/projects/p%201/workspaces/w%2F1/files?q=README&scope=tracked"); expect(fetchCall(fetchMock, 0)[0]).toBe("api/machines/remote%20a/projects/p%201/workspaces/w%2F1/files?q=README&scope=tracked");
}); });
it("falls back to the legacy cwd route when workspace-scoped suggestions are not enabled", async () => { it("falls back to the legacy cwd route when workspace-scoped suggestions are not enabled", async () => {
@@ -260,7 +260,7 @@ describe("machine-scoped file suggestion API", () => {
await filesApi.files("/repo", "README", { projectId: "p 1", workspaceId: "w/1", scope: "tracked", machineId: "remote a" }); await filesApi.files("/repo", "README", { projectId: "p 1", workspaceId: "w/1", scope: "tracked", machineId: "remote a" });
expect(fetchMock).toHaveBeenCalledOnce(); expect(fetchMock).toHaveBeenCalledOnce();
expect(fetchCall(fetchMock, 0)[0]).toBe("/api/machines/remote%20a/files?q=README&scope=tracked&cwd=%2Frepo"); expect(fetchCall(fetchMock, 0)[0]).toBe("api/machines/remote%20a/files?q=README&scope=tracked&cwd=%2Frepo");
}); });
}); });
@@ -272,7 +272,7 @@ describe("machine-scoped terminal command-run API", () => {
expect(fetchMock).toHaveBeenCalledOnce(); expect(fetchMock).toHaveBeenCalledOnce();
const [url, init] = fetchCall(fetchMock, 0); const [url, init] = fetchCall(fetchMock, 0);
expect(url).toBe("/api/machines/remote%20a/projects/p%201/workspaces/w%2F1"); expect(url).toBe("api/machines/remote%20a/projects/p%201/workspaces/w%2F1");
expect(init?.method).toBe("DELETE"); expect(init?.method).toBe("DELETE");
}); });
@@ -283,7 +283,7 @@ describe("machine-scoped terminal command-run API", () => {
expect(fetchMock).toHaveBeenCalledOnce(); expect(fetchMock).toHaveBeenCalledOnce();
const [url, init] = fetchCall(fetchMock, 0); const [url, init] = fetchCall(fetchMock, 0);
expect(url).toBe("/api/machines/remote%20a/projects/p%201/workspaces/w%2F1/terminal-command-runs"); expect(url).toBe("api/machines/remote%20a/projects/p%201/workspaces/w%2F1/terminal-command-runs");
expect(init?.method).toBe("POST"); expect(init?.method).toBe("POST");
expect(JSON.parse(requestBody(init))).toEqual({ origin: "core", title: "Build", command: "npm test", metadata: {} }); expect(JSON.parse(requestBody(init))).toEqual({ origin: "core", title: "Build", command: "npm test", metadata: {} });
}); });
@@ -295,7 +295,7 @@ describe("machine-scoped terminal command-run API", () => {
expect(fetchMock).toHaveBeenCalledOnce(); expect(fetchMock).toHaveBeenCalledOnce();
const [url, init] = fetchCall(fetchMock, 0); const [url, init] = fetchCall(fetchMock, 0);
expect(url).toBe("/api/machines/remote%20a/projects/p%201/workspaces/w%2F1/terminals"); expect(url).toBe("api/machines/remote%20a/projects/p%201/workspaces/w%2F1/terminals");
expect(init?.method).toBe("DELETE"); expect(init?.method).toBe("DELETE");
}); });
@@ -311,9 +311,9 @@ describe("machine-scoped terminal command-run API", () => {
await terminalsApi.cancelCommandRun("run 1", "remote a"); await terminalsApi.cancelCommandRun("run 1", "remote a");
expect(fetchMock.mock.calls.map((call) => call[0])).toEqual([ expect(fetchMock.mock.calls.map((call) => call[0])).toEqual([
"/api/machines/remote%20a/terminal-command-runs?projectId=p+1&workspaceId=w%2F1&statuses=running&metadata=%7B%22pi.operation%22%3A%22workspace.delete%22%7D", "api/machines/remote%20a/terminal-command-runs?projectId=p+1&workspaceId=w%2F1&statuses=running&metadata=%7B%22pi.operation%22%3A%22workspace.delete%22%7D",
"/api/machines/remote%20a/terminal-command-runs/run%201", "api/machines/remote%20a/terminal-command-runs/run%201",
"/api/machines/remote%20a/terminal-command-runs/run%201/cancel", "api/machines/remote%20a/terminal-command-runs/run%201/cancel",
]); ]);
expect(fetchCall(fetchMock, 2)[1]?.method).toBe("POST"); expect(fetchCall(fetchMock, 2)[1]?.method).toBe("POST");
}); });
@@ -323,7 +323,7 @@ describe("machine-scoped terminal command-run API", () => {
await expect(terminalsApi.getCommandRun("missing", "remote-a")).resolves.toBeUndefined(); await expect(terminalsApi.getCommandRun("missing", "remote-a")).resolves.toBeUndefined();
expect(fetchCall(fetchMock, 0)[0]).toBe("/api/machines/remote-a/terminal-command-runs/missing"); expect(fetchCall(fetchMock, 0)[0]).toBe("api/machines/remote-a/terminal-command-runs/missing");
}); });
}); });
@@ -335,7 +335,7 @@ describe("workspace file write API", () => {
expect(fetchMock).toHaveBeenCalledOnce(); expect(fetchMock).toHaveBeenCalledOnce();
const [url, init] = fetchCall(fetchMock, 0); const [url, init] = fetchCall(fetchMock, 0);
expect(url).toBe("/api/machines/local/projects/p%201/workspaces/w%2F1/file?path=hello.txt"); expect(url).toBe("api/machines/local/projects/p%201/workspaces/w%2F1/file?path=hello.txt");
expect(init?.method).toBe("PUT"); expect(init?.method).toBe("PUT");
expect(new Headers(init?.headers).get("content-type")).toBe("text/plain"); expect(new Headers(init?.headers).get("content-type")).toBe("text/plain");
}); });
@@ -348,7 +348,7 @@ describe("workspace file write API", () => {
expect(fetchMock).toHaveBeenCalledOnce(); expect(fetchMock).toHaveBeenCalledOnce();
const [url, init] = fetchCall(fetchMock, 0); const [url, init] = fetchCall(fetchMock, 0);
expect(url).toBe("/api/machines/local/projects/p%201/workspaces/w%2F1/file?path=image.png"); expect(url).toBe("api/machines/local/projects/p%201/workspaces/w%2F1/file?path=image.png");
expect(init?.method).toBe("PUT"); expect(init?.method).toBe("PUT");
expect(new Headers(init?.headers).get("content-type")).toBe("application/octet-stream"); expect(new Headers(init?.headers).get("content-type")).toBe("application/octet-stream");
}); });
@@ -386,7 +386,7 @@ describe("workspace file write API", () => {
expect(fetchMock).toHaveBeenCalledOnce(); expect(fetchMock).toHaveBeenCalledOnce();
const [url] = fetchCall(fetchMock, 0); const [url] = fetchCall(fetchMock, 0);
expect(url).toContain("/api/machines/remote%20a/"); expect(url).toContain("api/machines/remote%20a/");
}); });
}); });
+11 -11
View File
@@ -51,7 +51,7 @@ import {
} from "./parsers"; } from "./parsers";
import { machineGitDiffUrl, messageUrl } from "./urls"; import { machineGitDiffUrl, messageUrl } from "./urls";
const machinePrefix = (machineId = "local") => `/api/machines/${encodeURIComponent(machineId)}`; const machinePrefix = (machineId = "local") => `api/machines/${encodeURIComponent(machineId)}`;
type SessionLookup = SessionRef | string; type SessionLookup = SessionRef | string;
@@ -100,29 +100,29 @@ function sessionBulkMutationRef(session: SessionLookup): SessionBulkMutationRef
} }
function piWebStatusUrl(machineId: string): string { function piWebStatusUrl(machineId: string): string {
return machineId === "local" ? "/api/pi-web/status" : `${machinePrefix(machineId)}/pi-web/status`; return machineId === "local" ? "api/pi-web/status" : `${machinePrefix(machineId)}/pi-web/status`;
} }
export const piWebApi = { export const piWebApi = {
piWebStatus: (machineId = "local") => request(piWebStatusUrl(machineId), parsePiWebStatusResponse), piWebStatus: (machineId = "local") => request(piWebStatusUrl(machineId), parsePiWebStatusResponse),
checkForUpdates: (machineId = "local") => request(`${piWebStatusUrl(machineId)}?refresh=1`, parsePiWebStatusResponse, { cache: "no-store" }), checkForUpdates: (machineId = "local") => request(`${piWebStatusUrl(machineId)}?refresh=1`, parsePiWebStatusResponse, { cache: "no-store" }),
piWebRuntime: () => request("/api/pi-web/runtime", parsePiWebRuntimeResponse), piWebRuntime: () => request("api/pi-web/runtime", parsePiWebRuntimeResponse),
}; };
export const machinesApi = { export const machinesApi = {
machines: () => request("/api/machines", parseMachinesResponse), machines: () => request("api/machines", parseMachinesResponse),
addMachine: (input: { name: string; baseUrl: string; token?: string }) => request("/api/machines", parseMachine, { method: "POST", body: JSON.stringify(input) }), addMachine: (input: { name: string; baseUrl: string; token?: string }) => request("api/machines", parseMachine, { method: "POST", body: JSON.stringify(input) }),
deleteMachine: (machineId: string) => request(`/api/machines/${encodeURIComponent(machineId)}`, (value) => value, { method: "DELETE" }), deleteMachine: (machineId: string) => request(`api/machines/${encodeURIComponent(machineId)}`, (value) => value, { method: "DELETE" }),
health: (machineId: string) => request(`/api/machines/${encodeURIComponent(machineId)}/health`, parseMachineHealth), health: (machineId: string) => request(`api/machines/${encodeURIComponent(machineId)}/health`, parseMachineHealth),
runtime: (machineId: string) => request(`/api/machines/${encodeURIComponent(machineId)}/runtime`, parseMachineRuntime), runtime: (machineId: string) => request(`api/machines/${encodeURIComponent(machineId)}/runtime`, parseMachineRuntime),
}; };
function configUrl(machineId?: string): string { function configUrl(machineId?: string): string {
return machineId === undefined ? "/api/config" : `${machinePrefix(machineId)}/config`; return machineId === undefined ? "api/config" : `${machinePrefix(machineId)}/config`;
} }
function pluginsUrl(machineId?: string): string { function pluginsUrl(machineId?: string): string {
return machineId === undefined ? "/api/plugins" : `${machinePrefix(machineId)}/plugins`; return machineId === undefined ? "api/plugins" : `${machinePrefix(machineId)}/plugins`;
} }
export const configApi = { export const configApi = {
@@ -135,7 +135,7 @@ export const pluginsApi = {
}; };
function piPackageUrl(endpoint = "", machineId?: string): string { function piPackageUrl(endpoint = "", machineId?: string): string {
const baseUrl = machineId === undefined ? "/api/pi-packages" : `${machinePrefix(machineId)}/pi-packages`; const baseUrl = machineId === undefined ? "api/pi-packages" : `${machinePrefix(machineId)}/pi-packages`;
return endpoint === "" ? baseUrl : `${baseUrl}/${endpoint}`; return endpoint === "" ? baseUrl : `${baseUrl}/${endpoint}`;
} }
@@ -145,7 +145,7 @@ function fetchCallToRoute(call: Parameters<FetchLike>, scopedMachineId: string):
function routeFromMachineUrl(method: string, input: string | URL | Request, scopedMachineId: string): ObservedHttpRoute { function routeFromMachineUrl(method: string, input: string | URL | Request, scopedMachineId: string): ObservedHttpRoute {
const url = toUrl(input); const url = toUrl(input);
const prefix = `/api/machines/${encodeURIComponent(scopedMachineId)}`; const prefix = `api/machines/${encodeURIComponent(scopedMachineId)}`;
if (!url.pathname.startsWith(prefix)) throw new Error(`Expected machine-scoped URL, got ${url.pathname}`); if (!url.pathname.startsWith(prefix)) throw new Error(`Expected machine-scoped URL, got ${url.pathname}`);
return { method, path: url.pathname.slice(prefix.length) || "/" }; return { method, path: url.pathname.slice(prefix.length) || "/" };
} }
+5 -5
View File
@@ -24,9 +24,9 @@ describe("machine-scoped socket urls", () => {
realtimeEvents(); realtimeEvents();
expect(webSocketUrls).toEqual([ expect(webSocketUrls).toEqual([
"wss://pi.example.test/api/machines/local/sessions/s1/events?cwd=%2Frepo", "api/machines/local/sessions/s1/events?cwd=%2Frepo",
"wss://pi.example.test/api/machines/local/sessions/events", "api/machines/local/sessions/events",
"wss://pi.example.test/api/machines/local/events", "api/machines/local/events",
]); ]);
}); });
@@ -34,7 +34,7 @@ describe("machine-scoped socket urls", () => {
sessionEvents("s1"); sessionEvents("s1");
expect(webSocketUrls).toEqual([ expect(webSocketUrls).toEqual([
"wss://pi.example.test/api/machines/local/sessions/s1/events", "api/machines/local/sessions/s1/events",
]); ]);
}); });
@@ -42,7 +42,7 @@ describe("machine-scoped socket urls", () => {
terminalSocket("p 1", "w/1", "t?1", { cols: 120, rows: 40 }, "remote-a"); terminalSocket("p 1", "w/1", "t?1", { cols: 120, rows: 40 }, "remote-a");
expect(webSocketUrls).toEqual([ expect(webSocketUrls).toEqual([
"wss://pi.example.test/api/machines/remote-a/projects/p%201/workspaces/w%2F1/terminals/t%3F1/socket?cols=120&rows=40", "api/machines/remote-a/projects/p%201/workspaces/w%2F1/terminals/t%3F1/socket?cols=120&rows=40",
]); ]);
}); });
}); });
+2 -3
View File
@@ -23,10 +23,9 @@ export function realtimeEvents(machineId = "local"): WebSocket {
} }
function machinePrefix(machineId: string): string { function machinePrefix(machineId: string): string {
return `/api/machines/${encodeURIComponent(machineId)}`; return `api/machines/${encodeURIComponent(machineId)}`;
} }
function webSocketBaseUrl(): string { function webSocketBaseUrl(): string {
const protocol = location.protocol === "https:" ? "wss:" : "ws:"; return "";
return `${protocol}//${location.host}`;
} }
+4 -4
View File
@@ -15,7 +15,7 @@ export function machineGitDiffUrl(machineId: string, projectId: string, workspac
if (options?.path !== undefined) params.set("path", options.path); if (options?.path !== undefined) params.set("path", options.path);
if (options?.staged === true) params.set("staged", "true"); if (options?.staged === true) params.set("staged", "true");
const query = params.toString(); const query = params.toString();
return `/api/machines/${encodeURIComponent(machineId)}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/git/diff${query ? `?${query}` : ""}`; return `api/machines/${encodeURIComponent(machineId)}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/git/diff${query ? `?${query}` : ""}`;
} }
export function messageUrl(session: SessionLookup, options?: { limit?: number; before?: number }, machineId = "local"): string { export function messageUrl(session: SessionLookup, options?: { limit?: number; before?: number }, machineId = "local"): string {
@@ -25,14 +25,14 @@ export function messageUrl(session: SessionLookup, options?: { limit?: number; b
if (options?.limit !== undefined) params.set("limit", String(options.limit)); if (options?.limit !== undefined) params.set("limit", String(options.limit));
if (options?.before !== undefined) params.set("before", String(options.before)); if (options?.before !== undefined) params.set("before", String(options.before));
const query = params.toString(); const query = params.toString();
return `/api/machines/${encodeURIComponent(machineId)}/sessions/${encodeURIComponent(sessionId(session))}/messages${query === "" ? "" : `?${query}`}`; return `api/machines/${encodeURIComponent(machineId)}/sessions/${encodeURIComponent(sessionId(session))}/messages${query === "" ? "" : `?${query}`}`;
} }
export function workspaceFileWriteUrl(projectId: string, workspaceId: string, path: string, options?: { createDirs?: boolean; overwrite?: boolean; machineId?: string }): string { export function workspaceFileWriteUrl(projectId: string, workspaceId: string, path: string, options?: { createDirs?: boolean; overwrite?: boolean; machineId?: string }): string {
const params = new URLSearchParams({ path }); const params = new URLSearchParams({ path });
if (options?.createDirs === false) params.set("createDirs", "false"); if (options?.createDirs === false) params.set("createDirs", "false");
if (options?.overwrite === false) params.set("overwrite", "false"); if (options?.overwrite === false) params.set("overwrite", "false");
const prefix = `/api/machines/${encodeURIComponent(options?.machineId ?? "local")}`; const prefix = `api/machines/${encodeURIComponent(options?.machineId ?? "local")}`;
return `${prefix}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/file?${params.toString()}`; return `${prefix}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/file?${params.toString()}`;
} }
@@ -40,6 +40,6 @@ export function workspaceImagePreviewUrl(projectId: string, workspaceId: string,
const params = new URLSearchParams(); const params = new URLSearchParams();
params.set("path", path); params.set("path", path);
if (options?.modifiedAt !== undefined) params.set("v", options.modifiedAt); if (options?.modifiedAt !== undefined) params.set("v", options.modifiedAt);
const prefix = `/api/machines/${encodeURIComponent(options?.machineId ?? "local")}`; const prefix = `api/machines/${encodeURIComponent(options?.machineId ?? "local")}`;
return `${prefix}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/file/preview?${params.toString()}`; return `${prefix}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/file/preview?${params.toString()}`;
} }
+4 -4
View File
@@ -40,7 +40,7 @@ describe("workspace upload helpers", () => {
const xhr = xhrs.only(); const xhr = xhrs.only();
expect(xhr.method).toBe("PUT"); expect(xhr.method).toBe("PUT");
expect(xhr.url).toBe("/api/machines/remote%20a/projects/p%201/workspaces/w%2F1/file?path=manual%2Fhello.txt&overwrite=false"); expect(xhr.url).toBe("api/machines/remote%20a/projects/p%201/workspaces/w%2F1/file?path=manual%2Fhello.txt&overwrite=false");
expect(xhr.headers.get("content-type")).toBe("text/plain"); expect(xhr.headers.get("content-type")).toBe("text/plain");
expect(xhr.body).toBe(file); expect(xhr.body).toBe(file);
@@ -78,13 +78,13 @@ describe("workspace upload helpers", () => {
}); });
const first = xhrs.at(0); const first = xhrs.at(0);
expect(first.url).toBe("/api/machines/remote%20a/projects/p%201/workspaces/w%2F1/file?path=uploads%2Fmanual%2Fa.txt"); expect(first.url).toBe("api/machines/remote%20a/projects/p%201/workspaces/w%2F1/file?path=uploads%2Fmanual%2Fa.txt");
first.emitUploadProgress(1, 2); first.emitUploadProgress(1, 2);
first.respondJson(200, { path: "uploads/manual/a.txt", size: 2, modifiedAt: "2026-06-25T00:00:00.000Z", created: true }); first.respondJson(200, { path: "uploads/manual/a.txt", size: 2, modifiedAt: "2026-06-25T00:00:00.000Z", created: true });
await Promise.resolve(); await Promise.resolve();
const second = xhrs.at(1); const second = xhrs.at(1);
expect(second.url).toBe("/api/machines/remote%20a/projects/p%201/workspaces/w%2F1/file?path=uploads%2Fmanual%2Fb.txt"); expect(second.url).toBe("api/machines/remote%20a/projects/p%201/workspaces/w%2F1/file?path=uploads%2Fmanual%2Fb.txt");
second.emitUploadProgress(3, 3); second.emitUploadProgress(3, 3);
second.respondJson(200, { path: "uploads/manual/b.txt", size: 3, modifiedAt: "2026-06-25T00:00:01.000Z", created: true }); second.respondJson(200, { path: "uploads/manual/b.txt", size: 3, modifiedAt: "2026-06-25T00:00:01.000Z", created: true });
@@ -111,7 +111,7 @@ describe("workspace upload helpers", () => {
}); });
const xhr = xhrs.only(); const xhr = xhrs.only();
expect(xhr.url).toBe("/api/machines/local/projects/p1/workspaces/w1/file?path=uploads%2Fnested.txt&createDirs=false"); expect(xhr.url).toBe("api/machines/local/projects/p1/workspaces/w1/file?path=uploads%2Fnested.txt&createDirs=false");
xhr.respondJson(200, { path: "uploads/nested.txt", size: 5, modifiedAt: "2026-06-25T00:00:00.000Z", created: true }); xhr.respondJson(200, { path: "uploads/nested.txt", size: 5, modifiedAt: "2026-06-25T00:00:00.000Z", created: true });
await expect(task.promise).resolves.toEqual([ await expect(task.promise).resolves.toEqual([
+1 -1
View File
@@ -1488,7 +1488,7 @@ export class PiWebApp extends LitElement {
const existing = this.machinePluginLoadPromises.get(machine.id); const existing = this.machinePluginLoadPromises.get(machine.id);
if (existing !== undefined) return existing; if (existing !== undefined) return existing;
const load = this.registerExternalPlugins(`PI WEB plugins from ${machine.name}`, () => loadExternalPlugins(`/api/machines/${encodeURIComponent(machine.id)}/pi-web-plugins/manifest.json`, { const load = this.registerExternalPlugins(`PI WEB plugins from ${machine.name}`, () => loadExternalPlugins(`api/machines/${encodeURIComponent(machine.id)}/pi-web-plugins/manifest.json`, {
machineId: machine.id, machineId: machine.id,
shouldLoadPlugin: (entry) => this.plugins.shouldLoadRemotePlugin(entry.id, entry.machineSpecific), shouldLoadPlugin: (entry) => this.plugins.shouldLoadRemotePlugin(entry.id, entry.machineSpecific),
})) }))
+1 -1
View File
@@ -16,7 +16,7 @@ export interface LoadExternalPluginsOptions {
shouldLoadPlugin?: (entry: PluginManifestEntry) => boolean; shouldLoadPlugin?: (entry: PluginManifestEntry) => boolean;
} }
export async function loadExternalPlugins(manifestUrl = "/pi-web-plugins/manifest.json", options: LoadExternalPluginsOptions = {}): Promise<PiWebPluginRegistration[]> { export async function loadExternalPlugins(manifestUrl = "pi-web-plugins/manifest.json", options: LoadExternalPluginsOptions = {}): Promise<PiWebPluginRegistration[]> {
const manifest = await fetchPluginManifest(manifestUrl); const manifest = await fetchPluginManifest(manifestUrl);
if (manifest === undefined) return []; if (manifest === undefined) return [];
@@ -86,7 +86,7 @@ function rewriteRemotePluginManifest(machineId: string, manifest: RemotePluginMa
if (modulePath === undefined) return []; if (modulePath === undefined) return [];
return [{ return [{
...plugin, ...plugin,
module: `/pi-web-plugins/${encodeURIComponent(machineScopedPluginId(machineId, plugin.id))}/${modulePath.path}${modulePath.query}`, module: `${piWebBasePath()}/pi-web-plugins/${encodeURIComponent(machineScopedPluginId(machineId, plugin.id))}/${modulePath.path}${modulePath.query}`,
}]; }];
}), }),
}; };
@@ -190,6 +190,11 @@ function sendGatewayError(reply: FastifyReply, machineId: string, error: unknown
}); });
} }
function piWebBasePath(): string {
const basePath = process.env["PI_WEB_BASE_PATH"] ?? "";
return basePath.replace(/\/$/u, "");
}
function isRecord(value: unknown): value is Record<string, unknown> { function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value); return typeof value === "object" && value !== null && !Array.isArray(value);
} }
+6 -1
View File
@@ -50,6 +50,11 @@ interface PiWebPluginServiceOptions {
configProvider?: () => PiWebConfig; configProvider?: () => PiWebConfig;
} }
function piWebBasePath(): string {
const basePath = process.env["PI_WEB_BASE_PATH"] ?? "";
return basePath.replace(/\/$/u, "");
}
interface LocalPluginRoot { interface LocalPluginRoot {
path: string; path: string;
source: string; source: string;
@@ -135,7 +140,7 @@ export class PiWebPluginService {
private pluginInfo(plugin: PluginRecord, config: PiWebConfig): PiWebPluginInfo { private pluginInfo(plugin: PluginRecord, config: PiWebConfig): PiWebPluginInfo {
return { return {
id: plugin.id, id: plugin.id,
module: `/pi-web-plugins/${encodeURIComponent(plugin.id)}/${plugin.entryFile}?${pluginModuleQuery(plugin)}`, module: `${piWebBasePath()}/pi-web-plugins/${encodeURIComponent(plugin.id)}/${plugin.entryFile}?${pluginModuleQuery(plugin)}`,
source: plugin.source, source: plugin.source,
scope: plugin.scope, scope: plugin.scope,
machineSpecific: plugin.machineSpecific, machineSpecific: plugin.machineSpecific,