Files
Genarrative/src/services/apiClient.ts
2026-04-10 15:37:02 +08:00

581 lines
14 KiB
TypeScript

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<number>;
retryUnsafeMethods: boolean;
allowRetryMethods: Set<string>;
method: string;
};
type ParsedApiErrorShape = {
code: string;
details: Record<string, unknown> | null;
meta: Partial<ApiMeta>;
};
type RefreshTokenResponse = {
token: string;
};
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}
function normalizeHeaders(headers?: HeadersInit) {
const nextHeaders: Record<string, string> = {};
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<ApiMeta> {
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<ApiMeta>;
code?: string;
details?: Record<string, unknown> | null;
}
| Record<string, unknown>;
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<string, unknown> | 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<string, unknown> | 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<void>((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<string, unknown> | null;
meta: ApiMeta;
responseText: string;
constructor(params: {
message: string;
status: number;
code: string;
details?: Record<string, unknown> | null;
meta?: Partial<ApiMeta>;
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<ApiRequestOptions, 'omitEnvelopeHeader' | 'skipAuth'> = {},
) {
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<string> | 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<RefreshTokenResponse>(
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<T>(
url: string,
init: RequestInit,
fallbackMessage: string,
options: ApiRequestOptions = {},
): Promise<T> {
const response = await fetchWithApiAuth(url, init, options);
if (!response.ok) {
throw await buildApiClientError(response, fallbackMessage);
}
const responseText = await response.text();
return responseText
? unwrapApiResponse<T>(JSON.parse(responseText) as T)
: (null as T);
}