// API Client with automatic token handling and caching class APIClient { constructor() { this.apiKey = null; this.apiKeyExpiresAt = 0; this.apiKeyPromise = null; this.baseURL = ''; this.cache = new Map(); this.pendingRequests = new Map(); this.CACHE_DURATION = 5 * 60 * 1000; // 5 minutes client-side cache for general requests this.SEARCH_CACHE_DURATION = 15 * 60 * 1000; // 15 minutes for search results (longer because less frequently changing) } // Initialize the API client by fetching the API key async init(forceRefresh = false) { if (!forceRefresh && this.apiKey && this.apiKeyExpiresAt && Date.now() < this.apiKeyExpiresAt) { return; } if (this.apiKeyPromise) { return this.apiKeyPromise; } this.apiKeyPromise = (async () => { const response = await fetch('/api-key', { method: 'GET', cache: 'no-cache', headers: { 'Accept': 'application/json' } }); if (!response.ok) { throw new Error(`Failed to fetch API key: ${response.status} ${response.statusText}`); } const data = await response.json(); this.apiKey = data.apiKey; const expiresAt = Date.parse(data.expires); this.apiKeyExpiresAt = Number.isFinite(expiresAt) ? expiresAt : Date.now() + 24 * 60 * 60 * 1000; return this.apiKey; })(); try { return await this.apiKeyPromise; } finally { this.apiKeyPromise = null; } } async ensureValidApiKey() { if (!this.apiKey || !this.apiKeyExpiresAt || Date.now() >= this.apiKeyExpiresAt) { await this.init(true); } } // Generate signature for request (matches server-side algorithm) generateSignature(method, path, queryString, timestamp, apiSecret) { const data = `${method}${path}${queryString}`; // Match server-side simple hashing algorithm exactly let hash = 0; const combined = `${data}-${timestamp}-${apiSecret}`; for (let i = 0; i < combined.length; i++) { const char = combined.charCodeAt(i); hash = ((hash << 5) - hash) + char; hash = hash & hash; // Convert to 32bit integer } return Math.abs(hash).toString(16); } // Make authenticated API requests with caching and in-flight dedup async request(endpoint, options = {}) { const method = options.method || 'GET'; const authEnabled = options.auth !== false; const cacheKey = `${method}:${endpoint}`; const useCache = options.cache !== false && method === 'GET'; // Only cache GET requests // Determine cache duration based on endpoint type const isSearchRequest = endpoint.includes('/api/search/'); const cacheDuration = isSearchRequest ? this.SEARCH_CACHE_DURATION : this.CACHE_DURATION; // Check cache for GET requests if (useCache && this.cache.has(cacheKey)) { const cached = this.cache.get(cacheKey); const age = Date.now() - cached.timestamp; if (age < cacheDuration) { console.log(`[API Client] Cache hit for ${endpoint} (${Math.floor(age / 1000)}s old)`); return cached.data; } else { console.log(`[API Client] Cache expired for ${endpoint}`); this.cache.delete(cacheKey); } } // In-flight dedup: if an identical GET is already in flight, share its promise if (useCache && this.pendingRequests.has(cacheKey)) { console.log(`[API Client] In-flight dedup hit for ${endpoint}`); return this.pendingRequests.get(cacheKey); } const promise = this._performRequest(endpoint, options, { method, authEnabled, cacheKey, useCache }); if (useCache) { this.pendingRequests.set(cacheKey, promise); promise.finally(() => { if (this.pendingRequests.get(cacheKey) === promise) { this.pendingRequests.delete(cacheKey); } }); } return promise; } async _performRequest(endpoint, options, ctx) { const { method, authEnabled, cacheKey, useCache } = ctx; console.log('[API Client] request called for:', endpoint); // Auto-initialize if needed if (authEnabled) { await this.ensureValidApiKey(); } if (authEnabled && !this.apiKey) { throw new Error('Failed to get API key after initialization'); } const url = endpoint.startsWith('/') ? endpoint : `/${endpoint}`; const timestamp = Date.now().toString(); const headers = { 'Content-Type': 'application/json', ...options.headers }; if (authEnabled) { headers['X-API-Key'] = this.apiKey; headers['X-Request-Timestamp'] = timestamp; } const finalOptions = { ...options, method, headers }; console.log('[API Client] Making request to:', url); let response = await fetch(url, finalOptions); console.log('[API Client] Response status:', response.status); if (authEnabled && (response.status === 401 || response.status === 403)) { console.warn(`[API Client] Auth failed for ${endpoint}; refreshing API key and retrying once`); this.apiKey = null; this.apiKeyExpiresAt = 0; await this.init(true); finalOptions.headers['X-API-Key'] = this.apiKey; finalOptions.headers['X-Request-Timestamp'] = Date.now().toString(); response = await fetch(url, finalOptions); console.log('[API Client] Retry response status:', response.status); } if (!response.ok) { const errorText = await response.text(); console.error('[API Client] Request failed:', response.status, errorText); throw new Error(`API request failed: ${response.status} - ${errorText}`); } const data = await response.json(); // Cache successful GET responses if (useCache) { this.cache.set(cacheKey, { data: data, timestamp: Date.now() }); console.log(`[API Client] Cached response for ${endpoint}`); } return data; } // Clear cache manually if needed clearCache() { this.cache.clear(); this.pendingRequests.clear(); console.log('[API Client] Cache cleared'); } // Get cache stats getCacheStats() { const now = Date.now(); const stats = { total: this.cache.size, fresh: 0, stale: 0, searchEntries: 0 }; for (const [key, value] of this.cache.entries()) { const age = now - value.timestamp; const isSearch = key.includes('/api/search/'); const maxAge = isSearch ? this.SEARCH_CACHE_DURATION : this.CACHE_DURATION; if (isSearch) stats.searchEntries++; if (age < maxAge) { stats.fresh++; } else { stats.stale++; } } return stats; } // Convenience methods async searchPlayers(nickname) { return this.request(`/api/search/${encodeURIComponent(nickname)}`); } async getPlayer(uid) { return this.request(`/api/player/${uid}`); } async getPlayerGames(uid) { return this.request(`/api/player/${uid}/games`); } async getStats() { return this.request('/api/stats', { auth: false }); } // Leaderboard methods with stale-while-revalidate option async getPlayerLeaderboard(useStaleWhileRevalidate = true) { return this.requestWithSWR('/api/leaderboard/players', useStaleWhileRevalidate); } async getVehicleLeaderboard(vehicle = null, useStaleWhileRevalidate = true) { const endpoint = vehicle ? `/api/leaderboard/vehicles?vehicle=${encodeURIComponent(vehicle)}` : '/api/leaderboard/vehicles'; return this.requestWithSWR(endpoint, useStaleWhileRevalidate); } async getSquadronLeaderboard(useStaleWhileRevalidate = true) { return this.requestWithSWR('/api/leaderboard/squadrons', useStaleWhileRevalidate); } async getSquadronDetails(squadronName, startDate, endDate) { let endpoint = `/api/squadrons/${encodeURIComponent(squadronName)}`; if (startDate || endDate) { const params = new URLSearchParams(); if (startDate) params.append('start_date', startDate.toISOString()); if (endDate) params.append('end_date', endDate.toISOString()); endpoint += '?' + params.toString(); } return this.request(endpoint); } async getSquadronGames(squadronName, startDate, endDate) { let endpoint = `/api/squadrons/${encodeURIComponent(squadronName)}/games`; if (startDate || endDate) { const params = new URLSearchParams(); if (startDate) params.append('start_date', startDate.toISOString()); if (endDate) params.append('end_date', endDate.toISOString()); endpoint += '?' + params.toString(); } return this.request(endpoint); } async getLeaderboardStats(useStaleWhileRevalidate = true) { return this.requestWithSWR('/api/leaderboard/stats', useStaleWhileRevalidate); } // Stale-While-Revalidate: Return cached data immediately, fetch fresh data in background async requestWithSWR(endpoint, useSWR = true) { const cacheKey = `GET:${endpoint}`; if (useSWR && this.cache.has(cacheKey)) { const cached = this.cache.get(cacheKey); const age = Date.now() - cached.timestamp; // If data is stale (older than cache duration) but not ancient (< 10 minutes) if (age >= this.CACHE_DURATION && age < 10 * 60 * 1000) { console.log(`[API Client] SWR: Returning stale data and revalidating ${endpoint}`); // Fetch fresh data in background (don't await) this.request(endpoint).catch(err => { console.error('[API Client] SWR background fetch failed:', err); }); // Return stale data immediately return cached.data; } } // Normal request (will use cache if fresh) return this.request(endpoint); } async getVehicleIcons() { return this.request('/api/vehicle-icons'); } async getMatch(sessionId) { return this.request(`/api/match/${sessionId}`); } async getMatchReplay(sessionId) { return this.request(`/api/match/${sessionId}/replay`); } async getReplayCanvas(sessionId) { return this.request(`/api/match/${sessionId}/replay-canvas`); } async searchGames(params = {}) { const queryParams = new URLSearchParams(); if (params.player) queryParams.append('player', params.player); if (params.map) queryParams.append('map', params.map); if (params.squadron) queryParams.append('squadron', params.squadron); if (params.time_from) queryParams.append('time_from', params.time_from); if (params.time_to) queryParams.append('time_to', params.time_to); if (params.limit) queryParams.append('limit', params.limit); return this.request(`/api/games/search?${queryParams}`); } async getMaps() { return this.request('/api/maps'); } } // Global API client instance window.apiClient = new APIClient(); // Pre-initialize when DOM is ready (but don't block) document.addEventListener('DOMContentLoaded', () => { // Initialize in background without blocking window.apiClient.init().catch(() => { // Silently fail, will retry on first request }); }); // Quick init function that doesn't throw window.ensureAPIClient = async () => { try { if (!window.apiClient) { console.error('[API Client] window.apiClient is not defined'); return false; } if (!window.apiClient.apiKey) { await window.apiClient.init(); } return true; } catch (error) { console.error('[API Client] Error in ensureAPIClient:', error); return false; } };