// ============================================================================ // Stylescape | Fetch Client // ============================================================================ // A robust HTTP client for making API requests with built-in error handling. // Can be initialized via data-ss-fetch attributes for declarative AJAX. // ============================================================================ /** * Configuration options for FetchClient */ export interface FetchClientOptions { /** Base URL for all requests */ baseUrl?: string; /** Default headers to include in all requests */ headers?: Record; /** Default timeout in milliseconds */ timeout?: number; /** Whether to include credentials (cookies) */ credentials?: RequestCredentials; } /** * Response wrapper with status information */ export interface FetchResponse { data: T; status: number; statusText: string; headers: Headers; ok: boolean; } /** * A flexible HTTP client for making API requests. * * @example * ```typescript * const api = new FetchClient({ baseUrl: "/api" }) * const users = await api.get("/users") * await api.post("/users", { name: "John" }) * ``` * * @example HTML with data-ss * ```html *
*
* ``` */ export class FetchClient { private baseUrl: string; private defaultHeaders: Record; private timeout: number; private credentials: RequestCredentials; constructor(options: FetchClientOptions = {}) { this.baseUrl = options.baseUrl || ""; this.timeout = options.timeout || 30000; this.credentials = options.credentials || "same-origin"; this.defaultHeaders = { "Content-Type": "application/json", "X-Requested-With": "XMLHttpRequest", ...options.headers, }; } // ======================================================================== // Public Methods // ======================================================================== /** * Make a GET request */ async get( endpoint: string, params?: Record, options?: RequestInit, ): Promise> { const url = this.buildUrl(endpoint, params); return this.request(url, { ...options, method: "GET" }); } /** * Make a POST request */ async post( endpoint: string, body?: unknown, options?: RequestInit, ): Promise> { const url = this.buildUrl(endpoint); return this.request(url, { ...options, method: "POST", body: JSON.stringify(body), }); } /** * Make a PUT request */ async put( endpoint: string, body?: unknown, options?: RequestInit, ): Promise> { const url = this.buildUrl(endpoint); return this.request(url, { ...options, method: "PUT", body: JSON.stringify(body), }); } /** * Make a PATCH request */ async patch( endpoint: string, body?: unknown, options?: RequestInit, ): Promise> { const url = this.buildUrl(endpoint); return this.request(url, { ...options, method: "PATCH", body: JSON.stringify(body), }); } /** * Make a DELETE request */ async delete( endpoint: string, options?: RequestInit, ): Promise> { const url = this.buildUrl(endpoint); return this.request(url, { ...options, method: "DELETE" }); } /** * Submit a form via AJAX */ async submitForm( form: HTMLFormElement, options?: RequestInit, ): Promise> { const formData = new FormData(form); const url = form.action || window.location.href; const method = form.method?.toUpperCase() || "POST"; return this.request(url, { ...options, method, body: formData, headers: { ...this.getHeaders(), // Remove Content-Type to let browser set it with boundary for FormData }, }); } /** * Get CSRF token from cookies (for Django, Laravel, etc.) */ getCSRFToken(cookieName: string = "csrftoken"): string { const cookies = document.cookie.split(";"); for (const cookie of cookies) { const [name, value] = cookie.trim().split("="); if (name === cookieName) { return decodeURIComponent(value); } } return ""; } /** * Set a default header for all future requests */ setHeader(key: string, value: string): void { this.defaultHeaders[key] = value; } /** * Set the base URL */ setBaseUrl(url: string): void { this.baseUrl = url; } // ======================================================================== // Private Methods // ======================================================================== private async request( url: string, options: RequestInit = {}, ): Promise> { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), this.timeout); try { const response = await fetch(url, { ...options, headers: { ...this.getHeaders(), ...(options.headers as Record), }, credentials: this.credentials, signal: controller.signal, }); clearTimeout(timeoutId); let data: T; const contentType = response.headers.get("Content-Type") || ""; if (contentType.includes("application/json")) { data = await response.json(); } else if (contentType.includes("text/")) { data = (await response.text()) as unknown as T; } else { data = (await response.blob()) as unknown as T; } const result: FetchResponse = { data, status: response.status, statusText: response.statusText, headers: response.headers, ok: response.ok, }; if (!response.ok) { throw new FetchError( `HTTP ${response.status}: ${response.statusText}`, result, ); } return result; } catch (error) { clearTimeout(timeoutId); if (error instanceof FetchError) { throw error; } if ((error as Error).name === "AbortError") { throw new FetchError("Request timeout", null); } throw new FetchError((error as Error).message, null); } } private buildUrl( endpoint: string, params?: Record, ): string { const url = new URL(endpoint, this.baseUrl || window.location.origin); if (params) { Object.entries(params).forEach(([key, value]) => { url.searchParams.append(key, value); }); } return url.toString(); } private getHeaders(): Record { return { ...this.defaultHeaders }; } } /** * Custom error class for fetch errors */ export class FetchError extends Error { response: FetchResponse | null; constructor(message: string, response: FetchResponse | null) { super(message); this.name = "FetchError"; this.response = response; } } // ============================================================================ // Default Instance // ============================================================================ /** Default FetchClient instance */ export const fetchClient = new FetchClient(); export default FetchClient;