UNPKG

@globalleaderboards/sdk

Version:

Official SDK for GlobalLeaderboards.net - Add competitive leaderboards to any application

1,634 lines (1,626 loc) 53.2 kB
"use strict"; var __defProp = Object.defineProperty; var __getOwnPropDesc = Object.getOwnPropertyDescriptor; var __getOwnPropNames = Object.getOwnPropertyNames; var __hasOwnProp = Object.prototype.hasOwnProperty; var __name = (target, value) => __defProp(target, "name", { value, configurable: true }); var __export = (target, all) => { for (var name in all) __defProp(target, name, { get: all[name], enumerable: true }); }; var __copyProps = (to, from, except, desc) => { if (from && typeof from === "object" || typeof from === "function") { for (let key of __getOwnPropNames(from)) if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); } return to; }; var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); // src/index.ts var index_exports = {}; __export(index_exports, { GlobalLeaderboards: () => GlobalLeaderboards, GlobalLeaderboardsError: () => GlobalLeaderboardsError, LeaderboardSSE: () => LeaderboardSSE, LeaderboardWebSocket: () => LeaderboardWebSocket, OfflineQueue: () => OfflineQueue }); module.exports = __toCommonJS(index_exports); // ../../node_modules/ulid/dist/index.esm.js function createError(message) { const err = new Error(message); err.source = "ulid"; return err; } __name(createError, "createError"); var ENCODING = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"; var ENCODING_LEN = ENCODING.length; var TIME_MAX = Math.pow(2, 48) - 1; var TIME_LEN = 10; var RANDOM_LEN = 16; function randomChar(prng) { let rand = Math.floor(prng() * ENCODING_LEN); if (rand === ENCODING_LEN) { rand = ENCODING_LEN - 1; } return ENCODING.charAt(rand); } __name(randomChar, "randomChar"); function encodeTime(now, len) { if (isNaN(now)) { throw new Error(now + " must be a number"); } if (now > TIME_MAX) { throw createError("cannot encode time greater than " + TIME_MAX); } if (now < 0) { throw createError("time must be positive"); } if (Number.isInteger(Number(now)) === false) { throw createError("time must be an integer"); } let mod; let str = ""; for (; len > 0; len--) { mod = now % ENCODING_LEN; str = ENCODING.charAt(mod) + str; now = (now - mod) / ENCODING_LEN; } return str; } __name(encodeTime, "encodeTime"); function encodeRandom(len, prng) { let str = ""; for (; len > 0; len--) { str = randomChar(prng) + str; } return str; } __name(encodeRandom, "encodeRandom"); function detectPrng(allowInsecure = false, root) { if (!root) { root = typeof window !== "undefined" ? window : null; } const browserCrypto = root && (root.crypto || root.msCrypto); if (browserCrypto) { return () => { const buffer = new Uint8Array(1); browserCrypto.getRandomValues(buffer); return buffer[0] / 255; }; } else { try { const nodeCrypto = require("crypto"); return () => nodeCrypto.randomBytes(1).readUInt8() / 255; } catch (e) { } } if (allowInsecure) { try { console.error("secure crypto unusable, falling back to insecure Math.random()!"); } catch (e) { } return () => Math.random(); } throw createError("secure crypto unusable, insecure Math.random not allowed"); } __name(detectPrng, "detectPrng"); function factory(currPrng) { if (!currPrng) { currPrng = detectPrng(); } return /* @__PURE__ */ __name(function ulid2(seedTime) { if (isNaN(seedTime)) { seedTime = Date.now(); } return encodeTime(seedTime, TIME_LEN) + encodeRandom(RANDOM_LEN, currPrng); }, "ulid"); } __name(factory, "factory"); var ulid = factory(); // package.json var version = "0.5.0"; // src/types.ts var _GlobalLeaderboardsError = class _GlobalLeaderboardsError extends Error { constructor(message, code, statusCode, details) { super(message); this.code = code; this.statusCode = statusCode; this.details = details; this.name = "GlobalLeaderboardsError"; } }; __name(_GlobalLeaderboardsError, "GlobalLeaderboardsError"); var GlobalLeaderboardsError = _GlobalLeaderboardsError; // src/websocket.ts var _LeaderboardWebSocket = class _LeaderboardWebSocket { constructor(wsUrl, apiKey, options = {}) { this.wsUrl = wsUrl; this.apiKey = apiKey; this.options = options; this.ws = null; this.reconnectAttempts = 0; this.reconnectTimer = null; this.pingInterval = null; this.subscribedLeaderboards = /* @__PURE__ */ new Set(); this.isConnecting = false; this.shouldReconnect = true; this.permanentError = null; this.isOnline = true; this.networkListenersBound = false; this.handlers = {}; this.options = { maxReconnectAttempts: 5, reconnectDelay: 1e3, pingInterval: 3e4, ...options }; this.setupNetworkListeners(); this.isOnline = typeof navigator !== "undefined" ? navigator.onLine : true; } /** * Connect to the WebSocket server */ connect(leaderboardId, userId) { if (this.ws?.readyState === WebSocket.OPEN || this.isConnecting) { return; } this.isConnecting = true; this.shouldReconnect = true; this.permanentError = null; const params = new URLSearchParams({ api_key: this.apiKey }); if (leaderboardId) { params.append("leaderboard_id", leaderboardId); this.subscribedLeaderboards.add(leaderboardId); } if (userId) { params.append("user_id", userId); } const url = `${this.wsUrl}/v1/ws/connect?${params.toString()}`; try { this.ws = new WebSocket(url); this.setupEventHandlers(); } catch (error) { this.isConnecting = false; this.handleError(error); } } /** * Disconnect from the WebSocket server */ disconnect() { this.shouldReconnect = false; this.cleanup(); } /** * Subscribe to a leaderboard */ subscribe(leaderboardId, userId) { if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { throw new GlobalLeaderboardsError( "WebSocket is not connected", "WS_NOT_CONNECTED" ); } const message = { type: "subscribe", leaderboard_id: leaderboardId, user_id: userId }; this.send(message); this.subscribedLeaderboards.add(leaderboardId); } /** * Unsubscribe from a leaderboard */ unsubscribe(leaderboardId) { if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { return; } const message = { type: "unsubscribe", leaderboard_id: leaderboardId }; this.send(message); this.subscribedLeaderboards.delete(leaderboardId); } /** * Set event handlers */ on(handlers) { this.handlers = { ...this.handlers, ...handlers }; } /** * Get connection state */ get isConnected() { return this.ws?.readyState === WebSocket.OPEN; } /** * Get subscribed leaderboards */ get subscriptions() { return Array.from(this.subscribedLeaderboards); } /** * Get permanent error if connection was terminated due to a permanent error */ get permanentConnectionError() { return this.permanentError; } setupEventHandlers() { if (!this.ws) return; this.ws.onopen = () => { this.isConnecting = false; this.reconnectAttempts = 0; this.startPingInterval(); this.handlers.onConnect?.(); const urlParams = this.ws ? new URL(this.ws.url).searchParams : null; const connectedLeaderboardId = urlParams?.get("leaderboard_id"); this.subscribedLeaderboards.forEach((leaderboardId) => { if (leaderboardId !== connectedLeaderboardId) { this.subscribe(leaderboardId); } }); }; this.ws.onmessage = (event) => { try { const message = JSON.parse(event.data); this.handleMessage(message); } catch (error) { this.handleError(new Error("Failed to parse WebSocket message")); } }; this.ws.onerror = () => { this.handleError(new Error("WebSocket error")); }; this.ws.onclose = (event) => { this.isConnecting = false; this.stopPingInterval(); this.handlers.onDisconnect?.(event.code, event.reason); if (this.shouldReconnect) { this.scheduleReconnect(); } }; } handleMessage(message) { this.handlers.onMessage?.(message); switch (message.type) { case "leaderboard_update": this.handlers.onLeaderboardUpdate?.( message.payload ); break; case "user_rank_update": this.handlers.onUserRankUpdate?.( message.data ); break; case "error": const errorMsg = message; if (errorMsg.error && typeof errorMsg.error === "object" && "message" in errorMsg.error) { const error = new GlobalLeaderboardsError( errorMsg.error.message, errorMsg.error.code || "UNKNOWN_ERROR" ); const permanentErrors = [ "LEADERBOARD_NOT_FOUND", "INVALID_API_KEY", "INSUFFICIENT_PERMISSIONS", "INVALID_LEADERBOARD_ID" ]; if (permanentErrors.includes(error.code)) { this.shouldReconnect = false; this.permanentError = error; if (this.ws && this.ws.readyState === WebSocket.OPEN) { this.ws.close(4e3, `Permanent error: ${error.code}`); } } this.handleError(error); } else { this.handleError( new GlobalLeaderboardsError( "Invalid error message format", "INVALID_MESSAGE_FORMAT" ) ); } break; case "ping": this.send({ type: "pong" }); break; case "pong": break; case "connection_info": break; case "update": case "score_submission": break; default: } } send(message) { if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { throw new GlobalLeaderboardsError( "WebSocket is not connected", "WS_NOT_CONNECTED" ); } const serverMessage = this.transformToServerFormat(message); this.ws.send(JSON.stringify(serverMessage)); } transformToServerFormat(message) { const id = `msg_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`; const timestamp = (/* @__PURE__ */ new Date()).toISOString(); switch (message.type) { case "subscribe": const subMsg = message; return { id, type: "subscribe", timestamp, payload: { leaderboardId: subMsg.leaderboard_id, userId: subMsg.user_id } }; case "unsubscribe": const unsubMsg = message; return { id, type: "unsubscribe", timestamp, payload: { leaderboardId: unsubMsg.leaderboard_id } }; case "ping": case "pong": return { id, type: message.type, timestamp }; default: return { id, timestamp, ...message }; } } startPingInterval() { this.stopPingInterval(); this.pingInterval = setInterval(() => { if (this.ws?.readyState === WebSocket.OPEN) { this.send({ type: "ping" }); } }, this.options.pingInterval); } stopPingInterval() { if (this.pingInterval) { clearInterval(this.pingInterval); this.pingInterval = null; } } scheduleReconnect() { if (!this.isOnline) { return; } if (this.reconnectAttempts >= this.options.maxReconnectAttempts) { this.handleError( new GlobalLeaderboardsError( "Max reconnection attempts reached", "WS_MAX_RECONNECT" ) ); return; } this.reconnectAttempts++; const baseDelay = this.options.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1); const jitter = baseDelay * 0.25; const randomJitter = (Math.random() - 0.5) * 2 * jitter; const delay = Math.max(0, baseDelay + randomJitter); this.handlers.onReconnecting?.( this.reconnectAttempts, this.options.maxReconnectAttempts, Math.round(delay) ); this.reconnectTimer = setTimeout(() => { if (this.isOnline) { this.connect(); } else { this.scheduleReconnect(); } }, delay); } handleError(error) { this.handlers.onError?.(error); } cleanup() { if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = null; } this.stopPingInterval(); if (this.ws) { this.ws.close(); this.ws = null; } this.isConnecting = false; } /** * Set up network status listeners */ setupNetworkListeners() { if (typeof window === "undefined" || this.networkListenersBound) { return; } const handleOnline = /* @__PURE__ */ __name(() => { this.isOnline = true; if (this.permanentError) { return; } if (!this.isConnected && !this.isConnecting && this.shouldReconnect) { this.connect(); } }, "handleOnline"); const handleOffline = /* @__PURE__ */ __name(() => { this.isOnline = false; if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = null; } }, "handleOffline"); window.addEventListener("online", handleOnline); window.addEventListener("offline", handleOffline); this.networkListenersBound = true; this._handleOnline = handleOnline; this._handleOffline = handleOffline; } /** * Manually trigger a reconnection attempt * Useful for application-level retry logic */ reconnect() { if (this.isConnected || this.isConnecting) { return; } if (this.permanentError) { throw this.permanentError; } this.reconnectAttempts = 0; this.shouldReconnect = true; if (!this.isOnline) { throw new GlobalLeaderboardsError( "Cannot reconnect while offline", "WS_OFFLINE" ); } this.connect(); } }; __name(_LeaderboardWebSocket, "LeaderboardWebSocket"); var LeaderboardWebSocket = _LeaderboardWebSocket; // src/sse.ts var _LeaderboardSSE = class _LeaderboardSSE { /** * Create a new LeaderboardSSE client * * @param config - Configuration with API key and base URL */ constructor(config) { this.connections = /* @__PURE__ */ new Map(); this.reconnectAttempts = /* @__PURE__ */ new Map(); this.reconnectTimers = /* @__PURE__ */ new Map(); this.config = config; } /** * Connect to a leaderboard's SSE stream * * @param leaderboardId - The leaderboard to connect to * @param handlers - Event handlers for different SSE events * @param options - Connection options * @returns Connection object with close method */ connect(leaderboardId, handlers, options = {}) { this.disconnect(leaderboardId); const params = new URLSearchParams({ api_key: this.config.apiKey, ...options.userId && { user_id: options.userId }, include_metadata: String(options.includeMetadata ?? true), top_n: String(options.topN ?? 10) }); const url = `${this.config.baseUrl}/v1/sse/leaderboards/${leaderboardId}?${params.toString()}`; try { const eventSource = new EventSource(url); eventSource.onopen = () => { console.debug("[LeaderboardSSE] Connection opened:", leaderboardId); this.reconnectAttempts.delete(leaderboardId); handlers.onConnect?.(); }; eventSource.onmessage = (event) => { try { const data = JSON.parse(event.data); console.debug("[LeaderboardSSE] Received message:", data); handlers.onMessage?.(data); } catch (error) { console.error("[LeaderboardSSE] Failed to parse message:", error); } }; eventSource.onerror = (error) => { console.error("[LeaderboardSSE] Connection error:", error); if (eventSource.readyState === EventSource.CLOSED) { handlers.onDisconnect?.(); this.attemptReconnection(leaderboardId, handlers, options); } }; eventSource.addEventListener("connected", (event) => { try { const data = JSON.parse(event.data); console.debug("[LeaderboardSSE] Connected event:", data); } catch (error) { console.error("[LeaderboardSSE] Failed to parse connected event:", error); } }); eventSource.addEventListener("leaderboard_update", (event) => { try { const data = JSON.parse(event.data); console.debug("[LeaderboardSSE] Received leaderboard_update:", { leaderboardId: data.leaderboardId, mutations: data.mutations.length, entries: data.leaderboard.entries.length, sequence: data.sequence }); handlers.onLeaderboardUpdate?.(data); } catch (error) { console.error("[LeaderboardSSE] Failed to parse leaderboard_update event:", error); } }); eventSource.addEventListener("heartbeat", (event) => { try { const data = JSON.parse(event.data); handlers.onHeartbeat?.(data); } catch (error) { console.error("[LeaderboardSSE] Failed to parse heartbeat event:", error); } }); eventSource.addEventListener("error", (event) => { try { const data = JSON.parse(event.data); const error = new Error(data.error.message); error.code = data.error.code; handlers.onError?.(error); } catch (error) { console.error("[LeaderboardSSE] Failed to parse error event:", error); } }); this.connections.set(leaderboardId, eventSource); return { close: /* @__PURE__ */ __name(() => this.disconnect(leaderboardId), "close") }; } catch (error) { const message = error instanceof Error ? error.message : "Failed to create SSE connection"; const sseError = new Error(message); sseError.code = "CONNECTION_FAILED"; handlers.onError?.(sseError); throw sseError; } } /** * Disconnect from a specific leaderboard * * @param leaderboardId - The leaderboard to disconnect from */ disconnect(leaderboardId) { const eventSource = this.connections.get(leaderboardId); if (eventSource) { eventSource.close(); this.connections.delete(leaderboardId); } const timer = this.reconnectTimers.get(leaderboardId); if (timer) { clearTimeout(timer); this.reconnectTimers.delete(leaderboardId); } this.reconnectAttempts.delete(leaderboardId); } /** * Disconnect from all leaderboards */ disconnectAll() { for (const leaderboardId of this.connections.keys()) { this.disconnect(leaderboardId); } } /** * Check if connected to a specific leaderboard * * @param leaderboardId - The leaderboard to check * @returns Whether connected to the leaderboard */ isConnected(leaderboardId) { const eventSource = this.connections.get(leaderboardId); return eventSource?.readyState === EventSource.OPEN; } /** * Get connection status for all leaderboards * * @returns Map of leaderboard IDs to connection states */ getConnectionStatus() { const status = /* @__PURE__ */ new Map(); for (const [leaderboardId, eventSource] of this.connections) { switch (eventSource.readyState) { case EventSource.CONNECTING: status.set(leaderboardId, "connecting"); break; case EventSource.OPEN: status.set(leaderboardId, "open"); break; case EventSource.CLOSED: status.set(leaderboardId, "closed"); break; } } return status; } /** * Attempt to reconnect to a leaderboard * * @param leaderboardId - The leaderboard to reconnect to * @param handlers - Event handlers * @param options - Connection options */ attemptReconnection(leaderboardId, handlers, options) { const attempts = this.reconnectAttempts.get(leaderboardId) || 0; if (attempts >= (this.config.maxRetries || 3)) { console.error("[LeaderboardSSE] Max reconnection attempts reached:", leaderboardId); this.disconnect(leaderboardId); return; } const delay = Math.min(1e3 * Math.pow(2, attempts), 3e4); this.reconnectAttempts.set(leaderboardId, attempts + 1); console.debug("[LeaderboardSSE] Scheduling reconnection:", { leaderboardId, attempt: attempts + 1, delay }); const timer = setTimeout(() => { console.debug("[LeaderboardSSE] Attempting reconnection:", leaderboardId); this.connect(leaderboardId, handlers, options); this.reconnectTimers.delete(leaderboardId); }, delay); this.reconnectTimers.set(leaderboardId, timer); } }; __name(_LeaderboardSSE, "LeaderboardSSE"); var LeaderboardSSE = _LeaderboardSSE; // src/offline-queue.ts var _ChromeStorageAdapter = class _ChromeStorageAdapter { async get(key) { if (typeof chrome !== "undefined" && chrome.storage && chrome.storage.sync) { return new Promise((resolve) => { chrome.storage.sync.get(key, (result) => { resolve(result[key] || []); }); }); } const stored = localStorage.getItem(key); return stored ? JSON.parse(stored) : []; } async set(key, value) { if (typeof chrome !== "undefined" && chrome.storage && chrome.storage.sync) { return new Promise((resolve, reject) => { chrome.storage.sync.set({ [key]: value }, () => { if (chrome.runtime.lastError) { reject(new Error(chrome.runtime.lastError.message)); } else { resolve(); } }); }); } localStorage.setItem(key, JSON.stringify(value)); } async clear(key) { if (typeof chrome !== "undefined" && chrome.storage && chrome.storage.sync) { return new Promise((resolve) => { chrome.storage.sync.remove(key, () => resolve()); }); } localStorage.removeItem(key); } }; __name(_ChromeStorageAdapter, "ChromeStorageAdapter"); var ChromeStorageAdapter = _ChromeStorageAdapter; var _OfflineQueue = class _OfflineQueue { constructor(apiKey) { this.queue = []; this.processing = false; this.eventHandlers = /* @__PURE__ */ new Map(); this.MAX_QUEUE_SIZE = 1e3; this.QUEUE_TTL_MS = 24 * 60 * 60 * 1e3; // 24 hours this.MAX_BATCH_SIZE = 100; this.storageKey = `gl_queue_${this.hashApiKey(apiKey)}`; this.storage = new ChromeStorageAdapter(); this.loadQueue(); } /** * Add an operation to the queue */ async enqueue(operation) { const queueId = ulid(); const queuedOp = { ...operation, queueId, timestamp: Date.now(), retryCount: 0 }; if (this.queue.length >= this.MAX_QUEUE_SIZE) { throw new Error(`Queue is full (max ${this.MAX_QUEUE_SIZE} items)`); } this.queue.push(queuedOp); await this.persistQueue(); this.emit("queue:added", queuedOp); return { queued: true, queueId, queuePosition: this.queue.length, operation: "insert", rank: -1 }; } /** * Check if queue has items */ hasItems() { return this.queue.length > 0; } /** * Get queue size */ size() { return this.queue.length; } /** * Check if currently processing */ isProcessing() { return this.processing; } /** * Get all queued operations */ getQueue() { return [...this.queue]; } /** * Group operations by leaderboard for intelligent batching */ batchOperations() { const groups = /* @__PURE__ */ new Map(); this.cleanExpiredItems(); for (const op of this.queue) { if (op.method === "submit" && op.params.leaderboardId) { const key = op.params.leaderboardId; if (!groups.has(key)) { groups.set(key, []); } groups.get(key).push(op); } else if (op.method === "submitBulk") { groups.set(`bulk_${op.queueId}`, [op]); } } const limitedGroups = /* @__PURE__ */ new Map(); for (const [key, ops] of groups) { if (ops.length > this.MAX_BATCH_SIZE) { for (let i = 0; i < ops.length; i += this.MAX_BATCH_SIZE) { limitedGroups.set(`${key}_${i}`, ops.slice(i, i + this.MAX_BATCH_SIZE)); } } else { limitedGroups.set(key, ops); } } return limitedGroups; } /** * Mark operations as processed and remove from queue */ async removeProcessed(queueIds) { const idSet = new Set(queueIds); this.queue = this.queue.filter((op) => !idSet.has(op.queueId)); await this.persistQueue(); } /** * Clear the entire queue */ async clear() { this.queue = []; await this.storage.clear(this.storageKey); } /** * Mark operation as failed and increment retry count */ async markFailed(queueId, permanent = false) { const op = this.queue.find((o) => o.queueId === queueId); if (op) { if (permanent) { this.queue = this.queue.filter((o) => o.queueId !== queueId); this.emit("queue:failed", { operation: op, permanent: true }); } else { op.retryCount = (op.retryCount || 0) + 1; } await this.persistQueue(); } } /** * Set processing state */ setProcessing(processing) { this.processing = processing; } /** * Register event handler */ on(event, handler) { if (!this.eventHandlers.has(event)) { this.eventHandlers.set(event, /* @__PURE__ */ new Set()); } this.eventHandlers.get(event).add(handler); } /** * Unregister event handler */ off(event, handler) { this.eventHandlers.get(event)?.delete(handler); } /** * Emit event * @internal */ emit(event, data) { this.eventHandlers.get(event)?.forEach((handler) => { try { handler(event, data); } catch (error) { console.error(`Error in queue event handler for ${event}:`, error); } }); } /** * Load queue from storage */ async loadQueue() { try { this.queue = await this.storage.get(this.storageKey); this.cleanExpiredItems(); } catch (error) { console.error("Failed to load offline queue:", error); this.queue = []; } } /** * Persist queue to storage */ async persistQueue() { try { await this.storage.set(this.storageKey, this.queue); } catch (error) { console.error("Failed to persist offline queue:", error); } } /** * Clean expired items from queue */ cleanExpiredItems() { const now = Date.now(); const originalSize = this.queue.length; this.queue = this.queue.filter((op) => { const age = now - op.timestamp; return age < this.QUEUE_TTL_MS; }); if (this.queue.length < originalSize) { this.persistQueue(); } } /** * Simple hash function for API key */ hashApiKey(apiKey) { let hash = 0; for (let i = 0; i < apiKey.length; i++) { const char = apiKey.charCodeAt(i); hash = (hash << 5) - hash + char; hash = hash & hash; } return Math.abs(hash).toString(36); } }; __name(_OfflineQueue, "OfflineQueue"); var OfflineQueue = _OfflineQueue; // src/index.ts var _GlobalLeaderboards = class _GlobalLeaderboards { /** * Create a new GlobalLeaderboards SDK instance * * @param apiKey - Your API key from GlobalLeaderboards.net * @param config - Optional configuration options * @param config.defaultLeaderboardId - Default leaderboard ID for simplified submit() calls * @param config.baseUrl - API base URL (default: https://api.globalleaderboards.net) * @param config.wsUrl - WebSocket URL (default: wss://api.globalleaderboards.net) * @param config.timeout - Request timeout in ms (default: 30000) * @param config.autoRetry - Enable automatic retry (default: true) * @param config.maxRetries - Maximum retry attempts (default: 3) * * @example * ```typescript * const leaderboard = new GlobalLeaderboards('your-api-key', { * timeout: 60000 * }) * ``` */ constructor(apiKey, config) { this.wsClient = null; this.sseClient = null; this.isOnline = true; this.version = version; this.config = { apiKey, defaultLeaderboardId: config?.defaultLeaderboardId, baseUrl: config?.baseUrl || "https://api.globalleaderboards.net", wsUrl: config?.wsUrl || "wss://api.globalleaderboards.net", timeout: config?.timeout || 3e4, autoRetry: config?.autoRetry ?? true, maxRetries: config?.maxRetries || 3 }; this.offlineQueue = new OfflineQueue(apiKey); this.setupNetworkDetection(); this.isOnline = typeof navigator !== "undefined" ? navigator.onLine : true; } /** * Submit a score to a leaderboard with validation * * Supports three signature variations: * - `submit(userId, score)` - Uses default leaderboard ID * - `submit(userId, score, leaderboardId)` - Specify leaderboard * - `submit(userId, score, options)` - Full options object * * When offline or queue not empty, submissions are queued and processed when online. * * @param userId - Unique user identifier * @param score - Score value (must be >= 0) * @param leaderboardIdOrOptions - Leaderboard ID string or options object * @returns Score submission response or queued response * @throws {GlobalLeaderboardsError} If validation fails * * @example * ```typescript * // Using default leaderboard * await leaderboard.submit('user-123', 1500) * * // Specify leaderboard * await leaderboard.submit('user-123', 1500, 'leaderboard-456') * * // Full options * await leaderboard.submit('user-123', 1500, { * leaderboardId: 'leaderboard-456', * userName: 'PlayerOne', * metadata: { level: 5 } * }) * ``` */ async submit(userId, score, leaderboardIdOrOptions) { if (score < 0) { throw new GlobalLeaderboardsError( "Score must be greater than or equal to 0", "INVALID_SCORE" ); } let options; if (typeof leaderboardIdOrOptions === "string") { options = { leaderboardId: leaderboardIdOrOptions }; } else if (leaderboardIdOrOptions) { options = leaderboardIdOrOptions; } else { options = {}; } const leaderboardId = options.leaderboardId || this.config.defaultLeaderboardId; if (!leaderboardId) { throw new GlobalLeaderboardsError( "Leaderboard ID is required (specify in options or set defaultLeaderboardId in constructor)", "MISSING_LEADERBOARD_ID" ); } const userName = options.userName || userId; const userNamePattern = /^[a-zA-Z0-9\u00C0-\u017F()._\- ]+$/; if (!userNamePattern.test(userName)) { throw new GlobalLeaderboardsError( "Username contains invalid characters. Only alphanumeric, accented letters, parentheses, dots, underscores, hyphens, and spaces are allowed", "INVALID_USERNAME" ); } if (userName.length < 1 || userName.length > 50) { throw new GlobalLeaderboardsError( "Username must be between 1 and 50 characters", "INVALID_USERNAME_LENGTH" ); } if (!this.isOnline || this.offlineQueue.hasItems()) { return this.offlineQueue.enqueue({ method: "submit", params: { userId, score, leaderboardId, userName, metadata: options.metadata } }); } const request = { leaderboard_id: leaderboardId, user_id: userId, user_name: userName, score, metadata: options.metadata }; return this.request("POST", "/v1/scores", request); } /** * Get paginated leaderboard entries * * @param leaderboardId - Leaderboard ID to retrieve * @param options - Query options * @param options.page - Page number (default: 1) * @param options.limit - Results per page (default: 20, max: 100) * @param options.aroundUser - Center results around specific user ID * @returns Leaderboard entries with pagination info * @throws {GlobalLeaderboardsError} If API returns an error * * @example * ```typescript * const data = await leaderboard.getLeaderboard('leaderboard-456', { * page: 1, * limit: 10, * aroundUser: 'user-123' * }) * ``` */ async getLeaderboard(leaderboardId, options) { const params = new URLSearchParams(); if (options?.page) params.append("page", options.page.toString()); if (options?.limit) params.append("limit", options.limit.toString()); if (options?.aroundUser) params.append("around_user", options.aroundUser); const query = params.toString(); const path = `/v1/leaderboards/${leaderboardId}/scores${query ? `?${query}` : ""}`; return this.request("GET", path); } /** * Submit multiple scores in bulk for better performance * * Accepts mixed formats for flexibility: * - `[userId, score]` - Uses default leaderboard * - `[userId, score, leaderboardId]` - Specify leaderboard * - Full object with all options * * @param submissions - Array of score submissions in various formats (max 100) * @returns Bulk submission response with individual results and summary * @throws {GlobalLeaderboardsError} If validation fails or API returns an error * * @example * ```typescript * const results = await leaderboard.submitBulk([ * ['user-123', 1000], // Uses default leaderboard * ['user-456', 2000, 'leaderboard-789'], // Specific leaderboard * { // Full options * userId: 'user-789', * score: 3000, * leaderboardId: 'leaderboard-789', * userName: 'TopPlayer', * metadata: { level: 10 } * } * ]) * ``` */ async submitBulk(submissions) { const scores = submissions.map((submission) => { if (Array.isArray(submission)) { const [userId, score, leaderboardId] = submission; const finalLeaderboardId = leaderboardId || this.config.defaultLeaderboardId; if (!finalLeaderboardId) { throw new GlobalLeaderboardsError( "Leaderboard ID is required for bulk submission", "MISSING_LEADERBOARD_ID" ); } return { user_id: userId, user_name: userId, // Default to userId score, leaderboard_id: finalLeaderboardId }; } else { const leaderboardId = submission.leaderboardId || this.config.defaultLeaderboardId; if (!leaderboardId) { throw new GlobalLeaderboardsError( "Leaderboard ID is required for bulk submission", "MISSING_LEADERBOARD_ID" ); } return { user_id: submission.userId, user_name: submission.userName || submission.userId, score: submission.score, leaderboard_id: leaderboardId, metadata: submission.metadata }; } }); if (!this.isOnline || this.offlineQueue.hasItems()) { throw new GlobalLeaderboardsError( "Bulk submissions cannot be queued offline. Please retry when online.", "OFFLINE_BULK_NOT_SUPPORTED" ); } const request = { scores }; return this.request("POST", "/v1/scores/bulk", request); } /** * Get all scores for a user across leaderboards * * @param userId - User ID to get scores for * @param options - Query options * @param options.page - Page number (default: 1) * @param options.limit - Results per page (default: 20) * @returns User scores with pagination and summary stats * @throws {GlobalLeaderboardsError} If API returns an error * * @example * ```typescript * const userScores = await leaderboard.getUserScores('user-123', { * page: 1, * limit: 50 * }) * ``` */ async getUserScores(userId, options) { const params = new URLSearchParams(); if (options?.page) params.append("page", options.page.toString()); if (options?.limit) params.append("limit", options.limit.toString()); const query = params.toString(); const path = `/v1/scores/user/${userId}${query ? `?${query}` : ""}`; return this.request("GET", path); } /** * Connect to WebSocket for real-time leaderboard updates * * WebSocket connections now work through the main API domain with full * Cloudflare proxy protection. Both WebSocket and SSE are supported options * for real-time updates. Choose based on your specific needs: * - WebSocket: Lower latency, binary support, bidirectional potential * - SSE: Simpler implementation, automatic reconnection, better firewall compatibility * * @param handlers - Event handlers for WebSocket events * @param handlers.onConnect - Called when connection is established * @param handlers.onDisconnect - Called when connection is closed * @param handlers.onError - Called on errors * @param handlers.onLeaderboardUpdate - Called when leaderboard data changes * @param handlers.onUserRankUpdate - Called when user's rank changes * @param handlers.onMessage - Called for any WebSocket message * @param options - Connection options * @param options.leaderboardId - Initial leaderboard to subscribe to * @param options.userId - User ID for personalized updates * @param options.maxReconnectAttempts - Max reconnection attempts * @param options.reconnectDelay - Delay between reconnection attempts in ms * @returns WebSocket client instance * * @example * ```typescript * const ws = leaderboard.connectWebSocket({ * onConnect: () => console.log('Connected'), * onLeaderboardUpdate: (data) => console.log('Update:', data) * }, { * leaderboardId: 'leaderboard-456' * }) * ``` * * @see connectSSE - Recommended alternative for real-time updates */ connectWebSocket(handlers, options) { if (this.wsClient) { this.wsClient.disconnect(); } this.wsClient = new LeaderboardWebSocket( this.config.wsUrl, this.config.apiKey, { maxReconnectAttempts: options?.maxReconnectAttempts, reconnectDelay: options?.reconnectDelay } ); this.wsClient.on(handlers); this.wsClient.connect(options?.leaderboardId, options?.userId); return this.wsClient; } /** * Disconnect from WebSocket * * @example * ```typescript * leaderboard.disconnectWebSocket() * ``` */ disconnectWebSocket() { if (this.wsClient) { this.wsClient.disconnect(); this.wsClient = null; } } /** * Connect to Server-Sent Events (SSE) for real-time leaderboard updates * * This is the recommended method for real-time updates. SSE provides: * - Simpler implementation compared to WebSocket * - Automatic reconnection with exponential backoff * - Better firewall and proxy compatibility * - Lower resource usage * - Built-in heartbeat for connection health * * @param leaderboardId - Leaderboard to connect to * @param handlers - Event handlers for SSE events * @param handlers.onConnect - Called when connection is established * @param handlers.onDisconnect - Called when connection is closed * @param handlers.onError - Called on errors * @param handlers.onLeaderboardUpdate - Called when leaderboard data changes * @param handlers.onUserRankUpdate - Called when user's rank changes * @param handlers.onHeartbeat - Called on heartbeat (optional) * @param handlers.onMessage - Raw message handler (optional) * @param options - Connection options * @param options.userId - User ID for personalized updates * @param options.includeMetadata - Include metadata in updates (default: true) * @param options.topN - Number of top scores to include in refresh events (default: 10) * @returns SSE connection object with close method * * @example * ```typescript * const connection = leaderboard.connectSSE('leaderboard-123', { * onLeaderboardUpdate: (data) => { * console.log('Top scores:', data.topScores) * }, * onUserRankUpdate: (data) => { * console.log('Rank changed:', data) * } * }) * * // Later... * connection.close() * ``` */ connectSSE(leaderboardId, handlers, options) { if (!this.sseClient) { this.sseClient = new LeaderboardSSE(this.config); } return this.sseClient.connect(leaderboardId, handlers, options); } /** * Disconnect from all SSE connections * * @example * ```typescript * leaderboard.disconnectSSE() * ``` */ disconnectSSE() { if (this.sseClient) { this.sseClient.disconnectAll(); this.sseClient = null; } } /** * Generate a new ULID (Universally Unique Lexicographically Sortable Identifier) * * @returns A new ULID string * * @example * ```typescript * const id = leaderboard.generateId() * // Returns: '01ARZ3NDEKTSV4RRFFQ69G5FAV' * ``` */ generateId() { return ulid(); } /** * Get API information and available endpoints * * @returns API info including version, endpoints, and documentation URL * @throws {GlobalLeaderboardsError} If API returns an error * * @remarks No authentication required for this endpoint * * @example * ```typescript * const info = await leaderboard.getApiInfo() * console.log('API Version:', info.version) * ``` */ async getApiInfo() { const url = `${this.config.baseUrl}/`; try { const controller = new AbortController(); const timeoutId = setTimeout( () => controller.abort(), this.config.timeout ); const response = await fetch(url, { method: "GET", signal: controller.signal }); clearTimeout(timeoutId); if (!response.ok) { throw new GlobalLeaderboardsError( `Failed to get API info: HTTP ${response.status}`, "API_INFO_FAILED", response.status ); } return await response.json(); } catch (error) { if (error instanceof GlobalLeaderboardsError) { throw error; } else if (error instanceof Error) { if (error.name === "AbortError") { throw new GlobalLeaderboardsError( "API info request timeout", "TIMEOUT" ); } throw new GlobalLeaderboardsError( error.message, "API_INFO_ERROR" ); } else { throw new GlobalLeaderboardsError( "Unknown error getting API info", "UNKNOWN_ERROR" ); } } } /** * Perform a basic health check on the API * * @returns Health status with version and timestamp * @throws {GlobalLeaderboardsError} If health check fails * * @remarks No authentication required for this endpoint * * @example * ```typescript * const health = await leaderboard.health() * if (health.status === 'healthy') { * console.log('API is healthy') * } * ``` */ async health() { const url = `${this.config.baseUrl}/health`; try { const controller = new AbortController(); const timeoutId = setTimeout( () => controller.abort(), this.config.timeout ); const response = await fetch(url, { method: "GET", signal: controller.signal }); clearTimeout(timeoutId); if (!response.ok) { throw new GlobalLeaderboardsError( `Health check failed: HTTP ${response.status}`, "HEALTH_CHECK_FAILED", response.status ); } return await response.json(); } catch (error) { if (error instanceof GlobalLeaderboardsError) { throw error; } else if (error instanceof Error) { if (error.name === "AbortError") { throw new GlobalLeaderboardsError( "Health check timeout", "TIMEOUT" ); } throw new GlobalLeaderboardsError( error.message, "HEALTH_CHECK_ERROR" ); } else { throw new GlobalLeaderboardsError( "Unknown error during health check", "UNKNOWN_ERROR" ); } } } /** * Perform a detailed health check with individual service statuses * * @returns Detailed health info including database, cache, and storage status * @throws {GlobalLeaderboardsError} If health check fails * * @remarks No authentication required for this endpoint * * @example * ```typescript * const health = await leaderboard.healthDetailed() * console.log('Database:', health.services.database.status) * console.log('Cache:', health.services.cache.status) * ``` */ async healthDetailed() { const url = `${this.config.baseUrl}/health/detailed`; try { const controller = new AbortController(); const timeoutId = setTimeout( () => controller.abort(), this.config.timeout ); const response = await fetch(url, { method: "GET", signal: controller.signal }); clearTimeout(timeoutId); if (!response.ok) { throw new GlobalLeaderboardsError( `Detailed health check failed: HTTP ${response.status}`, "HEALTH_CHECK_FAILED", response.status ); } return await response.json(); } catch (error) { if (error instanceof GlobalLeaderboardsError) { throw error; } else if (error instanceof Error) { if (error.name === "AbortError") { throw new GlobalLeaderboardsError( "Detailed health check timeout", "TIMEOUT" ); } throw new GlobalLeaderboardsError( error.message, "HEALTH_CHECK_ERROR" ); } else { throw new GlobalLeaderboardsError( "Unknown error during detailed health check", "UNKNOWN_ERROR" ); } } } /** * Make an authenticated API request with automatic retry * * @private * @param method - HTTP method * @param path - API endpoint path * @param body - Request body (optional) * @param retryCount - Current retry attempt (internal use) * @returns API response * @throws {GlobalLeaderboardsError} If request fails */ async request(method, path, body, retryCount = 0) { const url = `${this.config.baseUrl}${path}`; const headers = { "Authorization": `Bearer ${this.config.apiKey}`, "Content-Type": "application/json" }; try { const controller = new AbortController(); const timeoutId = setTimeout( () => controller.abort(), this.config.timeout ); const response = await fetch(url, { method, headers, body: body ? JSON.stringify(body) : void 0, signal: controller.signal }); clearTimeout(timeoutId); if (!response.ok) { const errorData = await response.json(); throw new GlobalLeaderboardsError( errorData.error.message || `HTTP ${response.status}`, errorData.error.code || "HTTP_ERROR", response.status, errorData.error.details ); } return await response.json(); } catch (error) { if (this.config.autoRetry && retryCount < this.config.maxRetries && this.shouldRetry(error)) { const delay = Math.min(1e3 * Math.pow(2, retryCount), 1e4); await new Promise((resolve) => setTimeout(resolve, delay)); return this.request(method, path, body, retryCount + 1); } if (error instanceof GlobalLeaderboardsError) { throw error; } else if (error instanceof Error) { if (error.name === "AbortError") { throw new GlobalLeaderboardsError( "Request timeout", "TIMEOUT", void 0, { timeout: this.config.timeout } ); } throw new GlobalLeaderboardsError( error.message, "REQUEST_ERROR" ); } else { throw new GlobalLeaderboardsError( "Unknown error occurred", "UNKNOWN_ERROR" ); } } } /** * Check if an error is retryable * * @private * @param error - Error to check * @returns True if error is retryable (5xx, 429, 408, timeout) */ shouldRetry(error) { if (error instanceof GlobalLeaderboardsError) { return error.statusCode && error.statusCode >= 500 || error.statusCode === 429 || // Rate limited error.statusCode === 408 || // Request timeout error.code === "TIMEOUT" || error.code === "REQUEST_ERROR"; } return false; } /** * Set up network detection and automatic queue processing * @private */ setupNetworkDetection() { if (typeof window === "undefined") { return; } window.addEventListener("online", () => { console.debug("[GlobalLeaderboards] Network is online"); this.isOnline = true; this.processOfflineQueue(); }); window.addEventListener("offline", () => { console.debug("[GlobalLeaderboards] Network is offline"); this.isOnline = false; }); } /** * Process the offline queue * @private */ async processOfflineQueue() { if (!this.isOnline || this.offlineQueue.isProcessing()) { return; } this.offlineQueue.setProcessing(true); try { const batches = this.offlineQueue.batchOperations(); let t