/** * هسته‌ی ارتباط با بک‌اندِ تدریسینو. * * این فایل عمداً هیچ پکیجی اضافه نمی‌کند — فقط fetch استاندارد. هر چیزی که * اینجاست برای یک حالتِ خراب است که در فشار پیش می‌آید، نه برای حالتِ خوب: * * • timeout — بدونِ آن، یک بک‌اندِ کند سوکت‌ها را باز نگه می‌دارد تا مرورگر * به سقفِ ۶ درخواستِ همزمان بخورد و کلِ اپ قفل شود. * • backoff با jitter — وقتی سرور ۴۲۹/۵۰۳ می‌دهد، تکرارِ فوری فقط بار را * بیشتر می‌کند. jitter لازم است وگرنه هزار کلاینت *همزمان* دوباره می‌زنند * و موجِ بعدی از موجِ اول بدتر است. * • فقط GET خودکار تکرار می‌شود — تکرارِ POST یعنی سفارشِ تکراری. * • refresh تک‌نسخه‌ای — اگر ۲۰ درخواست با هم ۴۰۱ بگیرند، بدونِ این ۲۰ بار * refresh صدا زده می‌شود و سرور همه را throttle می‌کند. * • حذفِ درخواستِ تکراری — دو کامپوننت که همزمان یک GET را می‌خواهند، یک * درخواست می‌فرستند نه دو تا. */ export interface Tokens { token: string; refreshToken: string; } export class ApiError extends Error { constructor( readonly status: number, message: string, readonly payload: Record = {}, ) { super(message); this.name = 'ApiError'; } /** ۴۰۱ — توکن نامعتبر یا منقضی. کاربر باید دوباره وارد شود. */ get isUnauthorized() { return this.status === 401; } /** ۴۲۹ — سقفِ نرخ. `retryAfterMs` می‌گوید چقدر صبر کنید. */ get isRateLimited() { return this.status === 429; } /** ۵۰۳ — سرویسِ بیرونی (هوش مصنوعی/پیامک) موقتاً در دسترس نیست. */ get isProviderDown() { return this.status === 503; } /** درخواست اصلاً به سرور نرسید یا timeout شد. */ get isNetwork() { return this.status === 0; } retryAfterMs?: number; } /* ─── تنظیمات ───────────────────────────────────────────────────────────── */ export interface ClientOptions { /** پیش‌فرض از VITE_API_BASE_URL خوانده می‌شود. */ baseUrl?: string; /** سقفِ زمانِ هر درخواست. پیش‌فرض ۱۵ ثانیه. */ timeoutMs?: number; /** بیشترین تعدادِ تلاشِ دوباره برای GET. پیش‌فرض ۳. */ maxRetries?: number; storage?: TokenStorage; /** وقتی نشست به‌کل باطل شد (refresh هم شکست خورد) صدا زده می‌شود. */ onSignedOut?: () => void; } export interface TokenStorage { get(): Tokens | null; set(t: Tokens | null): void; } const STORAGE_KEY = 'tadrisino.tokens'; /** localStorage در حالتِ خصوصیِ مرورگر throw می‌کند؛ هیچ‌کدام نباید اپ را بخواباند. */ export function localTokenStorage(): TokenStorage { return { get() { try { const raw = localStorage.getItem(STORAGE_KEY); return raw ? (JSON.parse(raw) as Tokens) : null; } catch { return null; } }, set(t) { try { if (t) localStorage.setItem(STORAGE_KEY, JSON.stringify(t)); else localStorage.removeItem(STORAGE_KEY); } catch { /* نادیده */ } }, }; } /** * بک‌اندِ مشترک. پیش‌فرض است تا کسی که تازه ریپو را clone می‌کند بدونِ هیچ * تنظیماتی بتواند اپ را اجرا کند و داده‌ی واقعی ببیند. * برای بک‌اندِ محلی، در فایلِ .env مقدارِ VITE_API_BASE_URL را عوض کنید. */ const DEFAULT_API_BASE_URL = 'https://api.math.tadrisino2.ir/api'; function resolveBaseUrl(explicit?: string): string { if (explicit) return explicit.replace(/\/+$/, ''); const fromEnv = (import.meta as { env?: Record }).env?.VITE_API_BASE_URL; if (fromEnv) return fromEnv.replace(/\/+$/, ''); return DEFAULT_API_BASE_URL; } /* ─── کمکی‌ها ───────────────────────────────────────────────────────────── */ const RETRIABLE_STATUS = new Set([408, 425, 429, 500, 502, 503, 504]); /** full jitter — هزار کلاینت نباید همزمان بیدار شوند. */ function backoffMs(attempt: number, retryAfterMs?: number): number { if (retryAfterMs != null) return Math.min(retryAfterMs, 30_000); const ceiling = Math.min(1000 * 2 ** attempt, 8000); return Math.random() * ceiling; } 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((r) => setTimeout(r, ms)); /** برای POSTهایی که چیزی می‌سازند؛ سرور هدرِ Idempotency-Key را می‌پذیرد. */ function newIdempotencyKey(): string { const c = globalThis.crypto; if (c && 'randomUUID' in c) return c.randomUUID(); return `${Date.now()}-${Math.random().toString(36).slice(2)}`; } export interface RequestOptions { /** هدرهای اضافه. */ headers?: Record; /** برای درخواست‌های کند مثل پرسش از دستیار. */ timeoutMs?: number; /** لغو از بیرون (مثلاً کاربر از صفحه رفت). */ signal?: AbortSignal; /** POST را هم قابلِ تکرار می‌کند — فقط وقتی Idempotency-Key می‌فرستید. */ retry?: boolean; /** کلیدِ یکتا تا تکرارِ درخواست سفارشِ تکراری نسازد. */ idempotencyKey?: string; } /* ─── کلاینت ────────────────────────────────────────────────────────────── */ export function createHttpClient(options: ClientOptions = {}) { const baseUrl = resolveBaseUrl(options.baseUrl); const defaultTimeout = options.timeoutMs ?? 15_000; const maxRetries = options.maxRetries ?? 3; const storage = options.storage ?? localTokenStorage(); /** درخواست‌های GETِ در جریان، تا دو کامپوننت یک چیز را دوبار نگیرند. */ const inFlight = new Map>(); /** فقط یک refresh در هر لحظه. */ let refreshing: Promise | null = null; async function doRefresh(): Promise { const current = storage.get(); if (!current?.refreshToken) return null; if (refreshing) return refreshing; refreshing = (async () => { try { const response = await fetch(`${baseUrl}/auth/refresh`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ refreshToken: current.refreshToken }), signal: AbortSignal.timeout(defaultTimeout), }); if (!response.ok) return null; const data = (await response.json()) as Tokens; storage.set({ token: data.token, refreshToken: data.refreshToken }); return data; } catch { return null; } finally { // در میکروتسکِ بعدی آزاد می‌شود تا هم‌زمان‌ها همین نتیجه را ببینند. queueMicrotask(() => { refreshing = null; }); } })(); return refreshing; } async function once( method: string, path: string, body: unknown, opts: RequestOptions, allowRefresh: boolean, ): Promise { const tokens = storage.get(); const headers: Record = { Accept: 'application/json', ...opts.headers }; if (body !== undefined) headers['Content-Type'] = 'application/json'; if (tokens?.token) headers.Authorization = `Bearer ${tokens.token}`; if (opts.idempotencyKey) headers['Idempotency-Key'] = opts.idempotencyKey; // timeoutِ خودمان + لغوِ بیرونی، هر کدام زودتر رسید. const timer = AbortSignal.timeout(opts.timeoutMs ?? defaultTimeout); const signal = opts.signal ? anySignal([timer, opts.signal]) : timer; let response: Response; try { response = await fetch(`${baseUrl}${path}`, { method, headers, body: body === undefined ? undefined : JSON.stringify(body), signal, // احرازِ هویت هدرِ Bearer است، نه کوکی — سمتِ سرور هم عمداً // CORS_ALLOW_CREDENTIALS=False است. اینجا هم نباید عوض شود. credentials: 'omit', }); } catch (err) { const aborted = err instanceof DOMException && err.name === 'AbortError'; throw new ApiError(0, aborted ? 'زمانِ درخواست تمام شد' : 'ارتباط با سرور برقرار نشد'); } if (response.status === 401 && allowRefresh) { const refreshed = await doRefresh(); if (refreshed) return once(method, path, body, opts, false); storage.set(null); options.onSignedOut?.(); } const text = await response.text(); let parsed: Record = {}; if (text) { try { parsed = JSON.parse(text) as Record; } catch { // سرور HTML داد (۵۰۲ از nginx مثلاً) — پیامِ خام گمراه‌کننده است. parsed = {}; } } if (!response.ok) { const detail = (parsed.detail as string) ?? (parsed.error as string) ?? (response.status === 502 ? 'سرور در دسترس نیست' : `خطای ${response.status}`); const error = new ApiError(response.status, detail, parsed); error.retryAfterMs = parseRetryAfter(response.headers.get('Retry-After')); throw error; } return parsed as T; } async function request( method: string, path: string, body?: unknown, opts: RequestOptions = {}, ): Promise { const retriable = opts.retry ?? method === 'GET'; let lastError: ApiError | undefined; for (let attempt = 0; attempt <= (retriable ? maxRetries : 0); attempt++) { try { return await once(method, path, body, opts, true); } catch (err) { if (!(err instanceof ApiError)) throw err; lastError = err; const worthRetrying = err.isNetwork || RETRIABLE_STATUS.has(err.status); if (!retriable || !worthRetrying || attempt === maxRetries) throw err; if (opts.signal?.aborted) throw err; await sleep(backoffMs(attempt, err.retryAfterMs)); } } throw lastError ?? new ApiError(0, 'خطای نامشخص'); } /** GETها با هم ادغام می‌شوند تا رندرِ همزمانِ چند کامپوننت یک درخواست بدهد. */ 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; } 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(), }), }; } export type HttpClient = ReturnType; /** AbortSignal.any هنوز همه‌جا نیست؛ این جایگزینِ کوچکش است. */ function anySignal(signals: AbortSignal[]): AbortSignal { const anyFn = (AbortSignal as { any?: (s: AbortSignal[]) => AbortSignal }).any; if (anyFn) return anyFn(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; }