fix(webui): bound startup fetch waits

This commit is contained in:
chengyongru 2026-06-02 15:36:52 +08:00 committed by Xubin Ren
parent 2a98360105
commit 456ed77e79
5 changed files with 130 additions and 19 deletions

View File

@ -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<T>(
url: string,
token: string,
init?: RequestInit,
timeoutMs: number = 0,
): Promise<T> {
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<WebuiThreadPersistedPayload | null> {
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<SettingsPayload> {
return request<SettingsPayload>(`${base}/api/settings`, token);
return request<SettingsPayload>(
`${base}/api/settings`,
token,
undefined,
API_READ_TIMEOUT_MS,
);
}
export async function fetchWorkspaces(
token: string,
base: string = "",
): Promise<WorkspacesPayload> {
return request<WorkspacesPayload>(`${base}/api/workspaces`, token);
return request<WorkspacesPayload>(
`${base}/api/workspaces`,
token,
undefined,
API_READ_TIMEOUT_MS,
);
}
export async function fetchCliApps(
token: string,
base: string = "",
): Promise<CliAppsPayload> {
return request<CliAppsPayload>(`${base}/api/settings/cli-apps`, token);
return request<CliAppsPayload>(
`${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<McpPresetsPayload> {
return request<McpPresetsPayload>(`${base}/api/settings/mcp-presets`, token);
return request<McpPresetsPayload>(
`${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<ProviderModelsPayload>(
`${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<SidebarStatePayload> {
return request<SidebarStatePayload>(`${base}/api/webui/sidebar-state`, token);
return request<SidebarStatePayload>(
`${base}/api/webui/sidebar-state`,
token,
undefined,
API_READ_TIMEOUT_MS,
);
}
export async function updateSidebarState(

View File

@ -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<BootstrapResponse> {
const headers: Record<string, string> = {};
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}`);
}

33
webui/src/lib/http.ts Normal file
View File

@ -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<Response> {
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
return fetch(input, init);
}
const controller = typeof AbortController !== "undefined"
? new AbortController()
: null;
let timeoutId: ReturnType<typeof setTimeout> | undefined;
const request = fetch(input, {
...init,
signal: controller?.signal ?? init.signal,
});
const timeout = new Promise<Response>((_, 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);
}
}

View File

@ -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<Response>(() => {})));
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",

View File

@ -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<Response>(() => {})));
const pending = expect(fetchBootstrap("", "", 25)).rejects.toThrow(
"Request timed out after 25ms",
);
await vi.advanceTimersByTimeAsync(25);
await pending;
});
});