From 27af5a70ad5f6a0f4e4a7280d3f66337123d1b5c Mon Sep 17 00:00:00 2001 From: chenyimeng Date: Sun, 12 Jul 2026 03:57:23 +0000 Subject: [PATCH] feat: support deploying pi-web under an arbitrary base path Make all browser-facing API paths and WebSocket URLs relative so that vite's 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. --- src/client/src/api/clients.test.ts | 72 +++++++++---------- src/client/src/api/clients.ts | 22 +++--- .../src/api/federatedRouteContract.test.ts | 2 +- src/client/src/api/sockets.test.ts | 10 +-- src/client/src/api/sockets.ts | 5 +- src/client/src/api/urls.ts | 8 +-- src/client/src/api/workspaceUploads.test.ts | 8 +-- src/client/src/components/PiWebApp.ts | 2 +- src/client/src/plugins/external.ts | 2 +- .../machines/machinePluginProxyRoutes.ts | 7 +- src/server/piWebPluginService.ts | 7 +- 11 files changed, 77 insertions(+), 68 deletions(-) diff --git a/src/client/src/api/clients.test.ts b/src/client/src/api/clients.test.ts index c3b5bf3..02c972c 100644 --- a/src/client/src/api/clients.test.ts +++ b/src/client/src/api/clients.test.ts @@ -51,7 +51,7 @@ describe("machine-scoped runtime API", () => { await piWebApi.piWebStatus("remote a"); 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 () => { @@ -80,7 +80,7 @@ describe("machine-scoped runtime API", () => { await machinesApi.runtime("remote a"); 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()); expect(fetchMock.mock.calls.map((call) => call[0])).toEqual([ - "/api/config", - "/api/config", - "/api/plugins", + "api/config", + "api/config", + "api/plugins", ]); expect(fetchCall(fetchMock, 1)[1]?.method).toBe("PUT"); 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()); expect(fetchMock.mock.calls.map((call) => call[0])).toEqual([ - "/api/machines/remote%20a/config", - "/api/machines/remote%20a/config", - "/api/machines/remote%20a/plugins", + "api/machines/remote%20a/config", + "api/machines/remote%20a/config", + "api/machines/remote%20a/plugins", ]); expect(fetchCall(fetchMock, 1)[1]?.method).toBe("PUT"); expect(JSON.parse(requestBody(fetchCall(fetchMock, 1)[1]))).toEqual({ config: { spawnSessions: true } }); @@ -144,11 +144,11 @@ describe("Pi package API", () => { await piPackagesApi.update(); expect(fetchMock.mock.calls.map((call) => call[0])).toEqual([ - "/api/pi-packages", - "/api/pi-packages/install", - "/api/pi-packages/remove", - "/api/pi-packages/update", - "/api/pi-packages/update", + "api/pi-packages", + "api/pi-packages/install", + "api/pi-packages/remove", + "api/pi-packages/update", + "api/pi-packages/update", ]); expect(fetchCall(fetchMock, 1)[1]?.method).toBe("POST"); 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"); expect(fetchMock.mock.calls.map((call) => call[0])).toEqual([ - "/api/machines/local/pi-packages", - "/api/machines/remote%20a/pi-packages", - "/api/machines/remote%20a/pi-packages/install", - "/api/machines/remote%20a/pi-packages/remove", - "/api/machines/remote%20a/pi-packages/update", + "api/machines/local/pi-packages", + "api/machines/remote%20a/pi-packages", + "api/machines/remote%20a/pi-packages/install", + "api/machines/remote%20a/pi-packages/remove", + "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, 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); 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(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(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); 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(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(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(); 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" }); }); @@ -239,7 +239,7 @@ describe("session API compatibility", () => { expect(fetchMock).toHaveBeenCalledOnce(); 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" }); }); }); @@ -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 }); 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 () => { @@ -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" }); 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(); 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"); }); @@ -283,7 +283,7 @@ describe("machine-scoped terminal command-run API", () => { expect(fetchMock).toHaveBeenCalledOnce(); 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(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(); 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"); }); @@ -311,9 +311,9 @@ describe("machine-scoped terminal command-run API", () => { await terminalsApi.cancelCommandRun("run 1", "remote a"); 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/run%201", - "/api/machines/remote%20a/terminal-command-runs/run%201/cancel", + "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/cancel", ]); 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(); - 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(); 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(new Headers(init?.headers).get("content-type")).toBe("text/plain"); }); @@ -348,7 +348,7 @@ describe("workspace file write API", () => { expect(fetchMock).toHaveBeenCalledOnce(); 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(new Headers(init?.headers).get("content-type")).toBe("application/octet-stream"); }); @@ -386,7 +386,7 @@ describe("workspace file write API", () => { expect(fetchMock).toHaveBeenCalledOnce(); const [url] = fetchCall(fetchMock, 0); - expect(url).toContain("/api/machines/remote%20a/"); + expect(url).toContain("api/machines/remote%20a/"); }); }); diff --git a/src/client/src/api/clients.ts b/src/client/src/api/clients.ts index c0cfefa..a031ec6 100644 --- a/src/client/src/api/clients.ts +++ b/src/client/src/api/clients.ts @@ -51,7 +51,7 @@ import { } from "./parsers"; 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; @@ -100,29 +100,29 @@ function sessionBulkMutationRef(session: SessionLookup): SessionBulkMutationRef } 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 = { piWebStatus: (machineId = "local") => request(piWebStatusUrl(machineId), parsePiWebStatusResponse), 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 = { - machines: () => request("/api/machines", parseMachinesResponse), - 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" }), - health: (machineId: string) => request(`/api/machines/${encodeURIComponent(machineId)}/health`, parseMachineHealth), - runtime: (machineId: string) => request(`/api/machines/${encodeURIComponent(machineId)}/runtime`, parseMachineRuntime), + machines: () => request("api/machines", parseMachinesResponse), + 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" }), + health: (machineId: string) => request(`api/machines/${encodeURIComponent(machineId)}/health`, parseMachineHealth), + runtime: (machineId: string) => request(`api/machines/${encodeURIComponent(machineId)}/runtime`, parseMachineRuntime), }; 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 { - return machineId === undefined ? "/api/plugins" : `${machinePrefix(machineId)}/plugins`; + return machineId === undefined ? "api/plugins" : `${machinePrefix(machineId)}/plugins`; } export const configApi = { @@ -135,7 +135,7 @@ export const pluginsApi = { }; 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}`; } diff --git a/src/client/src/api/federatedRouteContract.test.ts b/src/client/src/api/federatedRouteContract.test.ts index a851f59..19f87d1 100644 --- a/src/client/src/api/federatedRouteContract.test.ts +++ b/src/client/src/api/federatedRouteContract.test.ts @@ -145,7 +145,7 @@ function fetchCallToRoute(call: Parameters, scopedMachineId: string): function routeFromMachineUrl(method: string, input: string | URL | Request, scopedMachineId: string): ObservedHttpRoute { 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}`); return { method, path: url.pathname.slice(prefix.length) || "/" }; } diff --git a/src/client/src/api/sockets.test.ts b/src/client/src/api/sockets.test.ts index 56b9cbd..60f4443 100644 --- a/src/client/src/api/sockets.test.ts +++ b/src/client/src/api/sockets.test.ts @@ -24,9 +24,9 @@ describe("machine-scoped socket urls", () => { realtimeEvents(); expect(webSocketUrls).toEqual([ - "wss://pi.example.test/api/machines/local/sessions/s1/events?cwd=%2Frepo", - "wss://pi.example.test/api/machines/local/sessions/events", - "wss://pi.example.test/api/machines/local/events", + "api/machines/local/sessions/s1/events?cwd=%2Frepo", + "api/machines/local/sessions/events", + "api/machines/local/events", ]); }); @@ -34,7 +34,7 @@ describe("machine-scoped socket urls", () => { sessionEvents("s1"); 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"); 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", ]); }); }); diff --git a/src/client/src/api/sockets.ts b/src/client/src/api/sockets.ts index a709bd0..665d9a4 100644 --- a/src/client/src/api/sockets.ts +++ b/src/client/src/api/sockets.ts @@ -23,10 +23,9 @@ export function realtimeEvents(machineId = "local"): WebSocket { } function machinePrefix(machineId: string): string { - return `/api/machines/${encodeURIComponent(machineId)}`; + return `api/machines/${encodeURIComponent(machineId)}`; } function webSocketBaseUrl(): string { - const protocol = location.protocol === "https:" ? "wss:" : "ws:"; - return `${protocol}//${location.host}`; + return ""; } diff --git a/src/client/src/api/urls.ts b/src/client/src/api/urls.ts index b532924..c154d18 100644 --- a/src/client/src/api/urls.ts +++ b/src/client/src/api/urls.ts @@ -15,7 +15,7 @@ export function machineGitDiffUrl(machineId: string, projectId: string, workspac if (options?.path !== undefined) params.set("path", options.path); if (options?.staged === true) params.set("staged", "true"); 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 { @@ -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?.before !== undefined) params.set("before", String(options.before)); 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 { const params = new URLSearchParams({ path }); if (options?.createDirs === false) params.set("createDirs", "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()}`; } @@ -40,6 +40,6 @@ export function workspaceImagePreviewUrl(projectId: string, workspaceId: string, const params = new URLSearchParams(); params.set("path", path); 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()}`; } diff --git a/src/client/src/api/workspaceUploads.test.ts b/src/client/src/api/workspaceUploads.test.ts index e55226f..ffe15c7 100644 --- a/src/client/src/api/workspaceUploads.test.ts +++ b/src/client/src/api/workspaceUploads.test.ts @@ -40,7 +40,7 @@ describe("workspace upload helpers", () => { const xhr = xhrs.only(); 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.body).toBe(file); @@ -78,13 +78,13 @@ describe("workspace upload helpers", () => { }); 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.respondJson(200, { path: "uploads/manual/a.txt", size: 2, modifiedAt: "2026-06-25T00:00:00.000Z", created: true }); await Promise.resolve(); 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.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(); - 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 }); await expect(task.promise).resolves.toEqual([ diff --git a/src/client/src/components/PiWebApp.ts b/src/client/src/components/PiWebApp.ts index fee70a6..b6ff8cf 100644 --- a/src/client/src/components/PiWebApp.ts +++ b/src/client/src/components/PiWebApp.ts @@ -1488,7 +1488,7 @@ export class PiWebApp extends LitElement { const existing = this.machinePluginLoadPromises.get(machine.id); 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, shouldLoadPlugin: (entry) => this.plugins.shouldLoadRemotePlugin(entry.id, entry.machineSpecific), })) diff --git a/src/client/src/plugins/external.ts b/src/client/src/plugins/external.ts index 534e7a0..792457c 100644 --- a/src/client/src/plugins/external.ts +++ b/src/client/src/plugins/external.ts @@ -16,7 +16,7 @@ export interface LoadExternalPluginsOptions { shouldLoadPlugin?: (entry: PluginManifestEntry) => boolean; } -export async function loadExternalPlugins(manifestUrl = "/pi-web-plugins/manifest.json", options: LoadExternalPluginsOptions = {}): Promise { +export async function loadExternalPlugins(manifestUrl = "pi-web-plugins/manifest.json", options: LoadExternalPluginsOptions = {}): Promise { const manifest = await fetchPluginManifest(manifestUrl); if (manifest === undefined) return []; diff --git a/src/server/machines/machinePluginProxyRoutes.ts b/src/server/machines/machinePluginProxyRoutes.ts index 293c909..d4e0b8a 100644 --- a/src/server/machines/machinePluginProxyRoutes.ts +++ b/src/server/machines/machinePluginProxyRoutes.ts @@ -86,7 +86,7 @@ function rewriteRemotePluginManifest(machineId: string, manifest: RemotePluginMa if (modulePath === undefined) return []; return [{ ...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 { return typeof value === "object" && value !== null && !Array.isArray(value); } diff --git a/src/server/piWebPluginService.ts b/src/server/piWebPluginService.ts index 45938ea..61b40dd 100644 --- a/src/server/piWebPluginService.ts +++ b/src/server/piWebPluginService.ts @@ -50,6 +50,11 @@ interface PiWebPluginServiceOptions { configProvider?: () => PiWebConfig; } +function piWebBasePath(): string { + const basePath = process.env["PI_WEB_BASE_PATH"] ?? ""; + return basePath.replace(/\/$/u, ""); +} + interface LocalPluginRoot { path: string; source: string; @@ -135,7 +140,7 @@ export class PiWebPluginService { private pluginInfo(plugin: PluginRecord, config: PiWebConfig): PiWebPluginInfo { return { 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, scope: plugin.scope, machineSpecific: plugin.machineSpecific,