Files
MathApp_frontend/src/api/client.ts
AliReza be9f99d52a add a typed API layer for the Tadrisino backend
اپ تا الان هیچ درخواستی به سرور نمی‌زد و همه‌ی داده‌ها داخلِ خودِ کامپوننت‌ها
هاردکد بود. این کامیت لایه‌ی ارتباط را اضافه می‌کند، بدونِ اینکه هیچ صفحه‌ای
را تغییر دهد.

عمداً فقط فایلِ جدید اضافه شده و App.tsx دست نخورده است، چون کارِ در جریانِ
برنچِ updateApp روی همان فایل است و نباید با هم تصادف کنند. وصل کردنِ
صفحه‌ها به این لایه کارِ بعدی‌ست.

هیچ پکیجِ جدیدی اضافه نشد — فقط fetch استاندارد.

چیزهایی که در client.ts هست برای حالتِ خراب‌اند، نه حالتِ خوب:
  • timeout روی هر درخواست — وگرنه بک‌اندِ کند سوکت‌ها را باز نگه می‌دارد تا
    مرورگر به سقفِ ۶ درخواستِ همزمان بخورد و اپ قفل شود.
  • backoff با jitter — بدونِ jitter همه‌ی کلاینت‌ها بعد از یک خطا همزمان
    دوباره می‌زنند و موجِ دوم از موجِ اول بدتر است.
  • تکرارِ خودکار فقط روی GET؛ POST فقط وقتی Idempotency-Key دارد (ثبتِ پاسخ،
    سفارش) تا دابل‌کلیکِ کاربر دو بار XP یا دو سفارش نسازد.
  • refresh تک‌نسخه‌ای — ۲۰ درخواستِ همزمانِ ۴۰۱ نباید ۲۰ بار refresh بزنند.
  • ادغامِ GETهای یکسانِ در جریان.
  • احترام به هدرِ Retry-After که بک‌اند موقعِ throttle می‌فرستد.

credentials: 'omit' عمدی‌ست: احرازِ هویت توکنِ Bearer در هدر است و سمتِ سرور
CORS_ALLOW_CREDENTIALS=False تنظیم شده.

.env هم به .gitignore اضافه شد؛ قبلاً فقط node_modules داخلش بود و آدرس/
تنظیماتِ محلی می‌توانست ناخواسته کامیت شود.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 20:06:28 +03:30

318 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* هسته‌ی ارتباط با بک‌اندِ تدریسینو.
*
* این فایل عمداً هیچ پکیجی اضافه نمی‌کند — فقط 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<string, unknown> = {},
) {
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<string, string> }).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<void>((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<string, string>;
/** برای درخواست‌های کند مثل پرسش از دستیار. */
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<string, Promise<unknown>>();
/** فقط یک refresh در هر لحظه. */
let refreshing: Promise<Tokens | null> | null = null;
async function doRefresh(): Promise<Tokens | null> {
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<T>(
method: string,
path: string,
body: unknown,
opts: RequestOptions,
allowRefresh: boolean,
): Promise<T> {
const tokens = storage.get();
const headers: Record<string, string> = { 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<T>(method, path, body, opts, false);
storage.set(null);
options.onSignedOut?.();
}
const text = await response.text();
let parsed: Record<string, unknown> = {};
if (text) {
try {
parsed = JSON.parse(text) as Record<string, unknown>;
} 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<T>(
method: string,
path: string,
body?: unknown,
opts: RequestOptions = {},
): Promise<T> {
const retriable = opts.retry ?? method === 'GET';
let lastError: ApiError | undefined;
for (let attempt = 0; attempt <= (retriable ? maxRetries : 0); attempt++) {
try {
return await once<T>(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<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;
}
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(),
}),
};
}
export type HttpClient = ReturnType<typeof createHttpClient>;
/** 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;
}