mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-06-13 22:34:06 +00:00
fix(webui): bound startup fetch waits
This commit is contained in:
parent
2a98360105
commit
456ed77e79
@ -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(
|
||||
|
||||
@ -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
33
webui/src/lib/http.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -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",
|
||||
|
||||
@ -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;
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user