From 27af5a70ad5f6a0f4e4a7280d3f66337123d1b5c Mon Sep 17 00:00:00 2001 From: chenyimeng Date: Sun, 12 Jul 2026 03:57:23 +0000 Subject: [PATCH 01/10] 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 c3b5bf34..02c972cc 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 c0cfefaa..a031ec6b 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 a851f597..19f87d18 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 56b9cbd2..60f44436 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 a709bd07..665d9a43 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 b5329240..c154d188 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 e55226f5..ffe15c7f 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 fee70a64..b6ff8cfc 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 534e7a08..792457c9 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 293c9093..d4e0b8a6 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 45938ea0..61b40dd7 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, From c661fad7e3e25b2ae0c8789db0ce255dae2c4af8 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Sun, 12 Jul 2026 22:56:37 +0200 Subject: [PATCH 02/10] fix(client): resolve URLs from application base --- src/client/src/api/clients.test.ts | 76 ++++++++++--------- src/client/src/api/clients.ts | 3 +- .../src/api/federatedRouteContract.test.ts | 14 ++-- src/client/src/api/http.ts | 4 +- src/client/src/api/sockets.test.ts | 12 +-- src/client/src/api/sockets.ts | 13 ++-- src/client/src/api/urls.ts | 9 ++- src/client/src/api/workspaceUploads.test.ts | 18 +++-- src/client/src/appUrl.test.ts | 40 ++++++++++ src/client/src/appUrl.ts | 33 ++++++++ .../sessionController.reloadSelection.test.ts | 1 + src/client/src/plugins/external.test.ts | 21 +++++ src/client/src/plugins/external.ts | 6 +- 13 files changed, 182 insertions(+), 68 deletions(-) create mode 100644 src/client/src/appUrl.test.ts create mode 100644 src/client/src/appUrl.ts create mode 100644 src/client/src/plugins/external.test.ts diff --git a/src/client/src/api/clients.test.ts b/src/client/src/api/clients.test.ts index 02c972cc..da00c18b 100644 --- a/src/client/src/api/clients.test.ts +++ b/src/client/src/api/clients.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { PI_WEB_CAPABILITIES } from "../../../shared/capabilities"; import type { PiWebConfigValues, TerminalCommandRun, Workspace } from "../../../shared/apiTypes"; import { configApi, filesApi, machinesApi, piPackagesApi, piWebApi, pluginsApi, sessionsApi, terminalsApi, workspacesApi } from "./clients"; @@ -40,6 +40,10 @@ const commandRun: TerminalCommandRun = { metadata: {}, }; +beforeEach(() => { + vi.stubGlobal("document", { baseURI: "https://pi.example.test/" }); +}); + afterEach(() => { vi.unstubAllGlobals(); }); @@ -51,7 +55,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("https://pi.example.test/api/machines/remote%20a/pi-web/status"); }); it("requests an uncached update check through the local status route", async () => { @@ -80,7 +84,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("https://pi.example.test/api/machines/remote%20a/runtime"); }); }); @@ -97,9 +101,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", + "https://pi.example.test/api/config", + "https://pi.example.test/api/config", + "https://pi.example.test/api/plugins", ]); expect(fetchCall(fetchMock, 1)[1]?.method).toBe("PUT"); expect(JSON.parse(requestBody(fetchCall(fetchMock, 1)[1]))).toEqual({ config: { spawnSessions: true } }); @@ -117,9 +121,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", + "https://pi.example.test/api/machines/remote%20a/config", + "https://pi.example.test/api/machines/remote%20a/config", + "https://pi.example.test/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 +148,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", + "https://pi.example.test/api/pi-packages", + "https://pi.example.test/api/pi-packages/install", + "https://pi.example.test/api/pi-packages/remove", + "https://pi.example.test/api/pi-packages/update", + "https://pi.example.test/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 +178,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", + "https://pi.example.test/api/machines/local/pi-packages", + "https://pi.example.test/api/machines/remote%20a/pi-packages", + "https://pi.example.test/api/machines/remote%20a/pi-packages/install", + "https://pi.example.test/api/machines/remote%20a/pi-packages/remove", + "https://pi.example.test/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 +200,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("https://pi.example.test/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("https://pi.example.test/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 +217,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("https://pi.example.test/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("https://pi.example.test/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 +232,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("https://pi.example.test/api/machines/remote%20a/sessions/s%201/prompt"); expect(JSON.parse(requestBody(init))).toEqual({ text: "hello", streamingBehavior: "followUp" }); }); @@ -239,7 +243,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("https://pi.example.test/api/machines/remote%20a/sessions/s%201/prompt"); expect(JSON.parse(requestBody(init))).toEqual({ cwd: "/repo", text: "hello" }); }); }); @@ -251,7 +255,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("https://pi.example.test/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 +264,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("https://pi.example.test/api/machines/remote%20a/files?q=README&scope=tracked&cwd=%2Frepo"); }); }); @@ -272,7 +276,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("https://pi.example.test/api/machines/remote%20a/projects/p%201/workspaces/w%2F1"); expect(init?.method).toBe("DELETE"); }); @@ -283,7 +287,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("https://pi.example.test/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 +299,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("https://pi.example.test/api/machines/remote%20a/projects/p%201/workspaces/w%2F1/terminals"); expect(init?.method).toBe("DELETE"); }); @@ -311,9 +315,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", + "https://pi.example.test/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", + "https://pi.example.test/api/machines/remote%20a/terminal-command-runs/run%201", + "https://pi.example.test/api/machines/remote%20a/terminal-command-runs/run%201/cancel", ]); expect(fetchCall(fetchMock, 2)[1]?.method).toBe("POST"); }); @@ -323,7 +327,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("https://pi.example.test/api/machines/remote-a/terminal-command-runs/missing"); }); }); @@ -335,7 +339,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("https://pi.example.test/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 +352,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("https://pi.example.test/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"); }); diff --git a/src/client/src/api/clients.ts b/src/client/src/api/clients.ts index a031ec6b..9b12e4fe 100644 --- a/src/client/src/api/clients.ts +++ b/src/client/src/api/clients.ts @@ -1,4 +1,5 @@ import type { DeleteWorkspaceFileResponse, FileSuggestion, MoveWorkspaceFileOptions, PiPackageInstallRequest, PiPackageRemoveRequest, PiPackageScope, PiPackageUpdateRequest, PiWebConfigValues, PromptAttachment, RunTerminalCommandInput, SessionBulkMutationRef, SessionCleanupRequest, SessionRef, TerminalCommandRun, TerminalCommandRunFilter, WriteWorkspaceFileOptions } from "../../../shared/apiTypes"; +import { resolveAppUrl } from "../appUrl"; import { request } from "./http"; import { arrayOf, @@ -256,7 +257,7 @@ export const terminalsApi = { }; async function getOptionalTerminalCommandRun(runId: string, machineId: string): Promise { - const response = await fetch(`${machinePrefix(machineId)}/terminal-command-runs/${encodeURIComponent(runId)}`); + const response = await fetch(resolveAppUrl(`${machinePrefix(machineId)}/terminal-command-runs/${encodeURIComponent(runId)}`)); if (response.status === 404) return undefined; if (!response.ok) { const body: unknown = await response.json().catch((): unknown => ({})); diff --git a/src/client/src/api/federatedRouteContract.test.ts b/src/client/src/api/federatedRouteContract.test.ts index 19f87d18..28a52573 100644 --- a/src/client/src/api/federatedRouteContract.test.ts +++ b/src/client/src/api/federatedRouteContract.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { Workspace } from "../../../shared/apiTypes"; import { FEDERATED_HTTP_ROUTES, FEDERATED_WEBSOCKET_ROUTES, type FederatedHttpRouteSpec } from "../../../shared/federatedRoutes"; import { activityApi, configApi, filesApi, gitApi, piPackagesApi, piWebApi, pluginsApi, projectsApi, sessionsApi, terminalsApi, workspacesApi } from "./clients"; @@ -17,6 +17,10 @@ const workspace: Workspace = { }; const session = { id: "s 1", cwd: workspace.path }; +beforeEach(() => { + vi.stubGlobal("document", { baseURI: "https://pi.example.test/" }); +}); + afterEach(() => { vi.unstubAllGlobals(); }); @@ -113,7 +117,6 @@ describe("federated route contract", () => { webSocketUrls.push(url); } vi.stubGlobal("WebSocket", FakeWebSocket); - vi.stubGlobal("location", { protocol: "https:", host: "pi.example.test" }); sessionEvents(session, machineId); globalSessionEvents(machineId); @@ -145,9 +148,10 @@ 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)}`; - if (!url.pathname.startsWith(prefix)) throw new Error(`Expected machine-scoped URL, got ${url.pathname}`); - return { method, path: url.pathname.slice(prefix.length) || "/" }; + const prefix = `/api/machines/${encodeURIComponent(scopedMachineId)}`; + const prefixIndex = url.pathname.lastIndexOf(prefix); + if (prefixIndex === -1) throw new Error(`Expected machine-scoped URL, got ${url.pathname}`); + return { method, path: url.pathname.slice(prefixIndex + prefix.length) || "/" }; } function toUrl(input: string | URL | Request): URL { diff --git a/src/client/src/api/http.ts b/src/client/src/api/http.ts index dd553ff5..c101bf33 100644 --- a/src/client/src/api/http.ts +++ b/src/client/src/api/http.ts @@ -1,7 +1,9 @@ +import { resolveAppUrl } from "../appUrl"; + export async function request(url: string, parse: (value: unknown) => T, init?: RequestInit): Promise { const headers = new Headers(init?.headers); if (init?.body !== undefined && !headers.has("content-type")) headers.set("content-type", "application/json"); - const response = await fetch(url, { ...init, headers }); + const response = await fetch(resolveAppUrl(url), { ...init, headers }); if (!response.ok) { const body: unknown = await response.json().catch((): unknown => ({})); throw new Error(errorMessage(body) ?? response.statusText); diff --git a/src/client/src/api/sockets.test.ts b/src/client/src/api/sockets.test.ts index 60f44436..37a7f2f4 100644 --- a/src/client/src/api/sockets.test.ts +++ b/src/client/src/api/sockets.test.ts @@ -10,7 +10,7 @@ function FakeWebSocket(url: string): void { beforeEach(() => { webSocketUrls.length = 0; vi.stubGlobal("WebSocket", FakeWebSocket); - vi.stubGlobal("location", { protocol: "https:", host: "pi.example.test" }); + vi.stubGlobal("document", { baseURI: "https://pi.example.test/" }); }); afterEach(() => { @@ -24,9 +24,9 @@ describe("machine-scoped socket urls", () => { realtimeEvents(); expect(webSocketUrls).toEqual([ - "api/machines/local/sessions/s1/events?cwd=%2Frepo", - "api/machines/local/sessions/events", - "api/machines/local/events", + "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", ]); }); @@ -34,7 +34,7 @@ describe("machine-scoped socket urls", () => { sessionEvents("s1"); expect(webSocketUrls).toEqual([ - "api/machines/local/sessions/s1/events", + "wss://pi.example.test/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([ - "api/machines/remote-a/projects/p%201/workspaces/w%2F1/terminals/t%3F1/socket?cols=120&rows=40", + "wss://pi.example.test/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 665d9a43..aa917e94 100644 --- a/src/client/src/api/sockets.ts +++ b/src/client/src/api/sockets.ts @@ -1,4 +1,5 @@ import type { SessionRef } from "../../../shared/apiTypes"; +import { resolveAppWebSocketUrl } from "../appUrl"; type SessionLookup = SessionRef | string; @@ -6,26 +7,22 @@ export function sessionEvents(session: SessionLookup, machineId = "local"): WebS const cwd = typeof session === "string" ? undefined : session.cwd; const query = cwd === undefined || cwd === "" ? "" : `?${new URLSearchParams({ cwd }).toString()}`; const sessionId = typeof session === "string" ? session : session.id; - return new WebSocket(`${webSocketBaseUrl()}${machinePrefix(machineId)}/sessions/${encodeURIComponent(sessionId)}/events${query}`); + return new WebSocket(resolveAppWebSocketUrl(`${machinePrefix(machineId)}/sessions/${encodeURIComponent(sessionId)}/events${query}`)); } export function globalSessionEvents(machineId = "local"): WebSocket { - return new WebSocket(`${webSocketBaseUrl()}${machinePrefix(machineId)}/sessions/events`); + return new WebSocket(resolveAppWebSocketUrl(`${machinePrefix(machineId)}/sessions/events`)); } export function terminalSocket(projectId: string, workspaceId: string, terminalId: string, initialSize?: { cols: number; rows: number }, machineId = "local"): WebSocket { const sizeQuery = initialSize === undefined ? "" : `?cols=${encodeURIComponent(String(initialSize.cols))}&rows=${encodeURIComponent(String(initialSize.rows))}`; - return new WebSocket(`${webSocketBaseUrl()}${machinePrefix(machineId)}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/terminals/${encodeURIComponent(terminalId)}/socket${sizeQuery}`); + return new WebSocket(resolveAppWebSocketUrl(`${machinePrefix(machineId)}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/terminals/${encodeURIComponent(terminalId)}/socket${sizeQuery}`)); } export function realtimeEvents(machineId = "local"): WebSocket { - return new WebSocket(`${webSocketBaseUrl()}${machinePrefix(machineId)}/events`); + return new WebSocket(resolveAppWebSocketUrl(`${machinePrefix(machineId)}/events`)); } function machinePrefix(machineId: string): string { return `api/machines/${encodeURIComponent(machineId)}`; } - -function webSocketBaseUrl(): string { - return ""; -} diff --git a/src/client/src/api/urls.ts b/src/client/src/api/urls.ts index c154d188..b84aa886 100644 --- a/src/client/src/api/urls.ts +++ b/src/client/src/api/urls.ts @@ -1,4 +1,5 @@ import type { SessionRef } from "../../../shared/apiTypes"; +import { resolveAppUrl } from "../appUrl"; type SessionLookup = SessionRef | string; @@ -15,7 +16,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 resolveAppUrl(`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,7 +26,7 @@ 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 resolveAppUrl(`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 { @@ -33,7 +34,7 @@ export function workspaceFileWriteUrl(projectId: string, workspaceId: string, pa if (options?.createDirs === false) params.set("createDirs", "false"); if (options?.overwrite === false) params.set("overwrite", "false"); const prefix = `api/machines/${encodeURIComponent(options?.machineId ?? "local")}`; - return `${prefix}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/file?${params.toString()}`; + return resolveAppUrl(`${prefix}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/file?${params.toString()}`); } export function workspaceImagePreviewUrl(projectId: string, workspaceId: string, path: string, options?: { modifiedAt?: string; machineId?: string }): string { @@ -41,5 +42,5 @@ export function workspaceImagePreviewUrl(projectId: string, workspaceId: string, params.set("path", path); if (options?.modifiedAt !== undefined) params.set("v", options.modifiedAt); const prefix = `api/machines/${encodeURIComponent(options?.machineId ?? "local")}`; - return `${prefix}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/file/preview?${params.toString()}`; + return resolveAppUrl(`${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 ffe15c7f..4eb5e5bd 100644 --- a/src/client/src/api/workspaceUploads.test.ts +++ b/src/client/src/api/workspaceUploads.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { effectiveWorkspaceUploadFolder, uploadWorkspaceFile, @@ -12,6 +12,14 @@ import { type WorkspaceUploadXhr, } from "./workspaceUploads"; +beforeEach(() => { + vi.stubGlobal("document", { baseURI: "https://pi.example.test/" }); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + describe("workspace upload helpers", () => { it("resolves effective upload defaults and workspace-relative paths", () => { expect(effectiveWorkspaceUploadFolder(undefined)).toBe(".pi-web/uploads"); @@ -40,7 +48,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("https://pi.example.test/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 +86,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("https://pi.example.test/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("https://pi.example.test/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 +119,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("https://pi.example.test/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/appUrl.test.ts b/src/client/src/appUrl.test.ts new file mode 100644 index 00000000..0136b100 --- /dev/null +++ b/src/client/src/appUrl.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import { resolveAppUrl, resolveAppWebSocketUrl, type AppUrlContext } from "./appUrl"; + +const rootHttpContext: AppUrlContext = { + viteBaseUrl: "/", + documentBaseUrl: "http://pi.example.test/", +}; + +const nestedHttpsContext: AppUrlContext = { + viteBaseUrl: "./", + documentBaseUrl: "https://pi.example.test/test/ai/", +}; + +describe("application URLs", () => { + it("resolves app-owned paths at an HTTP root deployment", () => { + expect(resolveAppUrl("api/pi-web/status", rootHttpContext)).toBe("http://pi.example.test/api/pi-web/status"); + expect(resolveAppUrl("/pi-web-plugins/manifest.json", rootHttpContext)).toBe("http://pi.example.test/pi-web-plugins/manifest.json"); + }); + + it("resolves paths within a canonical nested HTTPS deployment", () => { + expect(resolveAppUrl("api/pi-web/status", nestedHttpsContext)).toBe("https://pi.example.test/test/ai/api/pi-web/status"); + expect(resolveAppUrl("/pi-web-plugins/manifest.json", nestedHttpsContext)).toBe("https://pi.example.test/test/ai/pi-web-plugins/manifest.json"); + }); + + it("preserves encoded path segments and query parameters", () => { + expect(resolveAppUrl("api/machines/remote%20a/sessions/s%2F1/events?cwd=%2Frepo+one&before=10", nestedHttpsContext)) + .toBe("https://pi.example.test/test/ai/api/machines/remote%20a/sessions/s%2F1/events?cwd=%2Frepo+one&before=10"); + }); +}); + +describe("application WebSocket URLs", () => { + it("maps root HTTP URLs to absolute ws URLs", () => { + expect(resolveAppWebSocketUrl("api/machines/local/events", rootHttpContext)).toBe("ws://pi.example.test/api/machines/local/events"); + }); + + it("maps nested HTTPS URLs to absolute wss URLs without losing path or query data", () => { + expect(resolveAppWebSocketUrl("api/machines/remote%20a/sessions/s%2F1/events?cwd=%2Frepo+one", nestedHttpsContext)) + .toBe("wss://pi.example.test/test/ai/api/machines/remote%20a/sessions/s%2F1/events?cwd=%2Frepo+one"); + }); +}); diff --git a/src/client/src/appUrl.ts b/src/client/src/appUrl.ts new file mode 100644 index 00000000..72d6c365 --- /dev/null +++ b/src/client/src/appUrl.ts @@ -0,0 +1,33 @@ +export interface AppUrlContext { + viteBaseUrl: string; + documentBaseUrl: string; +} + +export function resolveAppUrl(path: string, context: AppUrlContext = browserAppUrlContext()): string { + const applicationBaseUrl = new URL(context.viteBaseUrl, context.documentBaseUrl); + return new URL(appRelativePath(path), applicationBaseUrl).toString(); +} + +export function resolveAppWebSocketUrl(path: string, context: AppUrlContext = browserAppUrlContext()): string { + const url = new URL(resolveAppUrl(path, context)); + if (url.protocol === "http:") { + url.protocol = "ws:"; + } else if (url.protocol === "https:") { + url.protocol = "wss:"; + } else { + throw new Error(`Cannot create a WebSocket URL from ${url.protocol}`); + } + return url.toString(); +} + +function browserAppUrlContext(): AppUrlContext { + return { + viteBaseUrl: import.meta.env.BASE_URL, + documentBaseUrl: document.baseURI, + }; +} + +function appRelativePath(path: string): string { + // A leading slash means the application root, not the origin root, so it must stay within nested deployments. + return path.startsWith("/") ? `.${path}` : path; +} diff --git a/src/client/src/controllers/sessionController.reloadSelection.test.ts b/src/client/src/controllers/sessionController.reloadSelection.test.ts index 62785cf0..3b985010 100644 --- a/src/client/src/controllers/sessionController.reloadSelection.test.ts +++ b/src/client/src/controllers/sessionController.reloadSelection.test.ts @@ -32,6 +32,7 @@ describe("SessionController reload and selection", () => { return Promise.resolve(freshPage); }, status: (session) => Promise.resolve(status(sessionLookupId(session))), + thinkingLevels: () => Promise.resolve({ levels: [] }), }; const controller = new SessionController( () => state, diff --git a/src/client/src/plugins/external.test.ts b/src/client/src/plugins/external.test.ts new file mode 100644 index 00000000..bd3c224c --- /dev/null +++ b/src/client/src/plugins/external.test.ts @@ -0,0 +1,21 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { loadExternalPlugins } from "./external"; + +beforeEach(() => { + vi.stubGlobal("document", { baseURI: "https://pi.example.test/" }); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("external plugin manifests", () => { + it("fetches the default manifest through the application base", async () => { + const fetchMock = vi.fn(() => Promise.resolve(new Response(null, { status: 404 }))); + vi.stubGlobal("fetch", fetchMock); + + await expect(loadExternalPlugins()).resolves.toEqual([]); + + expect(fetchMock).toHaveBeenCalledWith("https://pi.example.test/pi-web-plugins/manifest.json", { cache: "no-store" }); + }); +}); diff --git a/src/client/src/plugins/external.ts b/src/client/src/plugins/external.ts index 792457c9..9b598b26 100644 --- a/src/client/src/plugins/external.ts +++ b/src/client/src/plugins/external.ts @@ -1,4 +1,5 @@ import { machineScopedPluginId } from "../../../shared/machinePluginIds"; +import { resolveAppUrl } from "../appUrl"; import type { PiWebPlugin, PiWebPluginRegistration } from "./types"; export interface PluginManifestEntry { @@ -17,14 +18,15 @@ export interface LoadExternalPluginsOptions { } export async function loadExternalPlugins(manifestUrl = "pi-web-plugins/manifest.json", options: LoadExternalPluginsOptions = {}): Promise { - const manifest = await fetchPluginManifest(manifestUrl); + const resolvedManifestUrl = resolveAppUrl(manifestUrl); + const manifest = await fetchPluginManifest(resolvedManifestUrl); if (manifest === undefined) return []; const registrations: PiWebPluginRegistration[] = []; for (const entry of manifest.plugins) { if (options.shouldLoadPlugin?.(entry) === false) continue; try { - const moduleUrl = new URL(entry.module, new URL(manifestUrl, window.location.href)).toString(); + const moduleUrl = new URL(entry.module, resolvedManifestUrl).toString(); const module: unknown = await import(/* @vite-ignore */ moduleUrl); const plugin = parsePluginModule(module, moduleUrl); registrations.push({ From 4b2882bb5c53db2dbd798f9822a4cf3374724073 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Sun, 12 Jul 2026 23:02:08 +0200 Subject: [PATCH 03/10] fix(client): make build assets deployment relative --- src/client/index.html | 6 ++-- src/client/public/manifest.webmanifest | 8 ++--- src/clientBuildContents.test.ts | 45 ++++++++++++++++++++++++++ vite.config.ts | 1 + 4 files changed, 53 insertions(+), 7 deletions(-) create mode 100644 src/clientBuildContents.test.ts diff --git a/src/client/index.html b/src/client/index.html index 578d7db1..3b8cdad7 100644 --- a/src/client/index.html +++ b/src/client/index.html @@ -5,9 +5,9 @@ PI WEB - - - + + +