refactor(ui): inject the fetch client's base url instead of reading it at import

api.ts read globalThis.location when the module loaded, which froze the base
URL at import and pinned its test file to jsdom. The creation-time baseUrl and
the middleware's runtime rebase were also two mechanisms doing overlapping
work, and the rebase hand-copied eleven RequestInit fields on every call.

Pass openapi-fetch's Request option instead, so the constructor applies
whatever getRequestBaseUrl() returns at the moment the request is built.
registerBaseUrlGetter is now the single source of the base URL, rebaseUrl and
rebaseRequest are deleted, and the request is constructed once, so the init
openapi-fetch assembled reaches the platform Request untouched. The abort
signal is no longer copied by hand.

This preserves behaviour rather than approximating it: getProxyBaseUrl() falls
back to location.origin, so the runtime base was never empty in a browser and
the old middleware already rebased every request, discarding the creation-time
value each time.

setupTests.ts gates its DOM-only tail behind a window check; setup files run
for every environment, so that tail previously stopped any node-environment
test file from loading.

api.test.ts now runs under @vitest-environment node with its assertions intact
and no location stub, plus regressions for per-call base resolution and abort
forwarding. api.sameOrigin.test.ts covers the browser fallback to the page
origin, which needs a DOM environment.
This commit is contained in:
Yuneng Jiang 2026-08-04 12:07:45 -07:00
parent cbeaf86c8d
commit a6b9cedd03
No known key found for this signature in database
4 changed files with 171 additions and 104 deletions

View file

@ -0,0 +1,46 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { fetchClient } from "./api";
import { registerAuthTokenGetter, registerBaseUrlGetter, registerErrorHandler } from "./runtime";
const jsonResponse = (status: number, body: unknown): Response =>
new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } });
const capturingFetch = (response: Response) => {
const requests: Request[] = [];
const fetch = vi.fn(async (request: Request) => {
requests.push(request);
return response;
});
return { fetch, requests };
};
describe("typed api client on a same-origin deployment", () => {
beforeEach(() => {
registerAuthTokenGetter(() => null);
registerErrorHandler(() => {});
});
afterEach(() => {
vi.restoreAllMocks();
});
it("sends requests to the page origin when no base url is registered", async () => {
registerBaseUrlGetter(() => "");
const { fetch, requests } = capturingFetch(jsonResponse(200, { data: [] }));
await fetchClient.GET("/model_group/info", { fetch });
expect(requests[0].url).toBe(`${window.location.origin}/model_group/info`);
});
it("prefers a registered cross-origin base over the page origin", async () => {
registerBaseUrlGetter(() => "https://proxy.example.com");
const { fetch, requests } = capturingFetch(jsonResponse(200, { data: [] }));
await fetchClient.GET("/model_group/info", { fetch });
expect(requests[0].url).toBe("https://proxy.example.com/model_group/info");
expect(new URL(requests[0].url).origin).not.toBe(window.location.origin);
});
});

View file

