1
This commit is contained in:
@@ -7,8 +7,8 @@ import {
|
||||
parseApiErrorMessage,
|
||||
unwrapApiResponse,
|
||||
} from '../../packages/shared/src/http';
|
||||
import type { AuthRefreshResponse } from '../../packages/shared/src/contracts/auth';
|
||||
|
||||
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';
|
||||
@@ -30,6 +30,8 @@ export type ApiRequestOptions = {
|
||||
skipAuth?: boolean;
|
||||
omitEnvelopeHeader?: boolean;
|
||||
skipRefresh?: boolean;
|
||||
// 会话探测类请求需要静默处理 401,避免 AuthGate 因自发广播再次触发 hydrate。
|
||||
notifyAuthStateChange?: boolean;
|
||||
};
|
||||
|
||||
type ResolvedRetryOptions = {
|
||||
@@ -48,10 +50,6 @@ type ParsedApiErrorShape = {
|
||||
meta: Partial<ApiMeta>;
|
||||
};
|
||||
|
||||
type RefreshTokenResponse = {
|
||||
token: string;
|
||||
};
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null;
|
||||
}
|
||||
@@ -311,11 +309,7 @@ export class ApiClientError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
function canUseLocalStorage() {
|
||||
return typeof window !== 'undefined' && typeof window.localStorage !== 'undefined';
|
||||
}
|
||||
|
||||
function emitAuthStateChange() {
|
||||
export function emitAuthStateChange() {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
@@ -330,72 +324,18 @@ function emitAuthStateChange() {
|
||||
}
|
||||
}
|
||||
|
||||
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(
|
||||
function withApiHeaders(
|
||||
headers?: HeadersInit,
|
||||
options: Pick<ApiRequestOptions, 'omitEnvelopeHeader' | 'skipAuth'> = {},
|
||||
options: Pick<ApiRequestOptions, 'omitEnvelopeHeader'> = {},
|
||||
) {
|
||||
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;
|
||||
let refreshAccessTokenPromise: Promise<void> | null = null;
|
||||
|
||||
async function refreshAccessToken() {
|
||||
if (refreshAccessTokenPromise) {
|
||||
@@ -412,24 +352,19 @@ async function refreshAccessToken() {
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
clearStoredAccessToken();
|
||||
throw await buildApiClientError(response, '刷新登录状态失败');
|
||||
}
|
||||
|
||||
const responseText = await response.text();
|
||||
const payload = responseText
|
||||
? unwrapApiResponse<RefreshTokenResponse>(
|
||||
JSON.parse(responseText) as RefreshTokenResponse,
|
||||
? unwrapApiResponse<AuthRefreshResponse>(
|
||||
JSON.parse(responseText) as AuthRefreshResponse,
|
||||
)
|
||||
: null;
|
||||
|
||||
if (!payload?.token?.trim()) {
|
||||
clearStoredAccessToken();
|
||||
if (payload?.ok !== true) {
|
||||
throw new Error('刷新登录状态失败');
|
||||
}
|
||||
|
||||
setStoredAccessToken(payload.token, { emit: false });
|
||||
return payload.token;
|
||||
})();
|
||||
|
||||
try {
|
||||
@@ -446,25 +381,20 @@ export async function fetchWithApiAuth(
|
||||
) {
|
||||
const method = (init.method ?? 'GET').toUpperCase();
|
||||
const retry = resolveRetryOptions(method, options.retry);
|
||||
const shouldNotifyAuthStateChange = options.notifyAuthStateChange !== false;
|
||||
let attempt = 0;
|
||||
let refreshAttempted = false;
|
||||
|
||||
for (;;) {
|
||||
try {
|
||||
const requestHeaders = withAuthorizationHeaders(init.headers, options);
|
||||
const hasAuthHeader = Boolean(
|
||||
requestHeaders.Authorization?.trim() ||
|
||||
requestHeaders.authorization?.trim(),
|
||||
);
|
||||
const response = await fetch(input, {
|
||||
credentials: 'same-origin',
|
||||
...init,
|
||||
headers: requestHeaders,
|
||||
headers: withApiHeaders(init.headers, options),
|
||||
});
|
||||
|
||||
if (
|
||||
response.status === 401 &&
|
||||
hasAuthHeader &&
|
||||
!options.skipAuth &&
|
||||
!options.skipRefresh &&
|
||||
!refreshAttempted
|
||||
@@ -472,12 +402,19 @@ export async function fetchWithApiAuth(
|
||||
try {
|
||||
await refreshAccessToken();
|
||||
refreshAttempted = true;
|
||||
if (shouldNotifyAuthStateChange) {
|
||||
emitAuthStateChange();
|
||||
}
|
||||
continue;
|
||||
} catch {
|
||||
clearStoredAccessToken();
|
||||
if (shouldNotifyAuthStateChange) {
|
||||
emitAuthStateChange();
|
||||
}
|
||||
}
|
||||
} else if (response.status === 401 && !options.skipAuth) {
|
||||
if (shouldNotifyAuthStateChange) {
|
||||
emitAuthStateChange();
|
||||
}
|
||||
} else if (response.status === 401) {
|
||||
clearStoredAccessToken();
|
||||
}
|
||||
|
||||
if (!shouldRetryResponse(response.status, attempt, retry)) {
|
||||
|
||||
Reference in New Issue
Block a user