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.
This commit is contained in:
@@ -1,21 +1,12 @@
|
|||||||
import createClient from "openapi-fetch";
|
import createClient from "openapi-fetch";
|
||||||
import { loadConfig } from "@/lib/config";
|
import { loadConfig } from "@/lib/config";
|
||||||
import { httpFetch } from "@/lib/http";
|
import { dispatchFor } from "@/lib/dispatch";
|
||||||
import type { paths } from "./schema.d.ts";
|
import type { paths } from "./schema.d.ts";
|
||||||
|
|
||||||
export function createHonchoClient() {
|
export function createHonchoClient() {
|
||||||
const config = loadConfig();
|
const config = loadConfig() ?? { baseUrl: "http://localhost:8000", token: "" };
|
||||||
const baseUrl = config?.baseUrl ?? "http://localhost:8000";
|
const { baseUrl, headers, fetch } = dispatchFor(config);
|
||||||
const token = config?.token ?? "";
|
return createClient<paths>({ baseUrl, headers, fetch });
|
||||||
|
|
||||||
const headers: Record<string, string> = {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
};
|
|
||||||
if (token) {
|
|
||||||
headers.Authorization = `Bearer ${token}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return createClient<paths>({ baseUrl, headers, fetch: httpFetch });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const client = {
|
export const client = {
|
||||||
|
|||||||
@@ -1,18 +1,16 @@
|
|||||||
import createClient from "openapi-fetch";
|
import createClient from "openapi-fetch";
|
||||||
import type { Instance } from "@/lib/config";
|
import type { Instance } from "@/lib/config";
|
||||||
import { httpFetch } from "@/lib/http";
|
import { dispatchFor } from "@/lib/dispatch";
|
||||||
import type { paths } from "./schema.d.ts";
|
import type { paths } from "./schema.d.ts";
|
||||||
|
|
||||||
export type ScopedClient = ReturnType<typeof createClient<paths>>;
|
export type ScopedClient = ReturnType<typeof createClient<paths>>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create an openapi-fetch client bound to a specific instance. Use for views
|
* Create an openapi-fetch client bound to a specific instance. Use for views that
|
||||||
* that need to query non-active instances (e.g. side-by-side comparison).
|
* query non-active instances (e.g. the Fleet side-by-side comparison). Each scoped
|
||||||
* For single-instance access, prefer `client.current` which tracks the active
|
* client self-routes via its own X-Honcho-Upstream header in web mode.
|
||||||
* instance via localStorage.
|
|
||||||
*/
|
*/
|
||||||
export function createScopedClient(instance: Instance): ScopedClient {
|
export function createScopedClient(instance: Instance): ScopedClient {
|
||||||
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
const { baseUrl, headers, fetch } = dispatchFor(instance);
|
||||||
if (instance.token) headers.Authorization = `Bearer ${instance.token}`;
|
return createClient<paths>({ baseUrl, headers, fetch });
|
||||||
return createClient<paths>({ baseUrl: instance.baseUrl, headers, fetch: httpFetch });
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,17 @@ function normalizeUpstream(url: string): string {
|
|||||||
return url.trim().replace(/\/+$/, "");
|
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.
|
* Resolve how to issue a request for an instance.
|
||||||
* - Web: same-origin `/api` + `X-Honcho-Upstream` header (proxy forwards server-side, no CORS).
|
* - 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);
|
headers[UPSTREAM_HEADER] = normalizeUpstream(instance.baseUrl);
|
||||||
return { baseUrl: API_PREFIX, headers, fetch: httpFetch };
|
return { baseUrl: webApiBase(), headers, fetch: httpFetch };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,10 +8,10 @@ import { API_PREFIX, dispatchFor, PROXY_REJECT_HEADER, UPSTREAM_HEADER } from "@
|
|||||||
afterEach(() => mockIsTauri.mockReset());
|
afterEach(() => mockIsTauri.mockReset());
|
||||||
|
|
||||||
describe("dispatchFor — web mode", () => {
|
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);
|
mockIsTauri.mockReturnValue(false);
|
||||||
const d = dispatchFor({ baseUrl: "https://honcho.example.net/", token: "" });
|
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[UPSTREAM_HEADER]).toBe("https://honcho.example.net");
|
||||||
expect(d.headers.Authorization).toBeUndefined();
|
expect(d.headers.Authorization).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -103,23 +103,25 @@ describe("scoped option builders", () => {
|
|||||||
(httpFetch as ReturnType<typeof vi.fn>).mockClear();
|
(httpFetch as ReturnType<typeof vi.fn>).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 { httpFetch } = await import("@/lib/http");
|
||||||
const opts = scopedQueueStatusOptions(neo, "ws-1");
|
const opts = scopedQueueStatusOptions(neo, "ws-1");
|
||||||
await opts.queryFn();
|
await opts.queryFn();
|
||||||
const req = (httpFetch as ReturnType<typeof vi.fn>).mock.calls[0][0] as Request;
|
const req = (httpFetch as ReturnType<typeof vi.fn>).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");
|
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 { httpFetch } = await import("@/lib/http");
|
||||||
const opts = scopedConclusionsCountOptions(iris, "ws-9");
|
const opts = scopedConclusionsCountOptions(iris, "ws-9");
|
||||||
await opts.queryFn();
|
await opts.queryFn();
|
||||||
const req = (httpFetch as ReturnType<typeof vi.fn>).mock.calls[0][0] as Request;
|
const req = (httpFetch as ReturnType<typeof vi.fn>).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,
|
true,
|
||||||
);
|
);
|
||||||
|
expect(req.headers.get("X-Honcho-Upstream")).toBe("http://localhost:8002");
|
||||||
expect(req.headers.get("Authorization")).toBe("Bearer iris-token");
|
expect(req.headers.get("Authorization")).toBe("Bearer iris-token");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user