@ -1,3 +1,4 @@
// @vitest-environment node
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { fetchClient } from "./api";
import {
@ -36,7 +37,7 @@ const spyOnRequestConstruction = () => {
describe("typed api client middleware", () => {
beforeEach(() => {
registerBaseUrlGetter(() => "");
registerBaseUrlGetter(() => "http://localhost:4000");
registerAuthHeaderNameGetter(() => "Authorization");
registerErrorHandler(() => {});
registerAuthTokenGetter(() => null);
@ -66,7 +67,7 @@ describe("typed api client middleware", () => {
expect(requests[0].headers.get("Authorization")).toBeNull();
});
it("rebases the request onto the registered base url, preserving path and query", async () => {
it("builds the request url from the registered base url, preserving path and query", async () => {
registerBaseUrlGetter(() => "https://proxy.example.com/");
const { fetch, requests } = capturingFetch(jsonResponse(200, { data: [] }));
@ -90,7 +91,7 @@ describe("typed api client middleware", () => {
expect(await requests[0].text()).toBe(JSON.stringify({ key_alias: "my-key" }));
});
it("keeps the POST body as bytes when rebasing onto a runtime base url", async () => {
it("keeps the POST body as bytes when a different runtime base url is registered", async () => {
registerBaseUrlGetter(() => "https://proxy.example.com");
registerAuthTokenGetter(() => "sk-test");
const { streamBodiedInits } = spyOnRequestConstruction();
@ -107,6 +108,43 @@ describe("typed api client middleware", () => {
expect(await sent.text()).toBe(JSON.stringify({ key_alias: "my-key" }));
});
it("reads the base url on every call, so a base registered after import still takes effect", async () => {
const requests: Request[] = [];
const fetch = vi.fn(async (request: Request) => {
requests.push(request);
return jsonResponse(200, { data: [] });
});
registerBaseUrlGetter(() => "https://first.example.com");
await fetchClient.GET("/model_group/info", { fetch });
registerBaseUrlGetter(() => "https://second.example.com");
await fetchClient.GET("/model_group/info", { fetch });
expect(requests.map((request) => new URL(request.url).origin)).toEqual([
"https://first.example.com",
"https://second.example.com",
]);
});
it("forwards the caller's abort signal so an in-flight request can be cancelled", async () => {
const controller = new AbortController();
const seen: Request[] = [];
const fetch = vi.fn(
(request: Request) =>
new Promise<Response>((_resolve, reject) => {
seen.push(request);
request.signal.addEventListener("abort", () => reject(new DOMException("Aborted", "AbortError")));
}),
);
const pending = fetchClient.GET("/model_group/info", { fetch, signal: controller.signal });
await vi.waitFor(() => expect(seen).toHaveLength(1));
controller.abort();
await expect(pending).rejects.toMatchObject({ name: "AbortError" });
expect(seen[0].signal.aborted).toBe(true);
}, 5000);
it("maps a non-2xx response to an ApiError carrying status and the derived message", async () => {
const { fetch } = capturingFetch(jsonResponse(403, { error: { message: "no access" } }));

View file

@ -4,38 +4,18 @@ import type { paths } from "./schema";
import { ApiError, deriveErrorMessage } from "./client";
import { getAuthHeaderName, getAuthToken, getRequestBaseUrl, reportError } from "./runtime";
const rebaseUrl = (requestUrl: string, base: string): string => {
const { pathname, search } = new URL(requestUrl);
return `${base.replace(/\/+$/, "")}${pathname}${search}`;
};
const resolveRequestBase = (): string => (getRequestBaseUrl() || globalThis.location?.origin || "").replace(/\/+$/, "");
const rebaseRequest = async (request: Request, url: string): Promise<Request> => {
const init: RequestInit = {
method: request.method,
headers: request.headers,
body: request.body ? await request.arrayBuffer() : undefined,
mode: request.mode,
credentials: request.credentials,
cache: request.cache,
redirect: request.redirect,
referrer: request.referrer,
referrerPolicy: request.referrerPolicy,
integrity: request.integrity,
keepalive: request.keepalive,
signal: request.signal,
};
return new Request(url, init);
};
const BaseAwareRequest = function (url: string, init?: RequestInit): Request {
return new globalThis.Request(`${resolveRequestBase()}${url}`, init);
} as unknown as typeof Request;
const middleware: Middleware = {
async onRequest({ request }) {
const base = getRequestBaseUrl();
const next = base ? await rebaseRequest(request, rebaseUrl(request.url, base)) : request;
onRequest({ request }) {
const token = getAuthToken();
if (token) {
next.headers.set(getAuthHeaderName(), `Bearer ${token}`);
request.headers.set(getAuthHeaderName(), `Bearer ${token}`);
}
return next;
},
async onResponse({ response }) {
if (response.ok) return response;
@ -58,12 +38,13 @@ const middleware: Middleware = {
* (`fetchClient.GET("/path", { params })`) and for imperative calls; path
* params, query params, and request bodies are inferred from schema.d.ts.
*
* The creation-time base is the current origin so request URLs are absolute; the
* middleware rebases each call onto the runtime base when one is registered (a
* split-origin proxy or worker URL), injects the auth header, and maps non-2xx
* responses to ApiError so query functions can just read `.data`.
* The base URL is injected, not fixed at import: every request is built against
* whatever registerBaseUrlGetter supplies at call time (a split-origin proxy or
* worker URL), falling back to the current origin. The middleware injects the
* auth header and maps non-2xx responses to ApiError so query functions can just
* read `.data`.
*/
export const fetchClient = createFetchClient<paths>({ baseUrl: globalThis.location?.origin ?? "" });
export const fetchClient = createFetchClient<paths>({ Request: BaseAwareRequest });
fetchClient.use(middleware);
/**

View file

@ -183,78 +183,80 @@ vi.spyOn(Date.prototype, "toLocaleString").mockImplementation(function (this: Da
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
});
// Fixed matchMedia not found error in tests: https://github.com/vitest-dev/vitest/issues/821
Object.defineProperty(window, "matchMedia", {
writable: true,
value: (query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
}),
});
if (typeof window !== "undefined") {
// Fixed matchMedia not found error in tests: https://github.com/vitest-dev/vitest/issues/821
Object.defineProperty(window, "matchMedia", {
writable: true,
value: (query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
}),
});
// Silence jsdom "getComputedStyle with pseudo-elements" not implemented warnings
// by ignoring the second argument and delegating to the native implementation.
const realGetComputedStyle = window.getComputedStyle.bind(window);
window.getComputedStyle = ((elt: Element) => realGetComputedStyle(elt)) as any;
// Silence jsdom "getComputedStyle with pseudo-elements" not implemented warnings
// by ignoring the second argument and delegating to the native implementation.
const realGetComputedStyle = window.getComputedStyle.bind(window);
window.getComputedStyle = ((elt: Element) => realGetComputedStyle(elt)) as any;
// Avoid "navigation to another Document" warnings when clicking <a> with blob: URLs
// used by download flows in tests.
Object.defineProperty(HTMLAnchorElement.prototype, "click", {
configurable: true,
writable: true,
value: vi.fn(),
});
// Avoid "navigation to another Document" warnings when clicking <a> with blob: URLs
// used by download flows in tests.
Object.defineProperty(HTMLAnchorElement.prototype, "click", {
configurable: true,
writable: true,
value: vi.fn(),
});
if (!document.getAnimations) {
document.getAnimations = () => [];
}
// Stub URL.revokeObjectURL so vi.spyOn can intercept it in tests
if (!URL.revokeObjectURL) {
URL.revokeObjectURL = () => {};
}
// Mock ResizeObserver for components that use it (recharts, Tremor UI components).
// JSDOM has no layout, so for observers inside a shadcn ChartContainer ([data-slot="chart"])
// the mock immediately reports a fixed 800x400 box; recharts renders nothing until it
// observes a size. Scoped to chart subtrees only: firing for every observer re-enters
// React mid-effect for tremor/headlessui consumers whose tests assume the old no-op
// (chart text would duplicate getByText targets, popover clicks go stale). Widen or
// drop the scoping once tremor is gone.
const MOCK_RESIZE_BOX = { inlineSize: 800, blockSize: 400 };
const MOCK_RESIZE_RECT: DOMRectReadOnly = {
width: 800,
height: 400,
top: 0,
left: 0,
bottom: 400,
right: 800,
x: 0,
y: 0,
toJSON: () => ({}),
};
global.ResizeObserver = class ResizeObserver {
private readonly callback: ResizeObserverCallback;
constructor(callback: ResizeObserverCallback) {
this.callback = callback;
if (!document.getAnimations) {
document.getAnimations = () => [];
}
observe(target: Element) {
if (!target.closest('[data-slot="chart"]')) return;
const entry: ResizeObserverEntry = {
target,
contentRect: MOCK_RESIZE_RECT,
borderBoxSize: [MOCK_RESIZE_BOX],
contentBoxSize: [MOCK_RESIZE_BOX],
devicePixelContentBoxSize: [MOCK_RESIZE_BOX],
};
this.callback([entry], this);
// Stub URL.revokeObjectURL so vi.spyOn can intercept it in tests
if (!URL.revokeObjectURL) {
URL.revokeObjectURL = () => {};
}
unobserve() {}
disconnect() {}
};
// Mock ResizeObserver for components that use it (recharts, Tremor UI components).
// JSDOM has no layout, so for observers inside a shadcn ChartContainer ([data-slot="chart"])
// the mock immediately reports a fixed 800x400 box; recharts renders nothing until it
// observes a size. Scoped to chart subtrees only: firing for every observer re-enters
// React mid-effect for tremor/headlessui consumers whose tests assume the old no-op
// (chart text would duplicate getByText targets, popover clicks go stale). Widen or
// drop the scoping once tremor is gone.
const MOCK_RESIZE_BOX = { inlineSize: 800, blockSize: 400 };
const MOCK_RESIZE_RECT: DOMRectReadOnly = {
width: 800,
height: 400,
top: 0,
left: 0,
bottom: 400,
right: 800,
x: 0,
y: 0,
toJSON: () => ({}),
};
global.ResizeObserver = class ResizeObserver {
private readonly callback: ResizeObserverCallback;
constructor(callback: ResizeObserverCallback) {
this.callback = callback;
}
observe(target: Element) {
if (!target.closest('[data-slot="chart"]')) return;
const entry: ResizeObserverEntry = {
target,
contentRect: MOCK_RESIZE_RECT,
borderBoxSize: [MOCK_RESIZE_BOX],
contentBoxSize: [MOCK_RESIZE_BOX],
devicePixelContentBoxSize: [MOCK_RESIZE_BOX],
};
this.callback([entry], this);
}
unobserve() {}
disconnect() {}
};
}