From 0935099bc28468a21183f5f03105645f4ac8aa8a Mon Sep 17 00:00:00 2001 From: Offending Commit Date: Tue, 2 Jun 2026 11:47:24 -0500 Subject: [PATCH] feat(web): route web build through same-origin /api proxy client.current and createScopedClient resolve transport via dispatchFor: web -> absolute origin + /api base with an X-Honcho-Upstream header; Tauri -> absolute instance URL + reqwest. Absolute base (not bare "/api") so openapi-fetch can construct a Request under node/undici and in the browser alike. Fleet fan-out is unchanged; fleet.test.tsx now asserts the proxy contract. --- packages/web/src/api/client.ts | 17 ++++------------- packages/web/src/api/scopedClient.ts | 14 ++++++-------- packages/web/src/lib/dispatch.ts | 13 ++++++++++++- packages/web/src/test/dispatch.test.ts | 4 ++-- packages/web/src/test/fleet.test.tsx | 10 ++++++---- 5 files changed, 30 insertions(+), 28 deletions(-) diff --git a/packages/web/src/api/client.ts b/packages/web/src/api/client.ts index 7652996..514b426 100644 --- a/packages/web/src/api/client.ts +++ b/packages/web/src/api/client.ts @@ -1,21 +1,12 @@ import createClient from "openapi-fetch"; import { loadConfig } from "@/lib/config"; -import { httpFetch } from "@/lib/http"; +import { dispatchFor } from "@/lib/dispatch"; import type { paths } from "./schema.d.ts"; export function createHonchoClient() { - const config = loadConfig(); - const baseUrl = config?.baseUrl ?? "http://localhost:8000"; - const token = config?.token ?? ""; - - const headers: Record = { - "Content-Type": "application/json", - }; - if (token) { - headers.Authorization = `Bearer ${token}`; - } - - return createClient({ baseUrl, headers, fetch: httpFetch }); + const config = loadConfig() ?? { baseUrl: "http://localhost:8000", token: "" }; + const { baseUrl, headers, fetch } = dispatchFor(config); + return createClient({ baseUrl, headers, fetch }); } export const client = { diff --git a/packages/web/src/api/scopedClient.ts b/packages/web/src/api/scopedClient.ts index c37a9af..44c8764 100644 --- a/packages/web/src/api/scopedClient.ts +++ b/packages/web/src/api/scopedClient.ts @@ -1,18 +1,16 @@ import createClient from "openapi-fetch"; import type { Instance } from "@/lib/config"; -import { httpFetch } from "@/lib/http"; +import { dispatchFor } from "@/lib/dispatch"; import type { paths } from "./schema.d.ts"; export type ScopedClient = ReturnType>; /** - * Create an openapi-fetch client bound to a specific instance. Use for views - * that need to query non-active instances (e.g. side-by-side comparison). - * For single-instance access, prefer `client.current` which tracks the active - * instance via localStorage. + * Create an openapi-fetch client bound to a specific instance. Use for views that + * query non-active instances (e.g. the Fleet side-by-side comparison). Each scoped + * client self-routes via its own X-Honcho-Upstream header in web mode. */ export function createScopedClient(instance: Instance): ScopedClient { - const headers: Record = { "Content-Type": "application/json" }; - if (instance.token) headers.Authorization = `Bearer ${instance.token}`; - return createClient({ baseUrl: instance.baseUrl, headers, fetch: httpFetch }); + const { baseUrl, headers, fetch } = dispatchFor(instance); + return createClient({ baseUrl, headers, fetch }); } diff --git a/packages/web/src/lib/dispatch.ts b/packages/web/src/lib/dispatch.ts index 02f287b..3701e18 100644 --- a/packages/web/src/lib/dispatch.ts +++ b/packages/web/src/lib/dispatch.ts @@ -18,6 +18,17 @@ function normalizeUpstream(url: string): string { return url.trim().replace(/\/+$/, ""); } +/** + * Absolute same-origin base for the web proxy. Absolute (origin + `/api`), not the + * bare relative `/api`, so openapi-fetch and node/undici can construct a Request + * without an ambient document base — and so it resolves identically in the browser, + * behind a tunnel, and under jsdom. + */ +function webApiBase(): string { + const origin = typeof location !== "undefined" ? location.origin : ""; + return `${origin}${API_PREFIX}`; +} + /** * Resolve how to issue a request for an instance. * - Web: same-origin `/api` + `X-Honcho-Upstream` header (proxy forwards server-side, no CORS). @@ -32,5 +43,5 @@ export function dispatchFor(instance: { baseUrl: string; token?: string }): Disp } headers[UPSTREAM_HEADER] = normalizeUpstream(instance.baseUrl); - return { baseUrl: API_PREFIX, headers, fetch: httpFetch }; + return { baseUrl: webApiBase(), headers, fetch: httpFetch }; } diff --git a/packages/web/src/test/dispatch.test.ts b/packages/web/src/test/dispatch.test.ts index 0d66fb8..1fbfdc7 100644 --- a/packages/web/src/test/dispatch.test.ts +++ b/packages/web/src/test/dispatch.test.ts @@ -8,10 +8,10 @@ import { API_PREFIX, dispatchFor, PROXY_REJECT_HEADER, UPSTREAM_HEADER } from "@ afterEach(() => mockIsTauri.mockReset()); describe("dispatchFor — web mode", () => { - it("targets the /api prefix and carries the upstream header", () => { + it("targets the absolute same-origin /api base and carries the upstream header", () => { mockIsTauri.mockReturnValue(false); const d = dispatchFor({ baseUrl: "https://honcho.example.net/", token: "" }); - expect(d.baseUrl).toBe(API_PREFIX); + expect(d.baseUrl).toBe(`${location.origin}${API_PREFIX}`); expect(d.headers[UPSTREAM_HEADER]).toBe("https://honcho.example.net"); expect(d.headers.Authorization).toBeUndefined(); }); diff --git a/packages/web/src/test/fleet.test.tsx b/packages/web/src/test/fleet.test.tsx index 56c50e9..7ed13ac 100644 --- a/packages/web/src/test/fleet.test.tsx +++ b/packages/web/src/test/fleet.test.tsx @@ -103,23 +103,25 @@ describe("scoped option builders", () => { (httpFetch as ReturnType).mockClear(); }); - it("scopes queue status requests by instance baseUrl and token", async () => { + it("scopes queue status requests via the same-origin proxy, upstream header, and token", async () => { const { httpFetch } = await import("@/lib/http"); const opts = scopedQueueStatusOptions(neo, "ws-1"); await opts.queryFn(); const req = (httpFetch as ReturnType).mock.calls[0][0] as Request; - expect(req.url).toBe("http://localhost:8001/v3/workspaces/ws-1/queue/status"); + expect(req.url).toBe(`${location.origin}/api/v3/workspaces/ws-1/queue/status`); + expect(req.headers.get("X-Honcho-Upstream")).toBe("http://localhost:8001"); expect(req.headers.get("Authorization")).toBe("Bearer neo-token"); }); - it("scopes conclusions-count requests by instance baseUrl and token", async () => { + it("scopes conclusions-count requests via the same-origin proxy, upstream header, and token", async () => { const { httpFetch } = await import("@/lib/http"); const opts = scopedConclusionsCountOptions(iris, "ws-9"); await opts.queryFn(); const req = (httpFetch as ReturnType).mock.calls[0][0] as Request; - expect(req.url.startsWith("http://localhost:8002/v3/workspaces/ws-9/conclusions/list")).toBe( + expect(req.url.startsWith(`${location.origin}/api/v3/workspaces/ws-9/conclusions/list`)).toBe( true, ); + expect(req.headers.get("X-Honcho-Upstream")).toBe("http://localhost:8002"); expect(req.headers.get("Authorization")).toBe("Bearer iris-token"); });