forked from Tadrisino/MathApp_frontend
اپ تا الان هیچ درخواستی به سرور نمیزد و همهی دادهها داخلِ خودِ کامپوننتها
هاردکد بود. این کامیت لایهی ارتباط را اضافه میکند، بدونِ اینکه هیچ صفحهای
را تغییر دهد.
عمداً فقط فایلِ جدید اضافه شده و 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>
318 lines
13 KiB
TypeScript
318 lines
13 KiB
TypeScript
/**
|
||
* هستهی ارتباط با بکاندِ تدریسینو.
|
||
*
|
||
* این فایل عمداً هیچ پکیجی اضافه نمیکند — فقط 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;
|
||
}
|