diff --git a/webui/src/lib/api.ts b/webui/src/lib/api.ts index cfea95dc0..156e49e35 100644 --- a/webui/src/lib/api.ts +++ b/webui/src/lib/api.ts @@ -17,6 +17,9 @@ import type { WebuiThreadPersistedPayload, WorkspaceScopePayload, } from "./types"; +import { fetchWithTimeout } from "./http"; + +const API_READ_TIMEOUT_MS = 20_000; export class ApiError extends Error { status: number; @@ -31,15 +34,20 @@ async function request( url: string, token: string, init?: RequestInit, + timeoutMs: number = 0, ): Promise { - const res = await fetch(url, { - ...(init ?? {}), - headers: { - ...(init?.headers ?? {}), - Authorization: `Bearer ${token}`, + const res = await fetchWithTimeout( + url, + { + ...(init ?? {}), + headers: { + ...(init?.headers ?? {}), + Authorization: `Bearer ${token}`, + }, + credentials: "same-origin", }, - credentials: "same-origin", - }); + timeoutMs, + ); if (!res.ok) { const text = typeof res.text === "function" ? (await res.text()).trim() : ""; throw new ApiError(res.status, text || `HTTP ${res.status}`); @@ -95,6 +103,8 @@ export async function listSessions( const body = await request<{ sessions: Row[] }>( `${base}/api/sessions`, token, + undefined, + API_READ_TIMEOUT_MS, ); return body.sessions.map((s) => ({ key: s.key, @@ -115,7 +125,7 @@ export async function fetchWebuiThread( base: string = "", ): Promise { const url = `${base}/api/sessions/${encodeURIComponent(key)}/webui-thread`; - const res = await fetch(url, { + const res = await fetchWithTimeout(url, { headers: { Authorization: `Bearer ${token}` }, credentials: "same-origin", }); @@ -140,21 +150,36 @@ export async function fetchSettings( token: string, base: string = "", ): Promise { - return request(`${base}/api/settings`, token); + return request( + `${base}/api/settings`, + token, + undefined, + API_READ_TIMEOUT_MS, + ); } export async function fetchWorkspaces( token: string, base: string = "", ): Promise { - return request(`${base}/api/workspaces`, token); + return request( + `${base}/api/workspaces`, + token, + undefined, + API_READ_TIMEOUT_MS, + ); } export async function fetchCliApps( token: string, base: string = "", ): Promise { - return request(`${base}/api/settings/cli-apps`, token); + return request( + `${base}/api/settings/cli-apps`, + token, + undefined, + API_READ_TIMEOUT_MS, + ); } export async function runCliAppAction( @@ -172,7 +197,12 @@ export async function fetchMcpPresets( token: string, base: string = "", ): Promise { - return request(`${base}/api/settings/mcp-presets`, token); + return request( + `${base}/api/settings/mcp-presets`, + token, + undefined, + API_READ_TIMEOUT_MS, + ); } export async function fetchProviderModels( @@ -185,6 +215,8 @@ export async function fetchProviderModels( return request( `${base}/api/settings/provider-models?${query}`, token, + undefined, + API_READ_TIMEOUT_MS, ); } @@ -252,7 +284,12 @@ export async function listSlashCommands( icon: string; arg_hint?: string; }; - const body = await request<{ commands: Row[] }>(`${base}/api/commands`, token); + const body = await request<{ commands: Row[] }>( + `${base}/api/commands`, + token, + undefined, + API_READ_TIMEOUT_MS, + ); return body.commands .filter((command) => !["/stop", "/restart"].includes(command.command)) .map((command) => ({ @@ -268,7 +305,12 @@ export async function fetchSidebarState( token: string, base: string = "", ): Promise { - return request(`${base}/api/webui/sidebar-state`, token); + return request( + `${base}/api/webui/sidebar-state`, + token, + undefined, + API_READ_TIMEOUT_MS, + ); } export async function updateSidebarState( diff --git a/webui/src/lib/bootstrap.ts b/webui/src/lib/bootstrap.ts index 137014d05..434fe6859 100644 --- a/webui/src/lib/bootstrap.ts +++ b/webui/src/lib/bootstrap.ts @@ -1,4 +1,5 @@ import type { BootstrapResponse } from "./types"; +import { fetchWithTimeout } from "./http"; const SECRET_STORAGE_KEY = "nanobot-webui.bootstrap-secret"; @@ -37,16 +38,17 @@ export function clearSavedSecret(): void { export async function fetchBootstrap( baseUrl: string = "", secret: string = "", + timeoutMs?: number, ): Promise { const headers: Record = {}; if (secret) { headers["X-Nanobot-Auth"] = secret; } - const res = await fetch(`${baseUrl}/webui/bootstrap`, { + const res = await fetchWithTimeout(`${baseUrl}/webui/bootstrap`, { method: "GET", credentials: "same-origin", headers, - }); + }, timeoutMs); if (!res.ok) { throw new Error(`bootstrap failed: HTTP ${res.status}`); } diff --git a/webui/src/lib/http.ts b/webui/src/lib/http.ts new file mode 100644 index 000000000..c2a780f08 --- /dev/null +++ b/webui/src/lib/http.ts @@ -0,0 +1,33 @@ +export const DEFAULT_HTTP_TIMEOUT_MS = 20_000; + +export async function fetchWithTimeout( + input: RequestInfo | URL, + init: RequestInit = {}, + timeoutMs: number = DEFAULT_HTTP_TIMEOUT_MS, +): Promise { + if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) { + return fetch(input, init); + } + + const controller = typeof AbortController !== "undefined" + ? new AbortController() + : null; + let timeoutId: ReturnType | undefined; + + const request = fetch(input, { + ...init, + signal: controller?.signal ?? init.signal, + }); + const timeout = new Promise((_, reject) => { + timeoutId = setTimeout(() => { + reject(new Error(`Request timed out after ${timeoutMs}ms`)); + controller?.abort(); + }, timeoutMs); + }); + + try { + return await Promise.race([request, timeout]); + } finally { + if (timeoutId !== undefined) clearTimeout(timeoutId); + } +} diff --git a/webui/src/tests/api.test.ts b/webui/src/tests/api.test.ts index 0ab16274a..6e4b8ab28 100644 --- a/webui/src/tests/api.test.ts +++ b/webui/src/tests/api.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createModelConfiguration, @@ -38,6 +38,11 @@ describe("webui API helpers", () => { ); }); + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + it("percent-encodes websocket keys when fetching webui-thread snapshot", async () => { await fetchWebuiThread("tok", "websocket:chat-1"); @@ -151,6 +156,18 @@ describe("webui API helpers", () => { }); }); + it("times out when an API request never responds", async () => { + vi.useFakeTimers(); + vi.stubGlobal("fetch", vi.fn(() => new Promise(() => {}))); + + const pending = expect(listSessions("tok")).rejects.toThrow( + "Request timed out after 20000ms", + ); + await vi.advanceTimersByTimeAsync(20_000); + + await pending; + }); + it("serializes provider settings updates without returning secrets", async () => { await updateProviderSettings("tok", { provider: "openrouter", diff --git a/webui/src/tests/bootstrap.test.ts b/webui/src/tests/bootstrap.test.ts index 610a8e236..27ed3a8b3 100644 --- a/webui/src/tests/bootstrap.test.ts +++ b/webui/src/tests/bootstrap.test.ts @@ -1,8 +1,13 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; -import { deriveWsUrl } from "@/lib/bootstrap"; +import { deriveWsUrl, fetchBootstrap } from "@/lib/bootstrap"; describe("bootstrap helpers", () => { + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + it("prefers the server-provided websocket URL over the current dev host", () => { expect(deriveWsUrl("/", "tok en", "ws://127.0.0.1:8765/")).toBe( "ws://127.0.0.1:8765/?token=tok%20en", @@ -20,4 +25,16 @@ describe("bootstrap helpers", () => { "ws://localhost:3000/?token=tok", ); }); + + it("times out when the bootstrap endpoint never responds", async () => { + vi.useFakeTimers(); + vi.stubGlobal("fetch", vi.fn(() => new Promise(() => {}))); + + const pending = expect(fetchBootstrap("", "", 25)).rejects.toThrow( + "Request timed out after 25ms", + ); + await vi.advanceTimersByTimeAsync(25); + + await pending; + }); });