2b399fdb81
PR #1223 only staged the deletions of the old paths because the new top-level directories were still untracked when the commit was authored. This commit adds the actual restructured tree: SREBOT/ (existing bot), SHARED/ (vromfs, data_parser, ICONS/MAPS/FONTS, DAGOR_FILES, update_game_files), and TSSBOT/ (skeleton). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
363 lines
13 KiB
JavaScript
363 lines
13 KiB
JavaScript
// 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;
|
|
}
|
|
};
|