581 lines
14 KiB
TypeScript
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);
|
|
}
|