Skip to content

Commit 7f01da2

Browse files
committed
Fix local dashboard API base inference
1 parent 8006cf7 commit 7f01da2

3 files changed

Lines changed: 60 additions & 9 deletions

File tree

dashboard/src/lib/api/core.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -133,10 +133,16 @@ export async function ensureLibraryResponse(
133133
): Promise<Response> {
134134
if (res.ok) return res;
135135
const text = await res.text().catch(() => "");
136+
const contentType = res.headers.get("content-type") || "";
137+
const looksLikeHtml =
138+
contentType.includes("text/html") || /^\s*<!doctype html/i.test(text) || /^\s*<html[\s>]/i.test(text);
139+
const message = looksLikeHtml
140+
? `${fallbackMessage}. Received an HTML page instead of API JSON; check Settings -> API URL.`
141+
: text || fallbackMessage;
136142
if (res.status === 503) {
137-
throw new LibraryUnavailableError(text || "Library not initialized");
143+
throw new LibraryUnavailableError(looksLikeHtml ? "Library not initialized" : text || "Library not initialized");
138144
}
139-
throw new Error(text || fallbackMessage);
145+
throw new Error(message);
140146
}
141147

142148
export async function libGet<T>(path: string, errorMsg: string): Promise<T> {

dashboard/src/lib/settings.test.ts

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1-
import { describe, it, expect, beforeEach, vi, afterEach } from "vitest";
1+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
22
import {
33
readSavedSettings,
44
writeSavedSettings,
55
getRuntimeApiBase,
66
inferHostedApiBase,
7+
inferLocalApiBase,
78
} from "./settings";
89

910
describe("readSavedSettings", () => {
@@ -68,16 +69,27 @@ describe("getRuntimeApiBase", () => {
6869
expect(getRuntimeApiBase()).toBe("http://custom:9999");
6970
});
7071

72+
it("does not preserve a saved local frontend origin as the API URL", () => {
73+
localStorage.setItem(
74+
"settings",
75+
JSON.stringify({ apiUrl: window.location.origin })
76+
);
77+
expect(getRuntimeApiBase()).toBe("http://localhost:3000");
78+
});
79+
7180
it("returns env var when no saved setting", () => {
7281
process.env.NEXT_PUBLIC_API_URL = "http://env-host:8080";
7382
expect(getRuntimeApiBase()).toBe("http://env-host:8080");
7483
});
7584

76-
it("falls back to window.location.origin when no saved setting or env var", () => {
77-
// The key behavior: uses window.location.origin instead of hardcoding :3000.
78-
// In jsdom the origin is 'http://localhost' by default.
79-
const result = getRuntimeApiBase();
80-
expect(result).toBe(window.location.origin);
85+
it("maps a local browser origin to the default backend port", () => {
86+
window.history.replaceState({}, "", "/control");
87+
expect(getRuntimeApiBase()).toBe("http://localhost:3000");
88+
});
89+
90+
it("keeps same-origin when the local page already runs on the backend port", () => {
91+
window.history.replaceState({}, "", "http://localhost:3000/control");
92+
expect(getRuntimeApiBase()).toBe("http://localhost:3000");
8193
});
8294

8395
it("strips trailing slash from returned URL", () => {
@@ -98,3 +110,15 @@ describe("inferHostedApiBase", () => {
98110
expect(inferHostedApiBase("example.com")).toBeNull();
99111
});
100112
});
113+
114+
describe("inferLocalApiBase", () => {
115+
it("maps localhost frontend ports to :3000", () => {
116+
expect(inferLocalApiBase(new URL("http://localhost:3001/control") as unknown as Location)).toBe(
117+
"http://localhost:3000"
118+
);
119+
});
120+
121+
it("does not override non-local hosts", () => {
122+
expect(inferLocalApiBase(new URL("https://example.com/control") as unknown as Location)).toBeNull();
123+
});
124+
});

dashboard/src/lib/settings.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,19 +33,40 @@ const HOSTED_API_BASE_BY_HOSTNAME: Record<string, string> = {
3333
'agent.thomas.md': 'https://agent-backend.thomas.md',
3434
};
3535

36+
const LOCAL_BACKEND_PORT = '3000';
37+
3638
export function inferHostedApiBase(hostname: string): string | null {
3739
return HOSTED_API_BASE_BY_HOSTNAME[hostname] ?? null;
3840
}
3941

42+
export function inferLocalApiBase(location: Location): string | null {
43+
if (!['localhost', '127.0.0.1', '::1'].includes(location.hostname)) {
44+
return null;
45+
}
46+
if (location.port === LOCAL_BACKEND_PORT) {
47+
return null;
48+
}
49+
const host = location.hostname === '::1' ? '[::1]' : location.hostname;
50+
return `${location.protocol}//${host}:${LOCAL_BACKEND_PORT}`;
51+
}
52+
4053
export function getRuntimeApiBase(): string {
4154
const envBase = process.env.NEXT_PUBLIC_API_URL;
4255
if (typeof window === 'undefined') {
4356
return normalizeBaseUrl(envBase || 'http://127.0.0.1:3000');
4457
}
4558
const saved = readSavedSettings().apiUrl;
46-
if (saved) return normalizeBaseUrl(saved);
59+
const localBase = inferLocalApiBase(window.location);
60+
if (saved) {
61+
const normalizedSaved = normalizeBaseUrl(saved);
62+
if (localBase && normalizedSaved === normalizeBaseUrl(window.location.origin)) {
63+
return normalizeBaseUrl(localBase);
64+
}
65+
return normalizedSaved;
66+
}
4767
if (envBase) return normalizeBaseUrl(envBase);
4868
const hostedBase = inferHostedApiBase(window.location.hostname);
4969
if (hostedBase) return normalizeBaseUrl(hostedBase);
70+
if (localBase) return normalizeBaseUrl(localBase);
5071
return normalizeBaseUrl(window.location.origin);
5172
}

0 commit comments

Comments
 (0)