/** * هسته‌ی ارتباط با بک‌اند. فقط 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 = {}, ) { 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 { if (retryAfterMs != null) return Math.min(retryAfterMs, 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((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> { const text = await response.text(); if (!text) return {}; try { const parsed: unknown = JSON.parse(text); return parsed && typeof parsed === 'object' ? (parsed as Record) : {}; } catch { // HTML به‌جای JSON: معمولاً ۵۰۲ِ nginx، یا آدرسِ API اشتباه است و // سرورِ فرانت index.html را برگردانده. return {}; } } function errorFrom(response: Response, body: Record): 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 | 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>(); const signedOutListeners = new Set<() => void>(); let refreshing: Promise | null = null; function authHeaders(): Record { 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 { const current = storage.get(); if (!current?.refreshToken) return 'invalid'; if (refreshing) return refreshing; refreshing = (async (): Promise => { 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 { const headers: Record = { 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 { 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(method: string, path: string, body?: unknown, opts: RequestOptions = {}): Promise { 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(path: string, opts: RequestOptions = {}): Promise { // درخواستِ لغوشدنی ادغام نمی‌شود؛ لغوِ یکی نباید دیگری را بکشد. if (opts.signal) return request('GET', path, undefined, opts); const existing = inFlight.get(path); if (existing) return existing as Promise; const promise = request('GET', path, undefined, opts).finally(() => inFlight.delete(path)); inFlight.set(path, promise); return promise; } /** * درخواستِ Server-Sent Events (پاسخِ تکه‌تکه‌ی دستیار). * رویدادها با خطِ خالی جدا می‌شوند: `event: <نام>\ndata: `. */ async function stream( path: string, body: unknown, onEvent: (event: string, data: Record) => void, opts: RequestOptions = {}, ): Promise { 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); } catch (err) { if (err instanceof ApiError) throw err; /* تکه‌ی ناقص؛ نادیده */ } } } } return { baseUrl, storage, get, post: (path: string, body?: unknown, opts?: RequestOptions) => request('POST', path, body, opts), patch: (path: string, body?: unknown, opts?: RequestOptions) => request('PATCH', path, body, opts), /** POSTی که چیزی ثبت می‌کند: با کلیدِ یکتا، پس تکرارش دوبار ثبت نمی‌کند. */ create: (path: string, body?: unknown, opts: RequestOptions = {}) => request('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;