| import { createObjectDecoder, hasShape, isString, type ApiDecoder } from "@/shared/api/decoder"; |
| import { runtimeConfig } from "@/shared/config/runtime-config"; |
| import { i18n } from "@/shared/i18n"; |
|
|
| export class ApiError extends Error { |
| readonly status: number; |
| readonly code: string; |
| readonly requestId?: string; |
|
|
| constructor(status: number, code: string, message: string, requestId?: string) { |
| super(message); |
| this.name = "ApiError"; |
| this.status = status; |
| this.code = code; |
| this.requestId = requestId; |
| } |
| } |
|
|
| let accessToken: string | null = null; |
| let refreshPromise: Promise<RefreshResult> | null = null; |
| const sessionInvalidatedListeners = new Set<() => void>(); |
| const refreshLockName = "grok2api:admin-session-refresh"; |
| const maxEventStreamBufferCharacters = 1 << 20; |
| const eventStreamInactivityTimeoutMs = 60_000; |
|
|
| export type RefreshResult = "refreshed" | "invalid" | "unavailable"; |
|
|
| export function setAccessToken(token: string | null): void { |
| accessToken = token; |
| } |
|
|
| export function subscribeSessionInvalidated(listener: () => void): () => void { |
| sessionInvalidatedListeners.add(listener); |
| return () => sessionInvalidatedListeners.delete(listener); |
| } |
|
|
| function invalidateSession(): void { |
| accessToken = null; |
| sessionInvalidatedListeners.forEach((listener) => listener()); |
| } |
|
|
| function localizedErrorMessage(code: string, fallback: string): string { |
| const key = `apiErrors.${code}`; |
| return i18n.exists(key) ? i18n.t(key) : fallback; |
| } |
|
|
| async function parseResponse<T>(response: Response, decode: ApiDecoder<T>): Promise<T> { |
| const payload: unknown = await response.json().catch(() => null); |
| if (!response.ok) { |
| const error = readErrorEnvelope(payload); |
| const code = error.code ?? "requestFailed"; |
| throw new ApiError(response.status, code, localizedErrorMessage(code, error.message ?? `HTTP ${response.status}`), error.requestId); |
| } |
| if (!isRecord(payload) || !("data" in payload)) { |
| throw new ApiError(response.status, "invalidResponse", localizedErrorMessage("invalidResponse", "Server returned an invalid response")); |
| } |
| try { |
| return decode(payload.data); |
| } catch { |
| throw new ApiError(response.status, "invalidResponse", localizedErrorMessage("invalidResponse", "Server returned an invalid response")); |
| } |
| } |
|
|
| function isRecord(value: unknown): value is Record<string, unknown> { |
| return typeof value === "object" && value !== null && !Array.isArray(value); |
| } |
|
|
| function readErrorEnvelope(payload: unknown): { code?: string; message?: string; requestId?: string } { |
| if (!isRecord(payload) || !isRecord(payload.error)) return {}; |
| return { |
| code: typeof payload.error.code === "string" ? payload.error.code : undefined, |
| message: typeof payload.error.message === "string" ? payload.error.message : undefined, |
| requestId: typeof payload.error.requestId === "string" ? payload.error.requestId : undefined, |
| }; |
| } |
|
|
| async function requestRefresh(): Promise<RefreshResult> { |
| try { |
| const response = await fetch(`${runtimeConfig.apiBaseUrl}/api/admin/v1/auth/refresh`, { |
| method: "POST", |
| credentials: "include", |
| headers: { "Content-Type": "application/json" }, |
| body: "{}", |
| }); |
| if (response.status === 401) { |
| invalidateSession(); |
| return "invalid"; |
| } |
| const tokens = await parseResponse(response, decodeAuthTokensDTO); |
| setAccessToken(tokens.accessToken); |
| return "refreshed"; |
| } catch { |
| return "unavailable"; |
| } |
| } |
|
|
| async function requestRefreshWithBrowserLock(): Promise<RefreshResult> { |
| if (!("locks" in navigator)) { |
| return requestRefresh(); |
| } |
| try { |
| return await navigator.locks.request(refreshLockName, requestRefresh); |
| } catch { |
| return "unavailable"; |
| } |
| } |
|
|
| export async function refreshAccessToken(): Promise<RefreshResult> { |
| if (!refreshPromise) { |
| refreshPromise = requestRefreshWithBrowserLock() |
| .finally(() => { |
| refreshPromise = null; |
| }); |
| } |
| return refreshPromise; |
| } |
|
|
| type RequestOptions = Omit<RequestInit, "body"> & { |
| body?: BodyInit | object; |
| authenticated?: boolean; |
| retryAuth?: boolean; |
| }; |
|
|
| async function sendApiRequest(path: string, options: RequestOptions): Promise<Response> { |
| const { authenticated = true, retryAuth, body, headers, ...requestInit } = options; |
| void retryAuth; |
| const requestHeaders = new Headers(headers); |
| let requestBody: BodyInit | undefined; |
|
|
| if (body instanceof FormData || typeof body === "string" || body instanceof Blob) { |
| requestBody = body; |
| } else if (body !== undefined) { |
| requestHeaders.set("Content-Type", "application/json"); |
| requestBody = JSON.stringify(body); |
| } |
| if (authenticated && accessToken) { |
| requestHeaders.set("Authorization", `Bearer ${accessToken}`); |
| } |
|
|
| return fetch(`${runtimeConfig.apiBaseUrl}${path}`, { |
| ...requestInit, |
| body: requestBody, |
| credentials: "include", |
| headers: requestHeaders, |
| }); |
| } |
|
|
| export async function apiRequest<T>(path: string, options: RequestOptions, decode: ApiDecoder<T>): Promise<T> { |
| const { authenticated = true, retryAuth = true } = options; |
| const response = await sendApiRequest(path, options); |
|
|
| if (response.status === 401 && authenticated && retryAuth) { |
| const refreshResult = await refreshAccessToken(); |
| if (refreshResult === "refreshed") { |
| return apiRequest<T>(path, { ...options, retryAuth: false }, decode); |
| } |
| if (refreshResult === "unavailable") { |
| throw new ApiError(503, "sessionRefreshUnavailable", localizedErrorMessage("sessionRefreshUnavailable", "Unable to refresh the session. Please retry.")); |
| } |
| } |
|
|
| return parseResponse(response, decode); |
| } |
|
|
| export type ApiStreamEvent<T> = { |
| event: string; |
| data: T; |
| }; |
|
|
| |
| export async function apiEventStream<T>(path: string, options: RequestOptions, decode: ApiDecoder<T>, onEvent: (value: ApiStreamEvent<T>) => void): Promise<void> { |
| const { authenticated = true, retryAuth = true } = options; |
| const response = await sendApiRequest(path, options); |
| if (response.status === 401 && authenticated && retryAuth) { |
| const refreshResult = await refreshAccessToken(); |
| if (refreshResult === "refreshed") { |
| return apiEventStream(path, { ...options, retryAuth: false }, decode, onEvent); |
| } |
| if (refreshResult === "unavailable") { |
| throw new ApiError(503, "sessionRefreshUnavailable", localizedErrorMessage("sessionRefreshUnavailable", "Unable to refresh the session. Please retry.")); |
| } |
| } |
| if (!response.ok) { |
| await parseResponse(response, decodeNever); |
| } |
| if (!response.body) { |
| throw new ApiError(response.status, "invalidResponse", localizedErrorMessage("invalidResponse", "Server returned an invalid response")); |
| } |
| const contentType = response.headers.get("Content-Type")?.toLowerCase() ?? ""; |
| if (!contentType.startsWith("text/event-stream")) { |
| await response.body.cancel().catch(() => undefined); |
| throw new ApiError(response.status, "invalidResponse", localizedErrorMessage("invalidResponse", "Server returned an invalid response")); |
| } |
|
|
| const reader = response.body.getReader(); |
| const decoder = new TextDecoder(); |
| let buffer = ""; |
| const dispatch = (block: string) => { |
| let event = "message"; |
| const data: string[] = []; |
| block.split("\n").forEach((line) => { |
| const normalized = line.endsWith("\r") ? line.slice(0, -1) : line; |
| if (normalized.startsWith("event:")) event = normalized.slice(6).trim(); |
| if (normalized.startsWith("data:")) data.push(normalized.slice(5).trimStart()); |
| }); |
| if (data.length === 0) return; |
| let payload: T; |
| try { |
| payload = decode(JSON.parse(data.join("\n")) as unknown); |
| } catch { |
| throw new ApiError(response.status, "invalidResponse", localizedErrorMessage("invalidResponse", "Server returned an invalid response")); |
| } |
| onEvent({ event, data: payload }); |
| }; |
|
|
| try { |
| for (;;) { |
| const { done, value } = await readEventStreamChunk(reader, response.status); |
| buffer += decoder.decode(value, { stream: !done }); |
| buffer = buffer.replaceAll("\r\n", "\n"); |
| let boundary = buffer.indexOf("\n\n"); |
| while (boundary >= 0) { |
| if (boundary > maxEventStreamBufferCharacters) { |
| throw new ApiError(response.status, "invalidResponse", localizedErrorMessage("invalidResponse", "Server returned an invalid response")); |
| } |
| dispatch(buffer.slice(0, boundary)); |
| buffer = buffer.slice(boundary + 2); |
| boundary = buffer.indexOf("\n\n"); |
| } |
| if (buffer.length > maxEventStreamBufferCharacters) { |
| throw new ApiError(response.status, "invalidResponse", localizedErrorMessage("invalidResponse", "Server returned an invalid response")); |
| } |
| if (done) break; |
| } |
| if (buffer.trim()) dispatch(buffer); |
| } catch (error) { |
| await reader.cancel().catch(() => undefined); |
| throw error; |
| } finally { |
| reader.releaseLock(); |
| } |
| } |
|
|
| async function readEventStreamChunk(reader: ReadableStreamDefaultReader<Uint8Array>, status: number): Promise<ReadableStreamReadResult<Uint8Array>> { |
| let timeout = 0; |
| const inactivity = new Promise<never>((_, reject) => { |
| timeout = window.setTimeout(() => { |
| reject(new ApiError(status, "streamTimeout", localizedErrorMessage("streamTimeout", "The progress stream stopped responding"))); |
| }, eventStreamInactivityTimeoutMs); |
| }); |
| try { |
| return await Promise.race([reader.read(), inactivity]); |
| } finally { |
| window.clearTimeout(timeout); |
| } |
| } |
|
|
| export type ApiDownloadResult = { |
| blob: Blob; |
| headers: Headers; |
| }; |
|
|
| export async function apiDownloadResponse(path: string, options: RequestOptions = {}): Promise<ApiDownloadResult> { |
| const { authenticated = true, retryAuth = true } = options; |
| const response = await sendApiRequest(path, options); |
| if (response.status === 401 && authenticated && retryAuth) { |
| const refreshResult = await refreshAccessToken(); |
| if (refreshResult === "refreshed") return apiDownloadResponse(path, { ...options, retryAuth: false }); |
| if (refreshResult === "unavailable") { |
| throw new ApiError(503, "sessionRefreshUnavailable", localizedErrorMessage("sessionRefreshUnavailable", "Unable to refresh the session. Please retry.")); |
| } |
| } |
| if (!response.ok) { |
| await parseResponse(response, decodeNever); |
| throw new ApiError(response.status, "requestFailed", localizedErrorMessage("requestFailed", "The request failed")); |
| } |
| return { blob: await response.blob(), headers: response.headers }; |
| } |
|
|
| export async function apiDownload(path: string, options: RequestOptions = {}): Promise<Blob> { |
| return (await apiDownloadResponse(path, options)).blob; |
| } |
|
|
| export type AdminDTO = { |
| id: string; |
| username: string; |
| }; |
|
|
| export type AuthTokensDTO = { |
| accessToken: string; |
| accessTokenExpiresAt: string; |
| refreshTokenExpiresAt: string; |
| }; |
|
|
| export type LoginResponseDTO = { |
| admin: AdminDTO; |
| tokens: AuthTokensDTO; |
| }; |
|
|
| const adminValidator = hasShape({ id: isString, username: isString }); |
| const authTokensValidator = hasShape({ accessToken: isString, accessTokenExpiresAt: isString, refreshTokenExpiresAt: isString }); |
|
|
| export const decodeAdminDTO = createObjectDecoder<AdminDTO>("admin", { id: isString, username: isString }); |
| export const decodeAuthTokensDTO = createObjectDecoder<AuthTokensDTO>("auth tokens", { |
| accessToken: isString, |
| accessTokenExpiresAt: isString, |
| refreshTokenExpiresAt: isString, |
| }); |
| export const decodeLoginResponseDTO = createObjectDecoder<LoginResponseDTO>("login", { admin: adminValidator, tokens: authTokensValidator }); |
| export const decodeLoggedOut = createObjectDecoder<{ loggedOut: boolean }>("logout", { loggedOut: (value) => typeof value === "boolean" }); |
|
|
| function decodeNever(): never { |
| throw new Error("unexpected successful response"); |
| } |
|
|
| export type PaginatedDTO<T> = { |
| items: T[]; |
| page: number; |
| pageSize: number; |
| total: number; |
| }; |
|
|