1
This commit is contained in:
@@ -1,9 +1,317 @@
|
||||
import { parseApiErrorMessage } from '../editor/shared/jsonClient';
|
||||
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';
|
||||
@@ -14,7 +322,14 @@ function emitAuthStateChange() {
|
||||
return;
|
||||
}
|
||||
|
||||
window.dispatchEvent(new CustomEvent(AUTH_STATE_EVENT));
|
||||
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() {
|
||||
@@ -25,7 +340,12 @@ export function getStoredAccessToken() {
|
||||
return window.localStorage.getItem(ACCESS_TOKEN_KEY)?.trim() || '';
|
||||
}
|
||||
|
||||
export function setStoredAccessToken(token: string) {
|
||||
export function setStoredAccessToken(
|
||||
token: string,
|
||||
options: {
|
||||
emit?: boolean;
|
||||
} = {},
|
||||
) {
|
||||
if (!canUseLocalStorage()) {
|
||||
return;
|
||||
}
|
||||
@@ -36,16 +356,24 @@ export function setStoredAccessToken(token: string) {
|
||||
} else {
|
||||
window.localStorage.removeItem(ACCESS_TOKEN_KEY);
|
||||
}
|
||||
emitAuthStateChange();
|
||||
if (options.emit !== false) {
|
||||
emitAuthStateChange();
|
||||
}
|
||||
}
|
||||
|
||||
export function clearStoredAccessToken() {
|
||||
export function clearStoredAccessToken(
|
||||
options: {
|
||||
emit?: boolean;
|
||||
} = {},
|
||||
) {
|
||||
if (!canUseLocalStorage()) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.localStorage.removeItem(ACCESS_TOKEN_KEY);
|
||||
emitAuthStateChange();
|
||||
if (options.emit !== false) {
|
||||
emitAuthStateChange();
|
||||
}
|
||||
}
|
||||
|
||||
export function getStoredAutoAuthCredentials() {
|
||||
@@ -88,56 +416,165 @@ export function clearStoredAutoAuthCredentials() {
|
||||
emitAuthStateChange();
|
||||
}
|
||||
|
||||
function withAuthorizationHeaders(headers?: HeadersInit) {
|
||||
const nextHeaders: Record<string, string> = {};
|
||||
|
||||
if (headers instanceof Headers) {
|
||||
headers.forEach((value, key) => {
|
||||
nextHeaders[key] = value;
|
||||
});
|
||||
} else if (Array.isArray(headers)) {
|
||||
for (const [key, value] of headers) {
|
||||
nextHeaders[key] = value;
|
||||
}
|
||||
} else if (headers) {
|
||||
Object.assign(nextHeaders, headers);
|
||||
}
|
||||
|
||||
function withAuthorizationHeaders(
|
||||
headers?: HeadersInit,
|
||||
options: Pick<ApiRequestOptions, 'omitEnvelopeHeader' | 'skipAuth'> = {},
|
||||
) {
|
||||
const nextHeaders = normalizeHeaders(headers);
|
||||
const token = getStoredAccessToken();
|
||||
if (token) {
|
||||
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 response = await fetch(input, {
|
||||
credentials: 'same-origin',
|
||||
...init,
|
||||
headers: withAuthorizationHeaders(init.headers),
|
||||
});
|
||||
const method = (init.method ?? 'GET').toUpperCase();
|
||||
const retry = resolveRetryOptions(method, options.retry);
|
||||
let attempt = 0;
|
||||
let refreshAttempted = false;
|
||||
|
||||
if (response.status === 401) {
|
||||
clearStoredAccessToken();
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
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);
|
||||
const responseText = await response.text();
|
||||
const response = await fetchWithApiAuth(url, init, options);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(parseApiErrorMessage(responseText, fallbackMessage));
|
||||
throw await buildApiClientError(response, fallbackMessage);
|
||||
}
|
||||
|
||||
return responseText ? (JSON.parse(responseText) as T) : (null as T);
|
||||
const responseText = await response.text();
|
||||
|
||||
return responseText
|
||||
? unwrapApiResponse<T>(JSON.parse(responseText) as T)
|
||||
: (null as T);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user