add SREBOT, SHARED, TSSBOT contents (fixup for #1223)
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>
This commit is contained in:
@@ -0,0 +1,362 @@
|
||||
// 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;
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user