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,
|
WebuiThreadPersistedPayload,
|
||||||
WorkspaceScopePayload,
|
WorkspaceScopePayload,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
import { fetchWithTimeout } from "./http";
|
||||||
|
|
||||||
|
const API_READ_TIMEOUT_MS = 20_000;
|
||||||
|
|
||||||
export class ApiError extends Error {
|
export class ApiError extends Error {
|
||||||
status: number;
|
status: number;
|
||||||
@ -31,15 +34,20 @@ async function request<T>(
|
|||||||
url: string,
|
url: string,
|
||||||
token: string,
|
token: string,
|
||||||
init?: RequestInit,
|
init?: RequestInit,
|
||||||
|
timeoutMs: number = 0,
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const res = await fetch(url, {
|
const res = await fetchWithTimeout(
|
||||||
...(init ?? {}),
|
url,
|
||||||
headers: {
|
{
|
||||||
...(init?.headers ?? {}),
|
...(init ?? {}),
|
||||||
Authorization: `Bearer ${token}`,
|
headers: {
|
||||||
|
...(init?.headers ?? {}),
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
credentials: "same-origin",
|
||||||
},
|
},
|
||||||
credentials: "same-origin",
|
timeoutMs,
|
||||||
});
|
);
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const text = typeof res.text === "function" ? (await res.text()).trim() : "";
|
const text = typeof res.text === "function" ? (await res.text()).trim() : "";
|
||||||
throw new ApiError(res.status, text || `HTTP ${res.status}`);
|
throw new ApiError(res.status, text || `HTTP ${res.status}`);
|
||||||
@ -95,6 +103,8 @@ export async function listSessions(
|
|||||||
const body = await request<{ sessions: Row[] }>(
|
const body = await request<{ sessions: Row[] }>(
|
||||||
`${base}/api/sessions`,
|
`${base}/api/sessions`,
|
||||||
token,
|
token,
|
||||||
|
undefined,
|
||||||
|
API_READ_TIMEOUT_MS,
|
||||||
);
|
);
|
||||||
return body.sessions.map((s) => ({
|
return body.sessions.map((s) => ({
|
||||||
key: s.key,
|
key: s.key,
|
||||||
@ -115,7 +125,7 @@ export async function fetchWebuiThread(
|
|||||||
base: string = "",
|
base: string = "",
|
||||||
): Promise<WebuiThreadPersistedPayload | null> {
|
): Promise<WebuiThreadPersistedPayload | null> {
|
||||||
const url = `${base}/api/sessions/${encodeURIComponent(key)}/webui-thread`;
|
const url = `${base}/api/sessions/${encodeURIComponent(key)}/webui-thread`;
|
||||||
const res = await fetch(url, {
|
const res = await fetchWithTimeout(url, {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
credentials: "same-origin",
|
credentials: "same-origin",
|
||||||
});
|
});
|
||||||
@ -140,21 +150,36 @@ export async function fetchSettings(
|
|||||||
token: string,
|
token: string,
|
||||||
base: string = "",
|
base: string = "",
|
||||||
): Promise<SettingsPayload> {
|
): 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(
|
export async function fetchWorkspaces(
|
||||||
token: string,
|
token: string,
|
||||||
base: string = "",
|
base: string = "",
|
||||||
): Promise<WorkspacesPayload> {
|
): 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(
|
export async function fetchCliApps(
|
||||||
token: string,
|
token: string,
|
||||||
base: string = "",
|
base: string = "",
|
||||||
): Promise<CliAppsPayload> {
|
): 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(
|
export async function runCliAppAction(
|
||||||
@ -172,7 +197,12 @@ export async function fetchMcpPresets(
|
|||||||
token: string,
|
token: string,
|
||||||
base: string = "",
|
base: string = "",
|
||||||
): Promise<McpPresetsPayload> {
|
): 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(
|
export async function fetchProviderModels(
|
||||||
@ -185,6 +215,8 @@ export async function fetchProviderModels(
|
|||||||
return request<ProviderModelsPayload>(
|
return request<ProviderModelsPayload>(
|
||||||
`${base}/api/settings/provider-models?${query}`,
|
`${base}/api/settings/provider-models?${query}`,
|
||||||
token,
|
token,
|
||||||
|
undefined,
|
||||||
|
API_READ_TIMEOUT_MS,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -252,7 +284,12 @@ export async function listSlashCommands(
|
|||||||
icon: string;
|
icon: string;
|
||||||
arg_hint?: 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
|
return body.commands
|
||||||
.filter((command) => !["/stop", "/restart"].includes(command.command))
|
.filter((command) => !["/stop", "/restart"].includes(command.command))
|
||||||
.map((command) => ({
|
.map((command) => ({
|
||||||
@ -268,7 +305,12 @@ export async function fetchSidebarState(
|
|||||||
token: string,
|
token: string,
|
||||||
base: string = "",
|
base: string = "",
|
||||||
): Promise<SidebarStatePayload> {
|
): 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(
|
export async function updateSidebarState(
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import type { BootstrapResponse } from "./types";
|
import type { BootstrapResponse } from "./types";
|
||||||
|
import { fetchWithTimeout } from "./http";
|
||||||
|
|
||||||
const SECRET_STORAGE_KEY = "nanobot-webui.bootstrap-secret";
|
const SECRET_STORAGE_KEY = "nanobot-webui.bootstrap-secret";
|
||||||
|
|
||||||
@ -37,16 +38,17 @@ export function clearSavedSecret(): void {
|
|||||||
export async function fetchBootstrap(
|
export async function fetchBootstrap(
|
||||||
baseUrl: string = "",
|
baseUrl: string = "",
|
||||||
secret: string = "",
|
secret: string = "",
|
||||||
|
timeoutMs?: number,
|
||||||
): Promise<BootstrapResponse> {
|
): Promise<BootstrapResponse> {
|
||||||
const headers: Record<string, string> = {};
|
const headers: Record<string, string> = {};
|
||||||
if (secret) {
|
if (secret) {
|
||||||
headers["X-Nanobot-Auth"] = secret;
|
headers["X-Nanobot-Auth"] = secret;
|
||||||
}
|
}
|
||||||
const res = await fetch(`${baseUrl}/webui/bootstrap`, {
|
const res = await fetchWithTimeout(`${baseUrl}/webui/bootstrap`, {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
credentials: "same-origin",
|
credentials: "same-origin",
|
||||||
headers,
|
headers,
|
||||||
});
|
}, timeoutMs);
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Error(`bootstrap failed: HTTP ${res.status}`);
|
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 {
|
import {
|
||||||
createModelConfiguration,
|
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 () => {
|
it("percent-encodes websocket keys when fetching webui-thread snapshot", async () => {
|
||||||
await fetchWebuiThread("tok", "websocket:chat-1");
|
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 () => {
|
it("serializes provider settings updates without returning secrets", async () => {
|
||||||
await updateProviderSettings("tok", {
|
await updateProviderSettings("tok", {
|
||||||
provider: "openrouter",
|
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", () => {
|
describe("bootstrap helpers", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
it("prefers the server-provided websocket URL over the current dev host", () => {
|
it("prefers the server-provided websocket URL over the current dev host", () => {
|
||||||
expect(deriveWsUrl("/", "tok en", "ws://127.0.0.1:8765/")).toBe(
|
expect(deriveWsUrl("/", "tok en", "ws://127.0.0.1:8765/")).toBe(
|
||||||
"ws://127.0.0.1:8765/?token=tok%20en",
|
"ws://127.0.0.1:8765/?token=tok%20en",
|
||||||
@ -20,4 +25,16 @@ describe("bootstrap helpers", () => {
|
|||||||
"ws://localhost:3000/?token=tok",
|
"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