361 lines
14 KiB
TypeScript
361 lines
14 KiB
TypeScript
/**
|
||
* هستهی ارتباط با بکاند. فقط fetch استاندارد، بدونِ پکیج.
|
||
*
|
||
* هر چیزی که اینجاست برای یک حالتِ خراب است، نه حالتِ خوب:
|
||
* • timeout — بکاندِ کند نباید سوکتها را تا ابد باز نگه دارد.
|
||
* • تکرارِ خودکار فقط برای GET، با backoff و jitter؛ تکرارِ POST یعنی ثبتِ دوباره.
|
||
* • refreshِ تکنسخهای — ده درخواستِ همزمان با ۴۰۱ یک refresh میزنند، نه ده تا.
|
||
* • ادغامِ GETهای همزمانِ یکسان.
|
||
* • وقتی نشست بهکل باطل شد، onSignedOut خبر میدهد تا اپ به صفحهی ورود برگردد.
|
||
*/
|
||
|
||
export interface Tokens {
|
||
token: string;
|
||
refreshToken: string;
|
||
}
|
||
|
||
export class ApiError extends Error {
|
||
retryAfterMs?: number;
|
||
|
||
constructor(
|
||
readonly status: number,
|
||
readonly detail: string,
|
||
readonly body: Record<string, unknown> = {},
|
||
) {
|
||
super(detail);
|
||
this.name = 'ApiError';
|
||
}
|
||
|
||
/** کدِ ماشینیِ خطا، اگر سرور داده باشد (مثلاً "no_hearts"). */
|
||
get code(): string | undefined {
|
||
return typeof this.body.code === 'string' ? this.body.code : undefined;
|
||
}
|
||
get isNetwork() { return this.status === 0; }
|
||
get isUnauthorized() { return this.status === 401; }
|
||
get isRateLimited() { return this.status === 429; }
|
||
get isProviderDown() { return this.status === 503; }
|
||
}
|
||
|
||
export interface TokenStorage {
|
||
get(): Tokens | null;
|
||
set(tokens: Tokens | null): void;
|
||
}
|
||
|
||
const STORAGE_KEY = 'tadrisino.tokens';
|
||
|
||
/** localStorage در حالتِ خصوصیِ بعضی مرورگرها throw میکند؛ اپ نباید بخوابد. */
|
||
export const localTokenStorage: TokenStorage = {
|
||
get() {
|
||
try {
|
||
const raw = localStorage.getItem(STORAGE_KEY);
|
||
return raw ? (JSON.parse(raw) as Tokens) : null;
|
||
} catch {
|
||
return null;
|
||
}
|
||
},
|
||
set(tokens) {
|
||
try {
|
||
if (tokens) localStorage.setItem(STORAGE_KEY, JSON.stringify(tokens));
|
||
else localStorage.removeItem(STORAGE_KEY);
|
||
} catch {
|
||
/* ذخیره نشد؛ نشست فقط تا بستنِ صفحه میماند */
|
||
}
|
||
},
|
||
};
|
||
|
||
export interface RequestOptions {
|
||
timeoutMs?: number;
|
||
signal?: AbortSignal;
|
||
/** POST را هم تکرارپذیر میکند؛ فقط همراهِ idempotencyKey معنا دارد. */
|
||
retry?: boolean;
|
||
idempotencyKey?: string;
|
||
}
|
||
|
||
export interface HttpClientOptions {
|
||
baseUrl: string;
|
||
storage?: TokenStorage;
|
||
timeoutMs?: number;
|
||
maxRetries?: number;
|
||
}
|
||
|
||
const RETRIABLE_STATUS = new Set([408, 425, 429, 500, 502, 503, 504]);
|
||
|
||
function backoffMs(attempt: number, retryAfterMs?: number): number {
|
||
// Retry-After کمینه است، نه زمانِ دقیق: وقتی سرور زیرِ بار هزاران گوشی را همزمان رد میکند، اگر همه
|
||
// دقیقاً بعد از همان چند ثانیه برگردند همان اوج دوباره ساخته میشود. پس بینِ ۱ تا ۲ برابرِ آن پخش میشوند.
|
||
if (retryAfterMs != null) return Math.min(retryAfterMs * (1 + Math.random()), 30_000);
|
||
return Math.random() * Math.min(1000 * 2 ** attempt, 8000);
|
||
}
|
||
|
||
function parseRetryAfter(header: string | null): number | undefined {
|
||
if (!header) return undefined;
|
||
const seconds = Number(header);
|
||
if (Number.isFinite(seconds)) return seconds * 1000;
|
||
const at = Date.parse(header);
|
||
return Number.isNaN(at) ? undefined : Math.max(0, at - Date.now());
|
||
}
|
||
|
||
const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
|
||
|
||
export function newIdempotencyKey(): string {
|
||
const c = globalThis.crypto;
|
||
if (c && 'randomUUID' in c) return c.randomUUID();
|
||
return `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||
}
|
||
|
||
/** AbortSignal.any هنوز همهجا نیست؛ جایگزینِ کوچکش. */
|
||
function anySignal(signals: AbortSignal[]): AbortSignal {
|
||
const native = (AbortSignal as { any?: (s: AbortSignal[]) => AbortSignal }).any;
|
||
if (native) return native(signals);
|
||
const controller = new AbortController();
|
||
for (const s of signals) {
|
||
if (s.aborted) {
|
||
controller.abort(s.reason);
|
||
break;
|
||
}
|
||
s.addEventListener('abort', () => controller.abort(s.reason), { once: true });
|
||
}
|
||
return controller.signal;
|
||
}
|
||
|
||
function timeoutSignal(ms: number): AbortSignal {
|
||
if ('timeout' in AbortSignal) return AbortSignal.timeout(ms);
|
||
const controller = new AbortController();
|
||
setTimeout(() => controller.abort(), ms);
|
||
return controller.signal;
|
||
}
|
||
|
||
async function parseBody(response: Response): Promise<Record<string, unknown>> {
|
||
const text = await response.text();
|
||
if (!text) return {};
|
||
try {
|
||
const parsed: unknown = JSON.parse(text);
|
||
return parsed && typeof parsed === 'object' ? (parsed as Record<string, unknown>) : {};
|
||
} catch {
|
||
// HTML بهجای JSON: معمولاً ۵۰۲ِ nginx، یا آدرسِ API اشتباه است و
|
||
// سرورِ فرانت index.html را برگردانده.
|
||
return {};
|
||
}
|
||
}
|
||
|
||
function errorFrom(response: Response, body: Record<string, unknown>): ApiError {
|
||
const detail =
|
||
(typeof body.detail === 'string' && body.detail) ||
|
||
(typeof body.error === 'string' && body.error) ||
|
||
firstFieldError(body) ||
|
||
(response.status >= 500 ? 'سرور موقتاً در دسترس نیست' : `خطای ${response.status}`);
|
||
const error = new ApiError(response.status, detail, body);
|
||
error.retryAfterMs = parseRetryAfter(response.headers.get('Retry-After'));
|
||
return error;
|
||
}
|
||
|
||
/** خطای اعتبارسنجیِ DRF به شکلِ {field: ["پیام"]} است. */
|
||
function firstFieldError(body: Record<string, unknown>): string | undefined {
|
||
for (const value of Object.values(body)) {
|
||
if (Array.isArray(value) && typeof value[0] === 'string') return value[0];
|
||
}
|
||
return undefined;
|
||
}
|
||
|
||
export function createHttpClient(options: HttpClientOptions) {
|
||
const baseUrl = options.baseUrl.replace(/\/+$/, '');
|
||
const storage = options.storage ?? localTokenStorage;
|
||
const defaultTimeout = options.timeoutMs ?? 15_000;
|
||
const maxRetries = options.maxRetries ?? 2;
|
||
|
||
const inFlight = new Map<string, Promise<unknown>>();
|
||
const signedOutListeners = new Set<() => void>();
|
||
let refreshing: Promise<Tokens | 'invalid' | ApiError> | null = null;
|
||
|
||
function authHeaders(): Record<string, string> {
|
||
const token = storage.get()?.token;
|
||
return token ? { Authorization: `Bearer ${token}` } : {};
|
||
}
|
||
|
||
function signOut() {
|
||
storage.set(null);
|
||
signedOutListeners.forEach((listener) => listener());
|
||
}
|
||
|
||
/**
|
||
* نتیجهی تمدید سه حالت دارد و فقط یکیاش یعنی خروج:
|
||
* • Tokens — تمدید شد.
|
||
* • 'invalid' — سرور گفت refresh token معتبر نیست (۴۰۱)؛ نشست واقعاً تمام است.
|
||
* • ApiError — سرور در دسترس نبود یا سقف را زد (۴۲۹، ۵xx، قطعیِ شبکه)؛ نشست سالم است.
|
||
* تا ۲۰۲۶-۱۰-۰۳ هر شکستی خروج حساب میشد: پشتِ وایفایِ مدرسه که سقفِ تمدیدِ IP پر میشد،
|
||
* دانشآموزِ شمارهدار به صفحهی ورود پرت میشد و باید دوباره پیامک میگرفت.
|
||
*/
|
||
async function refreshTokens(): Promise<Tokens | 'invalid' | ApiError> {
|
||
const current = storage.get();
|
||
if (!current?.refreshToken) return 'invalid';
|
||
if (refreshing) return refreshing;
|
||
|
||
refreshing = (async (): Promise<Tokens | 'invalid' | ApiError> => {
|
||
try {
|
||
let response: Response;
|
||
try {
|
||
response = await fetch(`${baseUrl}/auth/refresh`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ refreshToken: current.refreshToken }),
|
||
signal: timeoutSignal(defaultTimeout),
|
||
credentials: 'omit',
|
||
});
|
||
} catch {
|
||
return new ApiError(0, 'ارتباط با سرور برقرار نشد');
|
||
}
|
||
if (response.status === 401) {
|
||
// تبِ دیگری همین توکن را زودتر تمدید کرده (سرور توکنِ قبلی را باطل میکند) و توکنِ تازه
|
||
// را در همان localStorageِ مشترک گذاشته؛ آن نشست سالم است و نباید هر دو تب خارج شوند.
|
||
const latest = storage.get();
|
||
if (latest?.refreshToken && latest.refreshToken !== current.refreshToken) return latest;
|
||
return 'invalid';
|
||
}
|
||
if (!response.ok) return errorFrom(response, await parseBody(response));
|
||
const data = (await response.json()) as Tokens;
|
||
const next = { token: data.token, refreshToken: data.refreshToken || current.refreshToken };
|
||
storage.set(next);
|
||
return next;
|
||
} finally {
|
||
queueMicrotask(() => {
|
||
refreshing = null;
|
||
});
|
||
}
|
||
})();
|
||
return refreshing;
|
||
}
|
||
|
||
async function send(
|
||
method: string,
|
||
path: string,
|
||
body: unknown,
|
||
opts: RequestOptions,
|
||
): Promise<Response> {
|
||
const headers: Record<string, string> = { Accept: 'application/json', ...authHeaders() };
|
||
if (body !== undefined) headers['Content-Type'] = 'application/json';
|
||
if (opts.idempotencyKey) headers['Idempotency-Key'] = opts.idempotencyKey;
|
||
|
||
const timer = timeoutSignal(opts.timeoutMs ?? defaultTimeout);
|
||
try {
|
||
return await fetch(`${baseUrl}${path}`, {
|
||
method,
|
||
headers,
|
||
body: body === undefined ? undefined : JSON.stringify(body),
|
||
signal: opts.signal ? anySignal([timer, opts.signal]) : timer,
|
||
// احرازِ هویت هدرِ Bearer است، نه کوکی؛ سرور هم CORS را بدونِ credentials دارد.
|
||
credentials: 'omit',
|
||
});
|
||
} catch (err) {
|
||
if (opts.signal?.aborted) throw err;
|
||
const timedOut = err instanceof DOMException && (err.name === 'TimeoutError' || err.name === 'AbortError');
|
||
throw new ApiError(0, timedOut ? 'زمانِ درخواست تمام شد' : 'ارتباط با سرور برقرار نشد');
|
||
}
|
||
}
|
||
|
||
/** یک درخواست، با یکبار refresh در صورتِ ۴۰۱. */
|
||
async function sendWithRefresh(
|
||
method: string,
|
||
path: string,
|
||
body: unknown,
|
||
opts: RequestOptions,
|
||
): Promise<Response> {
|
||
const hadToken = Boolean(storage.get()?.token);
|
||
let response = await send(method, path, body, opts);
|
||
if (response.status === 401 && hadToken) {
|
||
const refreshed = await refreshTokens();
|
||
if (refreshed === 'invalid') signOut();
|
||
else if (refreshed instanceof ApiError) throw refreshed; // نشست سالم است؛ تکرارپذیر مثلِ خطای شبکه
|
||
else response = await send(method, path, body, opts);
|
||
}
|
||
return response;
|
||
}
|
||
|
||
async function request<T>(method: string, path: string, body?: unknown, opts: RequestOptions = {}): Promise<T> {
|
||
const retriable = opts.retry ?? method === 'GET';
|
||
for (let attempt = 0; ; attempt++) {
|
||
try {
|
||
const response = await sendWithRefresh(method, path, body, opts);
|
||
const parsed = await parseBody(response);
|
||
if (!response.ok) throw errorFrom(response, parsed);
|
||
return parsed as T;
|
||
} catch (err) {
|
||
if (!(err instanceof ApiError)) throw err;
|
||
const worthRetrying = err.isNetwork || RETRIABLE_STATUS.has(err.status);
|
||
if (!retriable || !worthRetrying || attempt >= maxRetries || opts.signal?.aborted) throw err;
|
||
await sleep(backoffMs(attempt, err.retryAfterMs));
|
||
}
|
||
}
|
||
}
|
||
|
||
function get<T>(path: string, opts: RequestOptions = {}): Promise<T> {
|
||
// درخواستِ لغوشدنی ادغام نمیشود؛ لغوِ یکی نباید دیگری را بکشد.
|
||
if (opts.signal) return request<T>('GET', path, undefined, opts);
|
||
const existing = inFlight.get(path);
|
||
if (existing) return existing as Promise<T>;
|
||
const promise = request<T>('GET', path, undefined, opts).finally(() => inFlight.delete(path));
|
||
inFlight.set(path, promise);
|
||
return promise;
|
||
}
|
||
|
||
/**
|
||
* درخواستِ Server-Sent Events (پاسخِ تکهتکهی دستیار).
|
||
* رویدادها با خطِ خالی جدا میشوند: `event: <نام>\ndata: <json>`.
|
||
*/
|
||
async function stream(
|
||
path: string,
|
||
body: unknown,
|
||
onEvent: (event: string, data: Record<string, unknown>) => void,
|
||
opts: RequestOptions = {},
|
||
): Promise<void> {
|
||
const response = await sendWithRefresh('POST', path, body, { timeoutMs: 120_000, ...opts });
|
||
if (!response.ok || !response.body) {
|
||
throw errorFrom(response, await parseBody(response));
|
||
}
|
||
const reader = response.body.getReader();
|
||
const decoder = new TextDecoder();
|
||
let buffer = '';
|
||
for (;;) {
|
||
const { value, done } = await reader.read();
|
||
if (done) break;
|
||
buffer += decoder.decode(value, { stream: true });
|
||
const parts = buffer.split('\n\n');
|
||
buffer = parts.pop() ?? '';
|
||
for (const part of parts) {
|
||
const event = /^event: (.+)$/m.exec(part)?.[1];
|
||
const data = /^data: (.+)$/m.exec(part)?.[1];
|
||
if (!event || !data) continue;
|
||
try {
|
||
onEvent(event, JSON.parse(data) as Record<string, unknown>);
|
||
} catch (err) {
|
||
if (err instanceof ApiError) throw err;
|
||
/* تکهی ناقص؛ نادیده */
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
return {
|
||
baseUrl,
|
||
storage,
|
||
get,
|
||
post: <T>(path: string, body?: unknown, opts?: RequestOptions) => request<T>('POST', path, body, opts),
|
||
patch: <T>(path: string, body?: unknown, opts?: RequestOptions) => request<T>('PATCH', path, body, opts),
|
||
/** POSTی که چیزی ثبت میکند: با کلیدِ یکتا، پس تکرارش دوبار ثبت نمیکند. */
|
||
create: <T>(path: string, body?: unknown, opts: RequestOptions = {}) =>
|
||
request<T>('POST', path, body, {
|
||
...opts,
|
||
retry: true,
|
||
idempotencyKey: opts.idempotencyKey ?? newIdempotencyKey(),
|
||
}),
|
||
stream,
|
||
onSignedOut(listener: () => void) {
|
||
signedOutListeners.add(listener);
|
||
return () => {
|
||
signedOutListeners.delete(listener);
|
||
};
|
||
},
|
||
};
|
||
}
|
||
|
||
export type HttpClient = ReturnType<typeof createHttpClient>;
|