From 9945e4cf148aec6fc47bb853e8661c339c52ff32 Mon Sep 17 00:00:00 2001 From: Offending Commit Date: Tue, 2 Jun 2026 11:38:58 -0500 Subject: [PATCH] feat(web): add dispatchFor transport helper for same-origin proxy --- packages/web/src/lib/dispatch.ts | 36 +++++++++++++++++++++++ packages/web/src/test/dispatch.test.ts | 40 ++++++++++++++++++++++++++ 2 files changed, 76 insertions(+) create mode 100644 packages/web/src/lib/dispatch.ts create mode 100644 packages/web/src/test/dispatch.test.ts diff --git a/packages/web/src/lib/dispatch.ts b/packages/web/src/lib/dispatch.ts new file mode 100644 index 0000000..02f287b --- /dev/null +++ b/packages/web/src/lib/dispatch.ts @@ -0,0 +1,36 @@ +import { httpFetch } from "@/lib/http"; +import { isTauri } from "@/lib/platform"; + +/** Same-origin path prefix the web build issues all Honcho calls through. */ +export const API_PREFIX = "/api"; +/** Request header naming the real Honcho upstream for the proxy to forward to. */ +export const UPSTREAM_HEADER = "X-Honcho-Upstream"; +/** Response header the proxy sets on its OWN refusals (so they aren't read as upstream auth). */ +export const PROXY_REJECT_HEADER = "X-Honcho-Proxy-Reject"; + +export interface Dispatch { + baseUrl: string; + headers: Record; + fetch: typeof globalThis.fetch; +} + +function normalizeUpstream(url: string): string { + return url.trim().replace(/\/+$/, ""); +} + +/** + * Resolve how to issue a request for an instance. + * - Web: same-origin `/api` + `X-Honcho-Upstream` header (proxy forwards server-side, no CORS). + * - Tauri: the absolute instance URL via reqwest (no browser same-origin policy). + */ +export function dispatchFor(instance: { baseUrl: string; token?: string }): Dispatch { + const headers: Record = { "Content-Type": "application/json" }; + if (instance.token) headers.Authorization = `Bearer ${instance.token}`; + + if (isTauri()) { + return { baseUrl: instance.baseUrl, headers, fetch: httpFetch }; + } + + headers[UPSTREAM_HEADER] = normalizeUpstream(instance.baseUrl); + return { baseUrl: API_PREFIX, headers, fetch: httpFetch }; +} diff --git a/packages/web/src/test/dispatch.test.ts b/packages/web/src/test/dispatch.test.ts new file mode 100644 index 0000000..0d66fb8 --- /dev/null +++ b/packages/web/src/test/dispatch.test.ts @@ -0,0 +1,40 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +const { mockIsTauri } = vi.hoisted(() => ({ mockIsTauri: vi.fn() })); +vi.mock("@/lib/platform", () => ({ isTauri: () => mockIsTauri() })); + +import { API_PREFIX, dispatchFor, PROXY_REJECT_HEADER, UPSTREAM_HEADER } from "@/lib/dispatch"; + +afterEach(() => mockIsTauri.mockReset()); + +describe("dispatchFor — web mode", () => { + it("targets the /api prefix 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.headers[UPSTREAM_HEADER]).toBe("https://honcho.example.net"); + expect(d.headers.Authorization).toBeUndefined(); + }); + + it("adds Authorization only when a token is present", () => { + mockIsTauri.mockReturnValue(false); + const d = dispatchFor({ baseUrl: "https://honcho.example.net", token: "sk-1" }); + expect(d.headers.Authorization).toBe("Bearer sk-1"); + }); +}); + +describe("dispatchFor — tauri mode", () => { + it("targets the absolute URL with no upstream header", () => { + mockIsTauri.mockReturnValue(true); + const d = dispatchFor({ baseUrl: "https://honcho.example.net", token: "sk-1" }); + expect(d.baseUrl).toBe("https://honcho.example.net"); + expect(d.headers[UPSTREAM_HEADER]).toBeUndefined(); + expect(d.headers.Authorization).toBe("Bearer sk-1"); + }); +}); + +describe("proxy reject header constant", () => { + it("is the agreed sentinel name", () => { + expect(PROXY_REJECT_HEADER).toBe("X-Honcho-Proxy-Reject"); + }); +});