import { RestClientError, requestRestJson, resolvePinnedRestEndpoint, resolveRestEndpoint, type RestResponseMetadata, } from "../shared/restClient"; import type { Settings } from "./types"; type SettingsRecord = Record; type LoadSettingsResponse = SettingsRecord | { settings: SettingsRecord }; type SettingsMutationResponse = { success: true; settings: SettingsRecord }; export type SettingsBaselineResult = { baseline: Settings; revision: string }; export { RestClientError }; const SETTINGS_REVISION_PATTERN = /^sha256:[a-f0-9]{64}$/; const SETTINGS_ROOT_ROUTE = "/b3-wvs/v1"; const SETTINGS_ROUTE = "/b3-wvs/v1/settings"; const SETTINGS_RESET_ROUTE = "/b3-wvs/v1/settings/reset"; const SETTINGS_RETRY_DELAYS_MS = [50, 150] as const; const TRANSIENT_SETTINGS_LOAD_CODES = new Set([ "b3_settings_busy", "b3_settings_revision_unavailable", ]); const messages = { invalidEndpoint: "Invalid settings endpoint.", missingNonce: "Missing settings nonce.", networkError: "Settings request failed.", timeout: "Settings request timed out.", invalidResponse: "Invalid settings response.", statusError: (status: number) => `Settings request failed (${status}).`, }; function isRecord(value: unknown): value is SettingsRecord { return value !== null && typeof value === "object" && !Array.isArray(value); } function isLoadSettingsResponse(value: unknown): value is LoadSettingsResponse { return ( isRecord(value) && (!("settings" in value) || isRecord(value.settings)) ); } function isSettingsMutationResponse( value: unknown, ): value is SettingsMutationResponse { return ( isRecord(value) && value.success === true && "settings" in value && isRecord(value.settings) ); } function parseStrongSettingsEtag(value: string | null): string | null { const match = value ? /^"(sha256:[a-f0-9]{64})"$/.exec(value) : null; return match?.[1] ?? null; } function requireIfMatchHeader(revision: unknown): string { if ( typeof revision !== "string" || !SETTINGS_REVISION_PATTERN.test(revision) ) { throw new RestClientError("Invalid settings revision.", { kind: "request", }); } return `"${revision}"`; } function settingsEndpoint(restUrl: string, expectedRoute: string): string { const anchor = resolveRestEndpoint(restUrl); if (!anchor) return ""; const candidate = new URL(anchor); const restRoutes = candidate.searchParams.getAll("rest_route"); if (restRoutes.length > 0) { const queryKeys = Array.from(candidate.searchParams.keys()); if ( restRoutes.length !== 1 || restRoutes[0] !== SETTINGS_ROOT_ROUTE || queryKeys.some((key) => key !== "rest_route") || candidate.hash ) { return ""; } candidate.search = ""; candidate.searchParams.set("rest_route", expectedRoute); } else { if ( candidate.search || candidate.hash || !candidate.pathname.endsWith(SETTINGS_ROOT_ROUTE) ) { return ""; } candidate.pathname = `${candidate.pathname.slice( 0, -SETTINGS_ROOT_ROUTE.length, )}${expectedRoute}`; } return ( resolvePinnedRestEndpoint( candidate.toString(), anchor, SETTINGS_ROOT_ROUTE, expectedRoute, ) ?? "" ); } function settingsLoadAbortReason(signal: AbortSignal): unknown { return ( signal.reason ?? new DOMException("The operation was aborted.", "AbortError") ); } function isTransientSettingsLoadError(error: unknown): boolean { return ( error instanceof RestClientError && error.status === 503 && typeof error.code === "string" && TRANSIENT_SETTINGS_LOAD_CODES.has(error.code) ); } function isTransientSettingsSaveError(error: unknown): boolean { return ( error instanceof RestClientError && error.status === 503 && error.code === "b3_settings_busy" ); } function waitForSettingsLoadRetry( delayMs: number, signal?: AbortSignal, ): Promise { if (signal?.aborted) { return Promise.reject(settingsLoadAbortReason(signal)); } return new Promise((resolve, reject) => { const onAbort = () => { window.clearTimeout(timeoutId); signal?.removeEventListener("abort", onAbort); reject(signal ? settingsLoadAbortReason(signal) : undefined); }; const timeoutId = window.setTimeout(() => { signal?.removeEventListener("abort", onAbort); resolve(); }, delayMs); signal?.addEventListener("abort", onAbort, { once: true }); if (signal?.aborted) { onAbort(); } }); } async function requestSettings(args: { endpoint: string; nonce: string; signal?: AbortSignal; init: RequestInit; validate: (value: unknown) => value is T; select: (response: T) => Settings; }): Promise { const capture: { metadata: RestResponseMetadata | null } = { metadata: null }; const response = await requestRestJson({ ...args, messages, onValidatedResponse: (metadata) => { capture.metadata = metadata; }, }); const metadata = capture.metadata as RestResponseMetadata | null; const revision = parseStrongSettingsEtag(metadata?.etag ?? null); if (!revision) { throw new RestClientError(messages.invalidResponse, { kind: "invalid-response", status: metadata?.status, }); } return { baseline: args.select(response), revision }; } export function isSettingsConflict(error: unknown): boolean { return ( error instanceof RestClientError && error.status === 409 && error.code === "b3_settings_conflict" ); } export async function loadSettings( restUrl: string, nonce: string, signal?: AbortSignal, ): Promise { let attempt = 0; while (true) { try { return await requestSettings({ endpoint: settingsEndpoint(restUrl, SETTINGS_ROUTE), nonce, signal, init: { method: "GET" }, validate: isLoadSettingsResponse, select: (response) => ("settings" in response ? response.settings : response) as Settings, }); } catch (error) { const retryDelay = SETTINGS_RETRY_DELAYS_MS[attempt]; if (retryDelay === undefined || !isTransientSettingsLoadError(error)) { throw error; } await waitForSettingsLoadRetry(retryDelay, signal); attempt += 1; } } } export async function saveSettings( restUrl: string, nonce: string, settings: Settings, revision: string, signal?: AbortSignal, ): Promise { const ifMatch = requireIfMatchHeader(revision); const endpoint = settingsEndpoint(restUrl, SETTINGS_ROUTE); const body = JSON.stringify(settings); const init: RequestInit = { method: "POST", headers: { "Content-Type": "application/json", "If-Match": ifMatch, }, body, }; let attempt = 0; while (true) { try { return await requestSettings({ endpoint, nonce, signal, init, validate: isSettingsMutationResponse, select: (response) => response.settings as Settings, }); } catch (error) { const retryDelay = SETTINGS_RETRY_DELAYS_MS[attempt]; if (retryDelay === undefined || !isTransientSettingsSaveError(error)) { throw error; } await waitForSettingsLoadRetry(retryDelay, signal); attempt += 1; } } } export async function resetSettings( restUrl: string, nonce: string, revision: string, signal?: AbortSignal, ): Promise { const ifMatch = requireIfMatchHeader(revision); const endpoint = settingsEndpoint(restUrl, SETTINGS_RESET_ROUTE); const init: RequestInit = { method: "POST", headers: { "If-Match": ifMatch }, }; let attempt = 0; while (true) { try { return await requestSettings({ endpoint, nonce, signal, init, validate: isSettingsMutationResponse, select: (response) => response.settings as Settings, }); } catch (error) { const retryDelay = SETTINGS_RETRY_DELAYS_MS[attempt]; if (retryDelay === undefined || !isTransientSettingsSaveError(error)) { throw error; } await waitForSettingsLoadRetry(retryDelay, signal); attempt += 1; } } }