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";
client: NanobotClient;
token: string;
tokenExpiresAt: number;
modelName: string | null;
};
const SIDEBAR_STORAGE_KEY = "nanobot-webui.sidebar";
const RESTART_STARTED_KEY = "nanobot-webui.restartStartedAt";
const SIDEBAR_WIDTH = 272;
const TOKEN_REFRESH_MARGIN_MS = 30_000;
const TOKEN_REFRESH_MIN_DELAY_MS = 5_000;
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({
failed,
onSecret,
@ -108,6 +124,7 @@ function readSidebarOpen(): boolean {
export default function App() {
const { t } = useTranslation();
const [state, setState] = useState<BootState>({ status: "loading" });
const bootstrapSecretRef = useRef("");
const bootstrapWithSecret = useCallback(
(secret: string) => {
@ -119,22 +136,37 @@ export default function App() {
if (cancelled) return;
if (secret) saveSecret(secret);
const url = deriveWsUrl(boot.ws_path, boot.token);
const client = new NanobotClient({
let client: NanobotClient;
client = new NanobotClient({
url,
onReauth: async () => {
try {
const refreshed = await fetchBootstrap("", secret);
return deriveWsUrl(refreshed.ws_path, refreshed.token);
const refreshed = await fetchBootstrap("", bootstrapSecretRef.current);
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 {
return null;
}
},
});
bootstrapSecretRef.current = secret;
client.connect();
setState({
status: "ready",
client,
token: boot.token,
tokenExpiresAt: bootstrapTokenExpiresAt(boot.expires_in),
modelName: boot.model_name ?? null,
});
} 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(() => {
const saved = loadSavedSecret();
return bootstrapWithSecret(saved);

View File

@ -1,5 +1,5 @@
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ChatSummary } from "@/lib/types";
@ -8,6 +8,7 @@ const refreshSpy = vi.fn();
const createChatSpy = vi.fn().mockResolvedValue("chat-1");
const deleteChatSpy = vi.fn();
const toggleThemeSpy = vi.fn();
const updateUrlSpy = vi.fn();
let mockSessions: ChatSummary[] = [];
vi.mock("@/hooks/useSessions", async (importOriginal) => {
@ -70,22 +71,30 @@ vi.mock("@/lib/nanobot-client", () => {
newChat = vi.fn();
attach = vi.fn();
close = vi.fn();
updateUrl = vi.fn();
updateUrl = updateUrlSpy;
}
return { NanobotClient: MockClient };
});
import { deriveWsUrl, fetchBootstrap } from "@/lib/bootstrap";
import App from "@/App";
describe("App layout", () => {
beforeEach(() => {
mockSessions = [];
connectSpy.mockClear();
updateUrlSpy.mockClear();
refreshSpy.mockReset();
createChatSpy.mockClear();
deleteChatSpy.mockReset();
toggleThemeSpy.mockReset();
vi.mocked(fetchBootstrap).mockReset().mockResolvedValue({
token: "tok",
ws_path: "/",
expires_in: 300,
});
vi.mocked(deriveWsUrl).mockReset().mockReturnValue("ws://test");
vi.stubGlobal(
"fetch",
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 () => {
const { container } = render(<App />);
@ -479,4 +492,36 @@ describe("App layout", () => {
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();
});
});