fix(client): resolve URLs from application base

This commit is contained in:
Federico Jaramillo Martinez
2026-07-12 23:27:43 +02:00
parent 27af5a70ad
commit c661fad7e3
13 changed files with 182 additions and 68 deletions
+40 -36
View File
@@ -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");
});
+2 -1
View File
@@ -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<TerminalCommandRun | undefined> {
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 => ({}));
@@ -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<FetchLike>, 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 {
+3 -1
View File
@@ -1,7 +1,9 @@
import { resolveAppUrl } from "../appUrl";
export async function request<T>(url: string, parse: (value: unknown) => T, init?: RequestInit): Promise<T> {
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);
+6 -6
View File
@@ -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",
]);
});
});
+5 -8
View File
@@ -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 "";
}
+5 -4
View File
@@ -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()}`);
}
+13 -5
View File
@@ -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([
+40
View File
@@ -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");
});
});
+33
View File
@@ -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;
}
@@ -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,
+21
View File
@@ -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" });
});
});
+4 -2
View File
@@ -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<PiWebPluginRegistration[]> {
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({