diff --git a/pkgs/rest/package.json b/pkgs/rest/package.json index bc7bb7d..12e8327 100644 --- a/pkgs/rest/package.json +++ b/pkgs/rest/package.json @@ -1,6 +1,6 @@ { "name": "@purrkit/rest", - "version": "1.1.2", + "version": "1.2.0", "license": "EUPL-1.2", "files": [ "dist", diff --git a/pkgs/rest/src/client.ts b/pkgs/rest/src/client.ts index 1f55ba7..515cd59 100644 --- a/pkgs/rest/src/client.ts +++ b/pkgs/rest/src/client.ts @@ -1,15 +1,23 @@ import type { ApiResponse, DiscordError, DiscordRestClient } from "./types"; +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + export interface ClientOptions { token: string; tokenType?: "Bot" | "Bearer"; baseUrl?: string; fetch?: (input: RequestInfo | URL | string, init?: RequestInit) => Promise; + timeout?: number; + retries?: number; + retryDelay?: number; + onTokenRefresh?: () => Promise; } export function RESTClient(options: ClientOptions): DiscordRestClient { const baseUrl = options.baseUrl || "https://discord.com/api/v10"; const customFetch = options.fetch ?? globalThis.fetch; + const maxRetries = options.retries ?? 0; + const baseDelay = options.retryDelay ?? 1000; const executeRequest = async ( segments: string[], @@ -28,7 +36,9 @@ export function RESTClient(options: ClientOptions): DiscordRestClient { for (const item of val) { searchParams.append(key, String(item)); } - } else { + } else if (typeof val === "object") { + searchParams.append(key, JSON.stringify(val)); + } else if (typeof val === "string" || typeof val === "number" || typeof val === "boolean") { searchParams.append(key, String(val)); } } @@ -37,49 +47,83 @@ export function RESTClient(options: ClientOptions): DiscordRestClient { if (queryString) url += `?${queryString}`; } - try { - const isFormData = typeof FormData !== "undefined" && body instanceof FormData; - - const reqHeaders: Record = { - Authorization: `${options.tokenType ?? "Bot"} ${options.token}`, - ...headers, - }; - - if (!isFormData && !reqHeaders["Content-Type"] && body !== undefined) - reqHeaders["Content-Type"] = "application/json"; - - const res = await customFetch(url, { - method, - headers: reqHeaders, - body: isFormData - ? (body as BodyInit) - : body !== undefined - ? JSON.stringify(body) - : undefined, - }); - - const isOk = res.ok; - const status = res.status; - - let data: unknown = null; - - if (status !== 204) data = (await res.json().catch(() => null)) as unknown; - if (isOk) return { ok: true, data, status }; - - const errorData: DiscordError = - data && typeof data === "object" && "message" in data - ? (data as DiscordError) - : { message: "Unknown API Error" }; - - return { ok: false, data: errorData, status }; - } catch (error) { - const message = error instanceof Error ? error.message : "Network Error"; - return { - ok: false, - data: { message }, - status: 0, - }; + let currentToken = options.token; + let tokenRefreshed = false; + + for (let attempt = 0; attempt <= maxRetries; attempt++) { + try { + const controller = new AbortController(); + let timeoutId: ReturnType | null = null; + + if (options.timeout) { + timeoutId = setTimeout(() => controller.abort(), options.timeout); + } + + const isFormData = typeof FormData !== "undefined" && body instanceof FormData; + + const reqHeaders: Record = { + Authorization: `${options.tokenType ?? "Bot"} ${currentToken}`, + ...headers, + }; + + if (!isFormData && !reqHeaders["Content-Type"] && body !== undefined) + reqHeaders["Content-Type"] = "application/json"; + + const res = await customFetch(url, { + method, + headers: reqHeaders, + signal: controller.signal, + body: isFormData + ? (body as BodyInit) + : body !== undefined + ? JSON.stringify(body) + : undefined, + }); + + if (timeoutId) clearTimeout(timeoutId); + + const status = res.status; + let data: unknown = null; + if (status !== 204) data = (await res.json().catch(() => null)) as unknown; + + if (status === 401 && options.tokenType === "Bearer" && options.onTokenRefresh && !tokenRefreshed) { + tokenRefreshed = true; + currentToken = await options.onTokenRefresh(); + attempt--; + continue; + } + + if (res.ok) return { ok: true, data, status }; + + const errorData: DiscordError = + data && typeof data === "object" && "message" in data + ? (data as DiscordError) + : { message: "Unknown API Error" }; + + if (status >= 500 && status < 600 && attempt < maxRetries) { + if (status !== 501 && status !== 505 && status !== 511) { + await sleep(baseDelay * Math.pow(2, attempt) + Math.random() * 1000); + continue; + } + } + + return { ok: false, data: errorData, status }; + } catch (error: unknown) { + if (error instanceof DOMException && error.name === "AbortError") { + return { ok: false, data: { message: "Request timed out" }, status: 408 }; + } + + if (attempt < maxRetries) { + await sleep(baseDelay * Math.pow(2, attempt) + Math.random() * 1000); + continue; + } + + const message = error instanceof Error ? error.message : "Network Error"; + return { ok: false, data: { message }, status: 0 }; + } } + + return { ok: false, data: { message: "max retries exceeded" }, status: 0 }; }; const createProxy = (pathSegments: string[] = []): unknown => { diff --git a/pkgs/rest/tsconfig.json b/pkgs/rest/tsconfig.json index d3d6f55..d102b54 100644 --- a/pkgs/rest/tsconfig.json +++ b/pkgs/rest/tsconfig.json @@ -1,6 +1,7 @@ { "compilerOptions": { "outDir": "dist", + "rootDir": "src", "declaration": true, // Environment setup & latest features