fix(webui): refresh bootstrap token before expiry

This commit is contained in:
Xubin Ren 2026-05-18 00:53:36 +08:00
parent af26ed0041
commit fce1550814
2 changed files with 112 additions and 6 deletions

View File

@ -32,14 +32,30 @@ type BootState =
status: "ready"; status: "ready";
client: NanobotClient; client: NanobotClient;
token: string; token: string;
tokenExpiresAt: number;
modelName: string | null; modelName: string | null;
}; };
const SIDEBAR_STORAGE_KEY = "nanobot-webui.sidebar"; const SIDEBAR_STORAGE_KEY = "nanobot-webui.sidebar";
const RESTART_STARTED_KEY = "nanobot-webui.restartStartedAt"; const RESTART_STARTED_KEY = "nanobot-webui.restartStartedAt";
const SIDEBAR_WIDTH = 272; const SIDEBAR_WIDTH = 272;
const TOKEN_REFRESH_MARGIN_MS = 30_000;
const TOKEN_REFRESH_MIN_DELAY_MS = 5_000;
type ShellView = "chat" | "settings"; type ShellView = "chat" | "settings";
function bootstrapTokenExpiresAt(expiresInSeconds: number): number {
return Date.now() + Math.max(0, expiresInSeconds) * 1000;
}
function tokenRefreshDelayMs(expiresAt: number): number {
const remaining = Math.max(0, expiresAt - Date.now());
const margin = Math.min(
TOKEN_REFRESH_MARGIN_MS,
Math.max(1_000, remaining / 2),
);
return Math.max(TOKEN_REFRESH_MIN_DELAY_MS, remaining - margin);
}
function AuthForm({ function AuthForm({
failed, failed,
onSecret, onSecret,
@ -108,6 +124,7 @@ function readSidebarOpen(): boolean {
export default function App() { export default function App() {
const { t } = useTranslation(); const { t } = useTranslation();
const [state, setState] = useState<BootState>({ status: "loading" }); const [state, setState] = useState<BootState>({ status: "loading" });
const bootstrapSecretRef = useRef("");
const bootstrapWithSecret = useCallback( const bootstrapWithSecret = useCallback(
(secret: string) => { (secret: string) => {
@ -119,22 +136,37 @@ export default function App() {
if (cancelled) return; if (cancelled) return;
if (secret) saveSecret(secret); if (secret) saveSecret(secret);
const url = deriveWsUrl(boot.ws_path, boot.token); const url = deriveWsUrl(boot.ws_path, boot.token);
const client = new NanobotClient({ let client: NanobotClient;
client = new NanobotClient({
url, url,
onReauth: async () => { onReauth: async () => {
try { try {
const refreshed = await fetchBootstrap("", secret); const refreshed = await fetchBootstrap("", bootstrapSecretRef.current);
return deriveWsUrl(refreshed.ws_path, refreshed.token); const refreshedUrl = deriveWsUrl(refreshed.ws_path, refreshed.token);
const tokenExpiresAt = bootstrapTokenExpiresAt(refreshed.expires_in);
setState((current) =>
current.status === "ready" && current.client === client
? {
...current,
token: refreshed.token,
tokenExpiresAt,
modelName: refreshed.model_name ?? current.modelName,
}
: current,
);
return refreshedUrl;
} catch { } catch {
return null; return null;
} }
}, },
}); });
bootstrapSecretRef.current = secret;
client.connect(); client.connect();
setState({ setState({
status: "ready", status: "ready",
client, client,
token: boot.token, token: boot.token,
tokenExpiresAt: bootstrapTokenExpiresAt(boot.expires_in),
modelName: boot.model_name ?? null, modelName: boot.model_name ?? null,
}); });
} catch (e) { } catch (e) {
@ -154,6 +186,35 @@ export default function App() {
[], [],
); );
useEffect(() => {
if (state.status !== "ready") return;
const client = state.client;
const timer = window.setTimeout(async () => {
try {
const boot = await fetchBootstrap("", bootstrapSecretRef.current);
const url = deriveWsUrl(boot.ws_path, boot.token);
const tokenExpiresAt = bootstrapTokenExpiresAt(boot.expires_in);
client.updateUrl(url);
setState((current) =>
current.status === "ready" && current.client === client
? {
...current,
token: boot.token,
tokenExpiresAt,
modelName: boot.model_name ?? current.modelName,
}
: current,
);
} catch (e) {
const msg = (e as Error).message;
if (msg.includes("HTTP 401") || msg.includes("HTTP 403")) {
setState({ status: "auth", failed: true });
}
}
}, tokenRefreshDelayMs(state.tokenExpiresAt));
return () => window.clearTimeout(timer);
}, [state]);
useEffect(() => { useEffect(() => {
const saved = loadSavedSecret(); const saved = loadSavedSecret();
return bootstrapWithSecret(saved); return bootstrapWithSecret(saved);

View File

@ -1,5 +1,5 @@
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ChatSummary } from "@/lib/types"; import type { ChatSummary } from "@/lib/types";
@ -8,6 +8,7 @@ const refreshSpy = vi.fn();
const createChatSpy = vi.fn().mockResolvedValue("chat-1"); const createChatSpy = vi.fn().mockResolvedValue("chat-1");
const deleteChatSpy = vi.fn(); const deleteChatSpy = vi.fn();
const toggleThemeSpy = vi.fn(); const toggleThemeSpy = vi.fn();
const updateUrlSpy = vi.fn();
let mockSessions: ChatSummary[] = []; let mockSessions: ChatSummary[] = [];
vi.mock("@/hooks/useSessions", async (importOriginal) => { vi.mock("@/hooks/useSessions", async (importOriginal) => {
@ -70,22 +71,30 @@ vi.mock("@/lib/nanobot-client", () => {
newChat = vi.fn(); newChat = vi.fn();
attach = vi.fn(); attach = vi.fn();
close = vi.fn(); close = vi.fn();
updateUrl = vi.fn(); updateUrl = updateUrlSpy;
} }
return { NanobotClient: MockClient }; return { NanobotClient: MockClient };
}); });
import { deriveWsUrl, fetchBootstrap } from "@/lib/bootstrap";
import App from "@/App"; import App from "@/App";
describe("App layout", () => { describe("App layout", () => {
beforeEach(() => { beforeEach(() => {
mockSessions = []; mockSessions = [];
connectSpy.mockClear(); connectSpy.mockClear();
updateUrlSpy.mockClear();
refreshSpy.mockReset(); refreshSpy.mockReset();
createChatSpy.mockClear(); createChatSpy.mockClear();
deleteChatSpy.mockReset(); deleteChatSpy.mockReset();
toggleThemeSpy.mockReset(); toggleThemeSpy.mockReset();
vi.mocked(fetchBootstrap).mockReset().mockResolvedValue({
token: "tok",
ws_path: "/",
expires_in: 300,
});
vi.mocked(deriveWsUrl).mockReset().mockReturnValue("ws://test");
vi.stubGlobal( vi.stubGlobal(
"fetch", "fetch",
vi.fn().mockResolvedValue({ vi.fn().mockResolvedValue({
@ -95,6 +104,10 @@ describe("App layout", () => {
); );
}); });
afterEach(() => {
vi.useRealTimers();
});
it("keeps sidebar layout out of the main thread width contract", async () => { it("keeps sidebar layout out of the main thread width contract", async () => {
const { container } = render(<App />); const { container } = render(<App />);
@ -479,4 +492,36 @@ describe("App layout", () => {
expect(within(sidebar).getByText("Existing chat")).toBeInTheDocument(); expect(within(sidebar).getByText("Existing chat")).toBeInTheDocument();
}); });
it("refreshes the bootstrap token before REST settings auth expires", async () => {
vi.useFakeTimers();
vi.mocked(fetchBootstrap)
.mockResolvedValueOnce({
token: "tok-1",
ws_path: "/",
expires_in: 30,
})
.mockResolvedValueOnce({
token: "tok-2",
ws_path: "/",
expires_in: 300,
});
vi.mocked(deriveWsUrl).mockImplementation(
(_wsPath: string, token: string) => `ws://test?token=${token}`,
);
const { unmount } = render(<App />);
await act(async () => {});
expect(connectSpy).toHaveBeenCalled();
expect(fetchBootstrap).toHaveBeenCalledTimes(1);
await act(async () => {
await vi.advanceTimersByTimeAsync(15_000);
});
expect(fetchBootstrap).toHaveBeenCalledTimes(2);
expect(updateUrlSpy).toHaveBeenCalledWith("ws://test?token=tok-2");
unmount();
});
}); });