import { API_RESPONSE_ENVELOPE_HEADER, API_RESPONSE_ENVELOPE_VERSION, API_VERSION, type ApiErrorPayload, type ApiMeta, parseApiErrorMessage, unwrapApiResponse, } from '../../packages/shared/src/http'; const ACCESS_TOKEN_KEY = 'genarrative.auth.access-token.v1'; const AUTO_AUTH_USERNAME_KEY = 'genarrative.auth.auto-username.v1'; const AUTO_AUTH_PASSWORD_KEY = 'genarrative.auth.auto-password.v1'; export const AUTH_STATE_EVENT = 'genarrative-auth-state-changed'; const REQUEST_ID_HEADER = 'x-request-id'; const API_VERSION_HEADER = 'x-api-version'; const ROUTE_VERSION_HEADER = 'x-route-version'; const DEFAULT_RETRYABLE_STATUS_CODES = [408, 425, 429, 502, 503, 504]; const DEFAULT_SAFE_RETRY_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']); export type ApiRetryOptions = { maxRetries?: number; baseDelayMs?: number; maxDelayMs?: number; retryableStatusCodes?: number[]; retryUnsafeMethods?: boolean; allowRetryMethods?: string[]; }; export type ApiRequestOptions = { retry?: ApiRetryOptions; skipAuth?: boolean; omitEnvelopeHeader?: boolean; skipRefresh?: boolean; }; type ResolvedRetryOptions = { maxRetries: number; baseDelayMs: number; maxDelayMs: number; retryableStatusCodes: Set; retryUnsafeMethods: boolean; allowRetryMethods: Set; method: string; }; type ParsedApiErrorShape = { code: string; details: Record | null; meta: Partial; }; type RefreshTokenResponse = { token: string; }; function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null; } function normalizeHeaders(headers?: HeadersInit) { const nextHeaders: Record = {}; if (typeof Headers !== 'undefined' && headers instanceof Headers) { headers.forEach((value, key) => { nextHeaders[key] = value; }); return nextHeaders; } if (Array.isArray(headers)) { for (const [key, value] of headers) { nextHeaders[key] = value; } return nextHeaders; } if (headers) { Object.assign(nextHeaders, headers); } return nextHeaders; } function coerceMeta(value: unknown): Partial { if (!isRecord(value)) { return {}; } return { apiVersion: typeof value.apiVersion === 'string' && value.apiVersion.trim() ? value.apiVersion.trim() : undefined, requestId: typeof value.requestId === 'string' && value.requestId.trim() ? value.requestId.trim() : undefined, routeVersion: typeof value.routeVersion === 'string' && value.routeVersion.trim() ? value.routeVersion.trim() : undefined, operation: typeof value.operation === 'string' && value.operation.trim() ? value.operation.trim() : value.operation === null ? null : undefined, latencyMs: typeof value.latencyMs === 'number' && Number.isFinite(value.latencyMs) ? value.latencyMs : undefined, timestamp: typeof value.timestamp === 'string' && value.timestamp.trim() ? value.timestamp.trim() : undefined, }; } function parseApiErrorShape(rawText: string): ParsedApiErrorShape | null { if (!rawText.trim()) { return null; } try { const parsed = JSON.parse(rawText) as | { error?: ApiErrorPayload; meta?: Partial; code?: string; details?: Record | null; } | Record; if (isRecord(parsed.error)) { return { code: typeof parsed.error.code === 'string' && parsed.error.code.trim() ? parsed.error.code.trim() : 'HTTP_ERROR', details: isRecord(parsed.error.details) || parsed.error.details === null ? (parsed.error.details as Record | null) : null, meta: coerceMeta(parsed.meta), }; } if (typeof parsed.code === 'string' && parsed.code.trim()) { return { code: parsed.code.trim(), details: isRecord(parsed.details) || parsed.details === null ? (parsed.details as Record | null) : null, meta: coerceMeta(parsed.meta), }; } } catch { // Ignore malformed json responses. } return null; } function createAbortError() { if (typeof DOMException !== 'undefined') { return new DOMException('The operation was aborted.', 'AbortError'); } const error = new Error('The operation was aborted.'); error.name = 'AbortError'; return error; } async function waitForRetry(ms: number, signal?: AbortSignal) { if (ms <= 0) { return; } await new Promise((resolve, reject) => { const timeoutId = setTimeout(() => { cleanup(); resolve(); }, ms); const onAbort = () => { cleanup(); reject(signal?.reason ?? createAbortError()); }; const cleanup = () => { clearTimeout(timeoutId); signal?.removeEventListener('abort', onAbort); }; if (signal?.aborted) { cleanup(); reject(signal.reason ?? createAbortError()); return; } signal?.addEventListener('abort', onAbort, { once: true }); }); } function resolveRetryOptions( method: string, retry?: ApiRetryOptions, ): ResolvedRetryOptions { const normalizedMethod = method.toUpperCase(); const defaultMaxRetries = DEFAULT_SAFE_RETRY_METHODS.has(normalizedMethod) ? 1 : 0; return { maxRetries: typeof retry?.maxRetries === 'number' && retry.maxRetries >= 0 ? Math.floor(retry.maxRetries) : defaultMaxRetries, baseDelayMs: typeof retry?.baseDelayMs === 'number' && retry.baseDelayMs > 0 ? retry.baseDelayMs : 250, maxDelayMs: typeof retry?.maxDelayMs === 'number' && retry.maxDelayMs > 0 ? retry.maxDelayMs : 1500, retryableStatusCodes: new Set( retry?.retryableStatusCodes?.length ? retry.retryableStatusCodes : DEFAULT_RETRYABLE_STATUS_CODES, ), retryUnsafeMethods: retry?.retryUnsafeMethods === true, allowRetryMethods: new Set( (retry?.allowRetryMethods ?? []).map((value) => value.toUpperCase()), ), method: normalizedMethod, }; } function shouldRetryResponse( status: number, attempt: number, retry: ResolvedRetryOptions, ) { if (attempt >= retry.maxRetries) { return false; } if (!retry.retryableStatusCodes.has(status)) { return false; } return ( retry.retryUnsafeMethods || DEFAULT_SAFE_RETRY_METHODS.has(retry.method) || retry.allowRetryMethods.has(retry.method) ); } export function isAbortError(error: unknown) { return ( error instanceof Error && (error.name === 'AbortError' || (typeof DOMException !== 'undefined' && error instanceof DOMException && error.name === 'AbortError')) ); } function shouldRetryError(error: unknown, attempt: number, retry: ResolvedRetryOptions) { if (attempt >= retry.maxRetries || isAbortError(error)) { return false; } return error instanceof TypeError; } function buildRetryDelayMs(attempt: number, retry: ResolvedRetryOptions) { return Math.min(retry.maxDelayMs, retry.baseDelayMs * Math.max(1, attempt)); } export class ApiClientError extends Error { status: number; code: string; details: Record | null; meta: ApiMeta; responseText: string; constructor(params: { message: string; status: number; code: string; details?: Record | null; meta?: Partial; responseText?: string; }) { super(params.message); this.name = 'ApiClientError'; this.status = params.status; this.code = params.code; this.details = params.details ?? null; this.meta = { apiVersion: params.meta?.apiVersion ?? API_VERSION, requestId: params.meta?.requestId, routeVersion: params.meta?.routeVersion, operation: params.meta?.operation, latencyMs: params.meta?.latencyMs, timestamp: params.meta?.timestamp, }; this.responseText = params.responseText ?? ''; } } function canUseLocalStorage() { return typeof window !== 'undefined' && typeof window.localStorage !== 'undefined'; } function emitAuthStateChange() { if (typeof window === 'undefined') { return; } if (typeof CustomEvent === 'function') { window.dispatchEvent(new CustomEvent(AUTH_STATE_EVENT)); return; } if (typeof Event === 'function') { window.dispatchEvent(new Event(AUTH_STATE_EVENT)); } } export function getStoredAccessToken() { if (!canUseLocalStorage()) { return ''; } return window.localStorage.getItem(ACCESS_TOKEN_KEY)?.trim() || ''; } export function setStoredAccessToken( token: string, options: { emit?: boolean; } = {}, ) { if (!canUseLocalStorage()) { return; } const nextToken = token.trim(); if (nextToken) { window.localStorage.setItem(ACCESS_TOKEN_KEY, nextToken); } else { window.localStorage.removeItem(ACCESS_TOKEN_KEY); } if (options.emit !== false) { emitAuthStateChange(); } } export function clearStoredAccessToken( options: { emit?: boolean; } = {}, ) { if (!canUseLocalStorage()) { return; } window.localStorage.removeItem(ACCESS_TOKEN_KEY); if (options.emit !== false) { emitAuthStateChange(); } } export function getStoredAutoAuthCredentials() { if (!canUseLocalStorage()) { return null; } const username = window.localStorage.getItem(AUTO_AUTH_USERNAME_KEY)?.trim() || ''; const password = window.localStorage.getItem(AUTO_AUTH_PASSWORD_KEY)?.trim() || ''; if (!username || !password) { return null; } return { username, password, }; } export function setStoredAutoAuthCredentials(credentials: { username: string; password: string; }) { if (!canUseLocalStorage()) { return; } window.localStorage.setItem(AUTO_AUTH_USERNAME_KEY, credentials.username.trim()); window.localStorage.setItem(AUTO_AUTH_PASSWORD_KEY, credentials.password.trim()); } export function clearStoredAutoAuthCredentials() { if (!canUseLocalStorage()) { return; } window.localStorage.removeItem(AUTO_AUTH_USERNAME_KEY); window.localStorage.removeItem(AUTO_AUTH_PASSWORD_KEY); emitAuthStateChange(); } function withAuthorizationHeaders( headers?: HeadersInit, options: Pick = {}, ) { const nextHeaders = normalizeHeaders(headers); const token = getStoredAccessToken(); if (token && !options.skipAuth) { nextHeaders.Authorization = `Bearer ${token}`; } if (!options.omitEnvelopeHeader) { nextHeaders[API_RESPONSE_ENVELOPE_HEADER] = API_RESPONSE_ENVELOPE_VERSION; } return nextHeaders; } let refreshAccessTokenPromise: Promise | null = null; async function refreshAccessToken() { if (refreshAccessTokenPromise) { return refreshAccessTokenPromise; } refreshAccessTokenPromise = (async () => { const response = await fetch('/api/auth/refresh', { method: 'POST', credentials: 'same-origin', headers: { [API_RESPONSE_ENVELOPE_HEADER]: API_RESPONSE_ENVELOPE_VERSION, }, }); if (!response.ok) { clearStoredAccessToken(); throw await buildApiClientError(response, '刷新登录状态失败'); } const responseText = await response.text(); const payload = responseText ? unwrapApiResponse( JSON.parse(responseText) as RefreshTokenResponse, ) : null; if (!payload?.token?.trim()) { clearStoredAccessToken(); throw new Error('刷新登录状态失败'); } setStoredAccessToken(payload.token, { emit: false }); return payload.token; })(); try { return await refreshAccessTokenPromise; } finally { refreshAccessTokenPromise = null; } } export async function fetchWithApiAuth( input: string, init: RequestInit = {}, options: ApiRequestOptions = {}, ) { const method = (init.method ?? 'GET').toUpperCase(); const retry = resolveRetryOptions(method, options.retry); let attempt = 0; let refreshAttempted = false; for (;;) { try { const response = await fetch(input, { credentials: 'same-origin', ...init, headers: withAuthorizationHeaders(init.headers, options), }); if ( response.status === 401 && !options.skipAuth && !options.skipRefresh && !refreshAttempted ) { try { await refreshAccessToken(); refreshAttempted = true; continue; } catch { clearStoredAccessToken(); } } else if (response.status === 401) { clearStoredAccessToken(); } if (!shouldRetryResponse(response.status, attempt, retry)) { return response; } } catch (error) { if (!shouldRetryError(error, attempt, retry)) { throw error; } } attempt += 1; await waitForRetry( buildRetryDelayMs(attempt, retry), init.signal ?? undefined, ); } } async function buildApiClientError( response: Response, fallbackMessage: string, ) { const responseText = await response.text(); const parsedError = parseApiErrorShape(responseText); return new ApiClientError({ message: parseApiErrorMessage(responseText, fallbackMessage), status: response.status, code: parsedError?.code ?? `HTTP_${response.status || 0}`, details: parsedError?.details ?? null, meta: { apiVersion: parsedError?.meta.apiVersion ?? response.headers.get(API_VERSION_HEADER) ?? API_VERSION, requestId: parsedError?.meta.requestId ?? response.headers.get(REQUEST_ID_HEADER) ?? undefined, routeVersion: parsedError?.meta.routeVersion ?? response.headers.get(ROUTE_VERSION_HEADER) ?? undefined, operation: parsedError?.meta.operation, latencyMs: parsedError?.meta.latencyMs, timestamp: parsedError?.meta.timestamp, }, responseText, }); } export async function requestJson( url: string, init: RequestInit, fallbackMessage: string, options: ApiRequestOptions = {}, ): Promise { const response = await fetchWithApiAuth(url, init, options); if (!response.ok) { throw await buildApiClientError(response, fallbackMessage); } const responseText = await response.text(); return responseText ? unwrapApiResponse(JSON.parse(responseText) as T) : (null as T); }