import type { AuthRefreshResponse } from '../../packages/shared/src/contracts/auth'; 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'; 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; timeoutMs?: number; skipAuth?: boolean; omitEnvelopeHeader?: boolean; skipRefresh?: boolean; // 会话探测类请求需要静默处理 401,避免 AuthGate 因自发广播再次触发 hydrate。 notifyAuthStateChange?: 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; }; 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; } function createTimeoutError(timeoutMs: number) { const error = new Error(`请求超时:${timeoutMs}ms`); error.name = 'TimeoutError'; return error; } function composeAbortSignal( signal: AbortSignal | undefined, timeoutMs: number | undefined, ) { const shouldUseTimeout = typeof timeoutMs === 'number' && Number.isFinite(timeoutMs) && timeoutMs > 0; if (!shouldUseTimeout) { return { signal, cleanup: () => {}, }; } const controller = new AbortController(); const timeoutId = setTimeout(() => { controller.abort(createTimeoutError(timeoutMs)); }, timeoutMs); const cleanup = () => { clearTimeout(timeoutId); signal?.removeEventListener('abort', onAbort); }; const onAbort = () => { controller.abort(signal?.reason ?? createAbortError()); }; if (signal?.aborted) { cleanup(); controller.abort(signal.reason ?? createAbortError()); return { signal: controller.signal, cleanup, }; } signal?.addEventListener('abort', onAbort, { once: true }); return { signal: controller.signal, cleanup, }; } 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')) ); } export function isTimeoutError(error: unknown) { return error instanceof Error && error.name === 'TimeoutError'; } 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' ); } export 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(); const previousToken = getStoredAccessToken(); if (nextToken) { window.localStorage.setItem(ACCESS_TOKEN_KEY, nextToken); } else { window.localStorage.removeItem(ACCESS_TOKEN_KEY); } // 只有登录态令牌真的发生变化时才广播事件,避免无意义的鉴权重算。 if (options.emit !== false && previousToken !== nextToken) { emitAuthStateChange(); } } export function clearStoredAccessToken( options: { emit?: boolean; } = {}, ) { if (!canUseLocalStorage()) { return; } const previousToken = getStoredAccessToken(); window.localStorage.removeItem(ACCESS_TOKEN_KEY); // 未登录态下重复清空 token 不应触发状态刷新,否则会放大 401 循环。 if (options.emit !== false && previousToken) { 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({ emit: false }); throw await buildApiClientError(response, '刷新登录状态失败'); } const responseText = await response.text(); const payload = responseText ? unwrapApiResponse( JSON.parse(responseText) as AuthRefreshResponse, ) : null; if (payload?.ok !== true || !payload.token?.trim()) { clearStoredAccessToken({ emit: false }); throw new Error('刷新登录状态失败'); } const nextToken = payload.token.trim(); setStoredAccessToken(nextToken, { emit: false }); return nextToken; })(); try { return await refreshAccessTokenPromise; } finally { refreshAccessTokenPromise = null; } } export async function ensureStoredAccessToken() { const currentToken = getStoredAccessToken(); if (currentToken) { return currentToken; } // AuthGate 恢复会话时可能只有 HttpOnly refresh cookie,本地尚无 access token。 return refreshAccessToken(); } export async function fetchWithApiAuth( input: string, init: RequestInit = {}, options: ApiRequestOptions = {}, ) { const method = (init.method ?? 'GET').toUpperCase(); const retry = resolveRetryOptions(method, options.retry); const shouldNotifyAuthStateChange = options.notifyAuthStateChange !== false; const requestSignal = init.signal ?? undefined; let attempt = 0; let refreshAttempted = false; for (;;) { try { let requestHeaders = withAuthorizationHeaders(init.headers, options); let hasAuthHeader = Boolean( requestHeaders.Authorization?.trim() || requestHeaders.authorization?.trim(), ); if (!hasAuthHeader && !options.skipAuth && !options.skipRefresh) { try { // 受保护请求在本地 access token 缺失时,先尝试用 refresh cookie 静默补票, // 避免把后端原始 “缺少 Bearer Token” 直接暴露给业务 UI。 await ensureStoredAccessToken(); requestHeaders = withAuthorizationHeaders(init.headers, options); hasAuthHeader = Boolean( requestHeaders.Authorization?.trim() || requestHeaders.authorization?.trim(), ); } catch { // 补票失败时继续走原始请求,让调用方按真实 401 分支处理。 } } const timedRequest = composeAbortSignal(requestSignal, options.timeoutMs); let response: Response; try { response = await fetch(input, { credentials: 'same-origin', ...init, signal: timedRequest.signal, headers: requestHeaders, }); } finally { timedRequest.cleanup(); } if ( response.status === 401 && hasAuthHeader && !options.skipAuth && !options.skipRefresh && !refreshAttempted ) { try { await refreshAccessToken(); refreshAttempted = true; // refresh 成功只代表 access token 已补票成功, // 不能把当前业务请求的首次 401 直接放大成全局鉴权变更, // 否则像 Puzzle works 这类受保护列表会把单接口失败放大成整个平台重复 hydrate。 continue; } catch { if (hasAuthHeader) { clearStoredAccessToken({ emit: false }); } if (shouldNotifyAuthStateChange) { emitAuthStateChange(); } } } else if ( response.status === 401 && hasAuthHeader && !options.skipAuth && !refreshAttempted ) { clearStoredAccessToken({ emit: false }); if (shouldNotifyAuthStateChange) { emitAuthStateChange(); } } if (!shouldRetryResponse(response.status, attempt, retry)) { return response; } } catch (error) { if (!shouldRetryError(error, attempt, retry)) { throw error; } } attempt += 1; await waitForRetry(buildRetryDelayMs(attempt, retry), requestSignal); } } 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); }