UNPKG

@globalleaderboards/sdk

Version:

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

1 lines 111 kB
{"version":3,"sources":["../../../node_modules/ulid/dist/index.esm.js","../package.json","../src/types.ts","../src/websocket.ts","../src/sse.ts","../src/offline-queue.ts","../src/index.ts"],"sourcesContent":["function createError(message) {\n const err = new Error(message);\n err.source = \"ulid\";\n return err;\n}\n// These values should NEVER change. If\n// they do, we're no longer making ulids!\nconst ENCODING = \"0123456789ABCDEFGHJKMNPQRSTVWXYZ\"; // Crockford's Base32\nconst ENCODING_LEN = ENCODING.length;\nconst TIME_MAX = Math.pow(2, 48) - 1;\nconst TIME_LEN = 10;\nconst RANDOM_LEN = 16;\nfunction replaceCharAt(str, index, char) {\n if (index > str.length - 1) {\n return str;\n }\n return str.substr(0, index) + char + str.substr(index + 1);\n}\nfunction incrementBase32(str) {\n let done = undefined;\n let index = str.length;\n let char;\n let charIndex;\n const maxCharIndex = ENCODING_LEN - 1;\n while (!done && index-- >= 0) {\n char = str[index];\n charIndex = ENCODING.indexOf(char);\n if (charIndex === -1) {\n throw createError(\"incorrectly encoded string\");\n }\n if (charIndex === maxCharIndex) {\n str = replaceCharAt(str, index, ENCODING[0]);\n continue;\n }\n done = replaceCharAt(str, index, ENCODING[charIndex + 1]);\n }\n if (typeof done === \"string\") {\n return done;\n }\n throw createError(\"cannot increment this string\");\n}\nfunction randomChar(prng) {\n let rand = Math.floor(prng() * ENCODING_LEN);\n if (rand === ENCODING_LEN) {\n rand = ENCODING_LEN - 1;\n }\n return ENCODING.charAt(rand);\n}\nfunction encodeTime(now, len) {\n if (isNaN(now)) {\n throw new Error(now + \" must be a number\");\n }\n if (now > TIME_MAX) {\n throw createError(\"cannot encode time greater than \" + TIME_MAX);\n }\n if (now < 0) {\n throw createError(\"time must be positive\");\n }\n if (Number.isInteger(Number(now)) === false) {\n throw createError(\"time must be an integer\");\n }\n let mod;\n let str = \"\";\n for (; len > 0; len--) {\n mod = now % ENCODING_LEN;\n str = ENCODING.charAt(mod) + str;\n now = (now - mod) / ENCODING_LEN;\n }\n return str;\n}\nfunction encodeRandom(len, prng) {\n let str = \"\";\n for (; len > 0; len--) {\n str = randomChar(prng) + str;\n }\n return str;\n}\nfunction decodeTime(id) {\n if (id.length !== TIME_LEN + RANDOM_LEN) {\n throw createError(\"malformed ulid\");\n }\n var time = id\n .substr(0, TIME_LEN)\n .split(\"\")\n .reverse()\n .reduce((carry, char, index) => {\n const encodingIndex = ENCODING.indexOf(char);\n if (encodingIndex === -1) {\n throw createError(\"invalid character found: \" + char);\n }\n return (carry += encodingIndex * Math.pow(ENCODING_LEN, index));\n }, 0);\n if (time > TIME_MAX) {\n throw createError(\"malformed ulid, timestamp too large\");\n }\n return time;\n}\nfunction detectPrng(allowInsecure = false, root) {\n if (!root) {\n root = typeof window !== \"undefined\" ? window : null;\n }\n const browserCrypto = root && (root.crypto || root.msCrypto);\n if (browserCrypto) {\n return () => {\n const buffer = new Uint8Array(1);\n browserCrypto.getRandomValues(buffer);\n return buffer[0] / 0xff;\n };\n }\n else {\n try {\n const nodeCrypto = require(\"crypto\");\n return () => nodeCrypto.randomBytes(1).readUInt8() / 0xff;\n }\n catch (e) { }\n }\n if (allowInsecure) {\n try {\n console.error(\"secure crypto unusable, falling back to insecure Math.random()!\");\n }\n catch (e) { }\n return () => Math.random();\n }\n throw createError(\"secure crypto unusable, insecure Math.random not allowed\");\n}\nfunction factory(currPrng) {\n if (!currPrng) {\n currPrng = detectPrng();\n }\n return function ulid(seedTime) {\n if (isNaN(seedTime)) {\n seedTime = Date.now();\n }\n return encodeTime(seedTime, TIME_LEN) + encodeRandom(RANDOM_LEN, currPrng);\n };\n}\nfunction monotonicFactory(currPrng) {\n if (!currPrng) {\n currPrng = detectPrng();\n }\n let lastTime = 0;\n let lastRandom;\n return function ulid(seedTime) {\n if (isNaN(seedTime)) {\n seedTime = Date.now();\n }\n if (seedTime <= lastTime) {\n const incrementedRandom = (lastRandom = incrementBase32(lastRandom));\n return encodeTime(lastTime, TIME_LEN) + incrementedRandom;\n }\n lastTime = seedTime;\n const newRandom = (lastRandom = encodeRandom(RANDOM_LEN, currPrng));\n return encodeTime(seedTime, TIME_LEN) + newRandom;\n };\n}\nconst ulid = factory();\n\nexport { decodeTime, detectPrng, encodeRandom, encodeTime, factory, incrementBase32, monotonicFactory, randomChar, replaceCharAt, ulid };\n","{\n \"name\": \"@globalleaderboards/sdk\",\n \"version\": \"0.5.0\",\n \"description\": \"Official SDK for GlobalLeaderboards.net - Add competitive leaderboards to any application\",\n \"main\": \"dist/index.js\",\n \"module\": \"dist/index.mjs\",\n \"types\": \"dist/index.d.ts\",\n \"files\": [\n \"dist\",\n \"README.md\"\n ],\n \"scripts\": {\n \"build\": \"tsup\",\n \"dev\": \"tsup --watch\",\n \"docs\": \"yarn docs:html && yarn docs:markdown\",\n \"docs:html\": \"typedoc --options typedoc-html.json\",\n \"docs:markdown\": \"typedoc --options typedoc-markdown.json\",\n \"docs:watch\": \"typedoc --options typedoc-html.json --watch\",\n \"lint\": \"tsc --noEmit\",\n \"prepublishOnly\": \"yarn build\",\n \"test\": \"vitest\",\n \"test:coverage\": \"vitest run --coverage\",\n \"typecheck\": \"tsc --noEmit\"\n },\n \"keywords\": [\n \"leaderboards\",\n \"gaming\",\n \"highscores\",\n \"sdk\",\n \"api\",\n \"realtime\",\n \"websocket\",\n \"competitive\"\n ],\n \"author\": \"GlobalLeaderboards.net\",\n \"license\": \"MIT\",\n \"repository\": {\n \"type\": \"git\",\n \"url\": \"git@github.com:GlobalLeaderboards/sdk.git\"\n },\n \"bugs\": {\n \"url\": \"https://github.com/GlobalLeaderboards/sdk/issues\"\n },\n \"homepage\": \"https://globalleaderboards.net\",\n \"dependencies\": {\n \"ulid\": \"^2.3.0\"\n },\n \"devDependencies\": {\n \"@types/node\": \"^20.0.0\",\n \"@typescript-eslint/eslint-plugin\": \"^6.0.0\",\n \"@typescript-eslint/parser\": \"^6.0.0\",\n \"@vitest/coverage-v8\": \"^1.0.0\",\n \"barva\": \"^1.1.0\",\n \"eslint\": \"^8.0.0\",\n \"tsup\": \"^8.0.0\",\n \"typedoc\": \"^0.28.7\",\n \"typedoc-plugin-markdown\": \"^4.7.1\",\n \"typescript\": \"^5.0.0\",\n \"vitest\": \"^1.0.0\"\n },\n \"engines\": {\n \"node\": \">=16.0.0\"\n },\n \"publishConfig\": {\n \"access\": \"public\",\n \"registry\": \"https://registry.npmjs.org/\"\n }\n}\n","/**\n * Configuration options for the GlobalLeaderboards SDK\n */\nexport interface GlobalLeaderboardsConfig {\n /** API key for authentication */\n apiKey: string\n /** Default leaderboard ID for simplified submit() calls */\n defaultLeaderboardId?: string\n /** Base URL for the API (default: https://api.globalleaderboards.net) */\n baseUrl?: string\n /** WebSocket URL (default: wss://api.globalleaderboards.net) */\n wsUrl?: string\n /** Request timeout in milliseconds (default: 30000) */\n timeout?: number\n /** Enable automatic retry on failure (default: true) */\n autoRetry?: boolean\n /** Maximum number of retry attempts (default: 3) */\n maxRetries?: number\n}\n\n/**\n * Score submission request\n */\nexport interface SubmitScoreRequest {\n /** Leaderboard ID */\n leaderboard_id: string\n /** User ID (ULID format) */\n user_id: string\n /** Display name for the user */\n user_name: string\n /** Score value */\n score: number\n /** Optional metadata to store with the score */\n metadata?: Record<string, unknown>\n}\n\n/**\n * Score submission response\n */\nexport interface SubmitScoreResponse {\n /** Operation performed (insert, update, no_change) */\n operation: 'insert' | 'update' | 'no_change'\n /** User's new rank on the leaderboard */\n rank: number\n /** Previous score if update operation */\n previous_score?: number\n /** Score improvement if update operation */\n improvement?: number\n}\n\n/**\n * Queued score submission response\n */\nexport interface QueuedSubmitResponse extends SubmitScoreResponse {\n /** Indicates this submission was queued */\n queued: true\n /** Unique queue ID */\n queueId: string\n /** Position in queue */\n queuePosition: number\n}\n\n/**\n * Flexible score submission format\n */\nexport type FlexibleScoreSubmission = \n | [userId: string, score: number]\n | [userId: string, score: number, leaderboardId: string]\n | {\n userId: string\n score: number\n leaderboardId?: string\n userName?: string\n metadata?: Record<string, unknown>\n }\n\n/**\n * Bulk score submission request\n */\nexport interface BulkSubmitScoreRequest {\n /** Array of scores to submit */\n scores: SubmitScoreRequest[]\n}\n\n/**\n * Single score result in bulk response\n */\nexport interface BulkScoreResult extends SubmitScoreResponse {\n /** Leaderboard ID */\n leaderboard_id: string\n /** User ID */\n user_id: string\n}\n\n/**\n * Bulk score submission response\n */\nexport interface BulkSubmitScoreResponse {\n /** Individual results for each score */\n results: BulkScoreResult[]\n /** Summary statistics */\n summary: {\n /** Total scores submitted */\n total: number\n /** Successful submissions */\n successful: number\n /** Failed submissions */\n failed: number\n }\n}\n\n/**\n * Extended leaderboard entry with leaderboard info\n */\nexport interface UserScoreEntry extends LeaderboardEntry {\n /** Leaderboard ID */\n leaderboard_id: string\n /** Leaderboard name */\n leaderboard_name: string\n}\n\n/**\n * User scores response\n */\nexport interface UserScoresResponse {\n /** User's scores across leaderboards */\n data: UserScoreEntry[]\n /** Pagination information */\n pagination: {\n page: number\n limit: number\n total: number\n pages: number\n }\n /** User summary */\n user: {\n /** User ID */\n id: string\n /** Total number of scores */\n total_scores: number\n /** Best rank achieved */\n best_rank: number\n }\n}\n\n/**\n * Leaderboard entry\n */\nexport interface LeaderboardEntry {\n /** User ID */\n user_id: string\n /** User display name */\n user_name: string\n /** Score value */\n score: number\n /** Rank position */\n rank: number\n /** Timestamp when score was submitted */\n timestamp: string\n /** Optional metadata stored with the score */\n metadata?: Record<string, unknown>\n}\n\n/**\n * Leaderboard data\n */\nexport interface LeaderboardData {\n /** Leaderboard ID */\n id: string\n /** Leaderboard name */\n name: string\n /** Total number of entries */\n total_entries: number\n /** Last update timestamp */\n last_updated?: string\n}\n\n/**\n * Leaderboard entries response - matches OpenAPI LeaderboardEntriesResponse\n */\nexport interface LeaderboardEntriesResponse {\n /** Leaderboard entries */\n data: LeaderboardEntry[]\n /** Pagination information */\n pagination: {\n page: number\n limit: number\n total: number\n pages: number\n }\n /** Leaderboard information */\n leaderboard: LeaderboardData\n}\n\n/**\n * WebSocket message types - matches OpenAPI spec\n */\nexport type WebSocketMessageType = \n | 'subscribe'\n | 'unsubscribe'\n | 'ping'\n | 'pong'\n | 'leaderboard_update'\n | 'user_rank_update'\n | 'error'\n | 'connection_info'\n | 'update'\n | 'score_submission'\n\n/**\n * Base WebSocket message\n */\nexport interface WebSocketMessage {\n type: WebSocketMessageType\n}\n\n/**\n * Subscribe message\n */\nexport interface SubscribeMessage extends WebSocketMessage {\n type: 'subscribe'\n leaderboard_id: string\n user_id?: string\n}\n\n/**\n * Unsubscribe message\n */\nexport interface UnsubscribeMessage extends WebSocketMessage {\n type: 'unsubscribe'\n leaderboard_id: string\n}\n\n/**\n * Mutation types for leaderboard changes\n */\nexport type MutationType = 'new_entry' | 'rank_change' | 'score_update' | 'username_change' | 'removed'\n\n/**\n * Base mutation interface\n */\nexport interface BaseMutation {\n type: MutationType\n userId: string\n}\n\n/**\n * New entry mutation\n */\nexport interface NewEntryMutation extends BaseMutation {\n type: 'new_entry'\n newRank: number\n score: number\n userName: string\n}\n\n/**\n * Rank change mutation\n */\nexport interface RankChangeMutation extends BaseMutation {\n type: 'rank_change'\n previousRank: number\n newRank: number\n score: number\n}\n\n/**\n * Score update mutation\n */\nexport interface ScoreUpdateMutation extends BaseMutation {\n type: 'score_update'\n previousScore: number\n newScore: number\n previousRank: number\n newRank: number\n}\n\n/**\n * Username change mutation\n */\nexport interface UsernameChangeMutation extends BaseMutation {\n type: 'username_change'\n previousUsername: string\n newUsername: string\n rank: number\n}\n\n/**\n * Removed mutation\n */\nexport interface RemovedMutation extends BaseMutation {\n type: 'removed'\n previousRank: number\n score: number\n}\n\n/**\n * Union type for all mutations\n */\nexport type LeaderboardMutation = \n | NewEntryMutation\n | RankChangeMutation\n | ScoreUpdateMutation\n | UsernameChangeMutation\n | RemovedMutation\n\n/**\n * Update trigger information\n */\nexport interface UpdateTrigger {\n type: 'score_submission' | 'bulk_submission' | 'admin_action' | 'leaderboard_reset'\n submissions?: Array<{\n userId: string\n userName: string\n score: number\n previousScore?: number\n timestamp: string\n }>\n}\n\n/**\n * Leaderboard update message with full state and mutations\n */\nexport interface LeaderboardUpdateMessage extends WebSocketMessage {\n type: 'leaderboard_update'\n id: string\n timestamp: string\n payload: {\n leaderboardId: string\n updateType: 'score_update' | 'full_refresh' | 'bulk_update'\n \n // Complete current state (top 100 entries)\n leaderboard: {\n entries: LeaderboardEntry[]\n totalEntries: number\n displayedEntries: number\n }\n \n // What changed\n mutations: LeaderboardMutation[]\n \n // What triggered this update\n trigger: UpdateTrigger\n \n sequence: number // For ordering/deduplication\n }\n}\n\n/**\n * User rank update message\n */\nexport interface UserRankUpdateMessage extends WebSocketMessage {\n type: 'user_rank_update'\n data: {\n leaderboard_id: string\n user_id: string\n old_rank: number\n new_rank: number\n score: number\n }\n}\n\n/**\n * Error message\n */\nexport interface ErrorMessage extends WebSocketMessage {\n type: 'error'\n error: {\n code: string\n message: string\n details?: Record<string, unknown>\n }\n}\n\n/**\n * Connection info message\n */\nexport interface ConnectionInfoMessage extends WebSocketMessage {\n type: 'connection_info'\n payload?: {\n connectionId: string\n maxConnections: number\n currentConnections: number\n rateLimit?: {\n requestsPerMinute: number\n burstSize: number\n }\n }\n}\n\n/**\n * API error response\n */\nexport interface ApiErrorResponse {\n error: {\n code: string\n message: string\n details?: Record<string, unknown>\n }\n timestamp: string\n requestId?: string\n}\n\n/**\n * API info response\n */\nexport interface ApiInfoResponse {\n /** API name */\n name: string\n /** Package version */\n version: string\n /** API version */\n apiVersion: string\n /** Environment */\n environment: 'development' | 'staging' | 'production'\n /** Current timestamp */\n timestamp: string\n /** Available endpoints */\n endpoints: {\n health: {\n basic: string\n detailed: string\n }\n public: {\n description: string\n scores: string\n leaderboards: string\n websocket: string\n }\n dashboard: {\n description: string\n auth: string\n accounts: string\n apps: string\n leaderboards: string\n analytics: string\n }\n admin: {\n description: string\n auth: string\n accounts: string\n apps: string\n analytics: string\n }\n }\n /** Documentation URL */\n documentation: string\n /** Support email */\n support: string\n}\n\n/**\n * Basic health check response\n */\nexport interface HealthResponse {\n /** Health status */\n status: 'healthy' | 'unhealthy'\n /** API version */\n version: string\n /** Current timestamp */\n timestamp: string\n}\n\n/**\n * Detailed health check response\n */\nexport interface DetailedHealthResponse extends HealthResponse {\n /** Individual service statuses */\n services: {\n /** Database health */\n database: {\n status: 'healthy' | 'unhealthy'\n latency?: number\n }\n /** Cache health */\n cache: {\n status: 'healthy' | 'unhealthy'\n latency?: number\n }\n /** Storage health */\n storage: {\n status: 'healthy' | 'unhealthy'\n latency?: number\n }\n }\n /** System information */\n system: {\n /** Memory usage in MB */\n memoryUsage: number\n /** Uptime in seconds */\n uptime: number\n /** Environment */\n environment: string\n }\n}\n\n/**\n * SDK error class\n */\nexport class GlobalLeaderboardsError extends Error {\n constructor(\n message: string,\n public code: string,\n public statusCode?: number,\n public details?: Record<string, unknown>\n ) {\n super(message)\n this.name = 'GlobalLeaderboardsError'\n }\n}\n\n/**\n * WebSocket event handlers\n */\nexport interface WebSocketHandlers {\n /** Called when connection is established */\n onConnect?: () => void\n /** Called when connection is closed */\n onDisconnect?: (code: number, reason: string) => void\n /** Called when an error occurs */\n onError?: (error: Error) => void\n /** Called when leaderboard is updated */\n onLeaderboardUpdate?: (data: LeaderboardUpdateMessage['payload']) => void\n /** Called when user rank changes */\n onUserRankUpdate?: (data: UserRankUpdateMessage['data']) => void\n /** Called for any message */\n onMessage?: (message: WebSocketMessage) => void\n /** Called when starting a reconnection attempt */\n onReconnecting?: (attempt: number, maxAttempts: number, nextDelay: number) => void\n}\n\n/**\n * Offline queue operation\n */\nexport interface QueuedOperation {\n /** Unique queue ID */\n queueId: string\n /** Operation method */\n method: 'submit' | 'submitBulk'\n /** Operation parameters */\n params: {\n userId?: string\n score?: number\n leaderboardId?: string\n userName?: string\n metadata?: Record<string, unknown>\n scores?: SubmitScoreRequest[]\n }\n /** Timestamp when queued */\n timestamp: number\n /** Number of retry attempts */\n retryCount?: number\n}\n\n/**\n * Queue event types\n */\nexport type QueueEventType = 'queue:added' | 'queue:processed' | 'queue:failed' | 'queue:progress'\n\n/**\n * Queue event handler\n */\nexport interface QueueEventHandler {\n (event: QueueEventType, data: unknown): void\n}","import {\n WebSocketHandlers,\n WebSocketMessage,\n SubscribeMessage,\n UnsubscribeMessage,\n LeaderboardUpdateMessage,\n UserRankUpdateMessage,\n ErrorMessage,\n GlobalLeaderboardsError\n} from './types'\n\n/**\n * WebSocket client for real-time leaderboard updates\n * \n * WebSocket connections now work through the main API domain with full\n * Cloudflare proxy protection. Both WebSocket and SSE are supported options\n * for real-time updates.\n * \n * @see LeaderboardSSE - Alternative method using Server-Sent Events\n */\nexport class LeaderboardWebSocket {\n private ws: WebSocket | null = null\n private reconnectAttempts = 0\n private reconnectTimer: ReturnType<typeof setTimeout> | null = null\n private pingInterval: ReturnType<typeof setInterval> | null = null\n private subscribedLeaderboards = new Set<string>()\n private handlers: WebSocketHandlers\n private isConnecting = false\n private shouldReconnect = true\n private permanentError: GlobalLeaderboardsError | null = null\n private isOnline = true\n private networkListenersBound = false\n \n constructor(\n private readonly wsUrl: string,\n private readonly apiKey: string,\n private readonly options: {\n maxReconnectAttempts?: number\n reconnectDelay?: number\n pingInterval?: number\n } = {}\n ) {\n this.handlers = {}\n this.options = {\n maxReconnectAttempts: 5,\n reconnectDelay: 1000,\n pingInterval: 30000,\n ...options\n }\n \n // Set up network detection\n this.setupNetworkListeners()\n \n // Check initial network state\n this.isOnline = typeof navigator !== 'undefined' ? navigator.onLine : true\n }\n\n /**\n * Connect to the WebSocket server\n */\n connect(leaderboardId?: string, userId?: string): void {\n if (this.ws?.readyState === WebSocket.OPEN || this.isConnecting) {\n return\n }\n\n this.isConnecting = true\n this.shouldReconnect = true\n this.permanentError = null // Reset any previous permanent error\n\n const params = new URLSearchParams({\n api_key: this.apiKey\n })\n \n if (leaderboardId) {\n params.append('leaderboard_id', leaderboardId)\n // Add to subscribed list so we don't re-subscribe after connection\n this.subscribedLeaderboards.add(leaderboardId)\n }\n \n if (userId) {\n params.append('user_id', userId)\n }\n\n const url = `${this.wsUrl}/v1/ws/connect?${params.toString()}`\n\n try {\n this.ws = new WebSocket(url)\n this.setupEventHandlers()\n } catch (error) {\n this.isConnecting = false\n this.handleError(error as Error)\n }\n }\n\n /**\n * Disconnect from the WebSocket server\n */\n disconnect(): void {\n this.shouldReconnect = false\n this.cleanup()\n }\n\n /**\n * Subscribe to a leaderboard\n */\n subscribe(leaderboardId: string, userId?: string): void {\n if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {\n throw new GlobalLeaderboardsError(\n 'WebSocket is not connected',\n 'WS_NOT_CONNECTED'\n )\n }\n\n const message: SubscribeMessage = {\n type: 'subscribe',\n leaderboard_id: leaderboardId,\n user_id: userId\n }\n\n this.send(message)\n this.subscribedLeaderboards.add(leaderboardId)\n }\n\n /**\n * Unsubscribe from a leaderboard\n */\n unsubscribe(leaderboardId: string): void {\n if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {\n return\n }\n\n const message: UnsubscribeMessage = {\n type: 'unsubscribe',\n leaderboard_id: leaderboardId\n }\n\n this.send(message)\n this.subscribedLeaderboards.delete(leaderboardId)\n }\n\n /**\n * Set event handlers\n */\n on(handlers: Partial<WebSocketHandlers>): void {\n this.handlers = { ...this.handlers, ...handlers }\n }\n\n /**\n * Get connection state\n */\n get isConnected(): boolean {\n return this.ws?.readyState === WebSocket.OPEN\n }\n\n /**\n * Get subscribed leaderboards\n */\n get subscriptions(): string[] {\n return Array.from(this.subscribedLeaderboards)\n }\n\n /**\n * Get permanent error if connection was terminated due to a permanent error\n */\n get permanentConnectionError(): GlobalLeaderboardsError | null {\n return this.permanentError\n }\n\n private setupEventHandlers(): void {\n if (!this.ws) return\n\n this.ws.onopen = () => {\n this.isConnecting = false\n this.reconnectAttempts = 0\n this.startPingInterval()\n this.handlers.onConnect?.()\n \n // Re-subscribe to leaderboards after reconnection\n // Skip if leaderboard was already provided in connection URL\n const urlParams = this.ws ? new URL(this.ws.url).searchParams : null\n const connectedLeaderboardId = urlParams?.get('leaderboard_id')\n \n this.subscribedLeaderboards.forEach(leaderboardId => {\n // Don't re-subscribe to the leaderboard we're already connected to\n if (leaderboardId !== connectedLeaderboardId) {\n this.subscribe(leaderboardId)\n }\n })\n }\n\n this.ws.onmessage = (event) => {\n try {\n const message = JSON.parse(event.data) as WebSocketMessage\n this.handleMessage(message)\n } catch (error) {\n this.handleError(new Error('Failed to parse WebSocket message'))\n }\n }\n\n this.ws.onerror = () => {\n this.handleError(new Error('WebSocket error'))\n }\n\n this.ws.onclose = (event) => {\n this.isConnecting = false\n this.stopPingInterval()\n this.handlers.onDisconnect?.(event.code, event.reason)\n \n if (this.shouldReconnect) {\n this.scheduleReconnect()\n }\n }\n }\n\n private handleMessage(message: WebSocketMessage): void {\n this.handlers.onMessage?.(message)\n\n switch (message.type) {\n case 'leaderboard_update':\n this.handlers.onLeaderboardUpdate?.(\n (message as LeaderboardUpdateMessage).payload\n )\n break\n \n case 'user_rank_update':\n this.handlers.onUserRankUpdate?.(\n (message as UserRankUpdateMessage).data\n )\n break\n \n case 'error':\n const errorMsg = message as ErrorMessage\n \n if (errorMsg.error && typeof errorMsg.error === 'object' && 'message' in errorMsg.error) {\n const error = new GlobalLeaderboardsError(\n errorMsg.error.message,\n errorMsg.error.code || 'UNKNOWN_ERROR'\n )\n \n // Check if this is a permanent error that shouldn't trigger reconnection\n const permanentErrors = [\n 'LEADERBOARD_NOT_FOUND',\n 'INVALID_API_KEY',\n 'INSUFFICIENT_PERMISSIONS',\n 'INVALID_LEADERBOARD_ID'\n ]\n \n if (permanentErrors.includes(error.code)) {\n // Permanent error - stop reconnection attempts\n this.shouldReconnect = false\n this.permanentError = error\n \n // Close the connection immediately\n if (this.ws && this.ws.readyState === WebSocket.OPEN) {\n this.ws.close(4000, `Permanent error: ${error.code}`)\n }\n }\n \n this.handleError(error)\n } else {\n // Handle invalid error format\n this.handleError(\n new GlobalLeaderboardsError(\n 'Invalid error message format',\n 'INVALID_MESSAGE_FORMAT'\n )\n )\n }\n break\n \n case 'ping':\n this.send({ type: 'pong' })\n break\n \n case 'pong':\n // Pong received in response to our ping - no action needed\n break\n \n case 'connection_info':\n // Connection info is informational\n break\n \n case 'update':\n case 'score_submission':\n // These are handled via onMessage handler\n break\n \n default:\n // Unknown message type - ignore\n }\n }\n\n private send(message: WebSocketMessage): void {\n if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {\n throw new GlobalLeaderboardsError(\n 'WebSocket is not connected',\n 'WS_NOT_CONNECTED'\n )\n }\n\n // Transform SDK message format to server format\n const serverMessage = this.transformToServerFormat(message)\n this.ws.send(JSON.stringify(serverMessage))\n }\n\n private transformToServerFormat(message: WebSocketMessage): any {\n // Generate message ID and timestamp\n const id = `msg_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`\n const timestamp = new Date().toISOString()\n\n switch (message.type) {\n case 'subscribe':\n const subMsg = message as SubscribeMessage\n return {\n id,\n type: 'subscribe',\n timestamp,\n payload: {\n leaderboardId: subMsg.leaderboard_id,\n userId: subMsg.user_id\n }\n }\n \n case 'unsubscribe':\n const unsubMsg = message as UnsubscribeMessage\n return {\n id,\n type: 'unsubscribe',\n timestamp,\n payload: {\n leaderboardId: unsubMsg.leaderboard_id\n }\n }\n \n case 'ping':\n case 'pong':\n return {\n id,\n type: message.type,\n timestamp\n }\n \n default:\n // For other message types, just add id and timestamp\n return {\n id,\n timestamp,\n ...message\n }\n }\n }\n\n private startPingInterval(): void {\n this.stopPingInterval()\n \n this.pingInterval = setInterval(() => {\n if (this.ws?.readyState === WebSocket.OPEN) {\n this.send({ type: 'ping' })\n }\n }, this.options.pingInterval!)\n }\n\n private stopPingInterval(): void {\n if (this.pingInterval) {\n clearInterval(this.pingInterval)\n this.pingInterval = null\n }\n }\n\n private scheduleReconnect(): void {\n // Don't reconnect if we're offline\n if (!this.isOnline) {\n return\n }\n\n if (this.reconnectAttempts >= this.options.maxReconnectAttempts!) {\n this.handleError(\n new GlobalLeaderboardsError(\n 'Max reconnection attempts reached',\n 'WS_MAX_RECONNECT'\n )\n )\n return\n }\n\n this.reconnectAttempts++\n const baseDelay = this.options.reconnectDelay! * Math.pow(2, this.reconnectAttempts - 1)\n \n // Add jitter (±25%)\n const jitter = baseDelay * 0.25\n const randomJitter = (Math.random() - 0.5) * 2 * jitter\n const delay = Math.max(0, baseDelay + randomJitter)\n \n // Notify about reconnection attempt\n this.handlers.onReconnecting?.(\n this.reconnectAttempts,\n this.options.maxReconnectAttempts!,\n Math.round(delay)\n )\n\n this.reconnectTimer = setTimeout(() => {\n // Check network status again before attempting\n if (this.isOnline) {\n this.connect()\n } else {\n // If still offline, schedule another attempt\n this.scheduleReconnect()\n }\n }, delay)\n }\n\n private handleError(error: Error): void {\n this.handlers.onError?.(error)\n }\n\n private cleanup(): void {\n if (this.reconnectTimer) {\n clearTimeout(this.reconnectTimer)\n this.reconnectTimer = null\n }\n\n this.stopPingInterval()\n\n if (this.ws) {\n this.ws.close()\n this.ws = null\n }\n\n this.isConnecting = false\n }\n \n /**\n * Set up network status listeners\n */\n private setupNetworkListeners(): void {\n if (typeof window === 'undefined' || this.networkListenersBound) {\n return\n }\n \n const handleOnline = () => {\n this.isOnline = true\n \n // If we have a permanent error, don't attempt reconnection\n if (this.permanentError) {\n return\n }\n \n // If we're not connected and should reconnect, try to connect\n if (!this.isConnected && !this.isConnecting && this.shouldReconnect) {\n this.connect()\n }\n }\n \n const handleOffline = () => {\n this.isOnline = false\n \n // Cancel any pending reconnection attempts\n if (this.reconnectTimer) {\n clearTimeout(this.reconnectTimer)\n this.reconnectTimer = null\n }\n }\n \n window.addEventListener('online', handleOnline)\n window.addEventListener('offline', handleOffline)\n \n this.networkListenersBound = true\n \n // Store references for cleanup if needed\n ;(this as unknown as { \n _handleOnline?: () => void\n _handleOffline?: () => void \n })._handleOnline = handleOnline\n ;(this as unknown as { \n _handleOnline?: () => void\n _handleOffline?: () => void \n })._handleOffline = handleOffline\n }\n \n /**\n * Manually trigger a reconnection attempt\n * Useful for application-level retry logic\n */\n reconnect(): void {\n if (this.isConnected || this.isConnecting) {\n return\n }\n \n if (this.permanentError) {\n throw this.permanentError\n }\n \n // Reset reconnection attempts for manual reconnect\n this.reconnectAttempts = 0\n this.shouldReconnect = true\n \n // Check network status\n if (!this.isOnline) {\n throw new GlobalLeaderboardsError(\n 'Cannot reconnect while offline',\n 'WS_OFFLINE'\n )\n }\n \n this.connect()\n }\n}","/**\n * Server-Sent Events client for real-time leaderboard updates\n */\n\nimport type { \n GlobalLeaderboardsConfig, \n GlobalLeaderboardsError,\n LeaderboardEntry,\n LeaderboardMutation,\n UpdateTrigger\n} from './types'\n\n/**\n * SSE event types\n */\nexport type SSEEventType = \n | 'connected'\n | 'leaderboard_update' // Changed to match WebSocket\n | 'heartbeat'\n | 'error'\n\n/**\n * Enhanced SSE leaderboard update event data - matches WebSocket format\n */\nexport interface SSELeaderboardUpdateEvent {\n leaderboardId: string\n updateType: 'score_update' | 'full_refresh' | 'bulk_update'\n \n // Complete current state (top 100 entries)\n leaderboard: {\n entries: LeaderboardEntry[]\n totalEntries: number\n displayedEntries: number\n }\n \n // What changed\n mutations: LeaderboardMutation[]\n \n // What triggered this update\n trigger: UpdateTrigger\n \n sequence: number // For ordering/deduplication\n}\n\n/**\n * Event handlers for SSE\n */\nexport interface SSEEventHandlers {\n onConnect?: () => void\n onDisconnect?: () => void\n onError?: (error: GlobalLeaderboardsError) => void\n onLeaderboardUpdate?: (data: SSELeaderboardUpdateEvent) => void\n onHeartbeat?: (data: { connectionId: string; serverTime: string }) => void\n onMessage?: (message: any) => void\n}\n\n/**\n * SSE connection options\n */\nexport interface SSEConnectionOptions {\n userId?: string\n includeMetadata?: boolean\n topN?: number\n}\n\n/**\n * LeaderboardSSE client for Server-Sent Events\n * \n * @example\n * ```typescript\n * const sse = new LeaderboardSSE(config)\n * \n * const connection = sse.connect('leaderboard-id', {\n * onLeaderboardUpdate: (data) => {\n * console.log('Leaderboard updated:', data.topScores)\n * },\n * onUserRankUpdate: (data) => {\n * console.log('User rank changed:', data)\n * }\n * })\n * \n * // Later...\n * connection.close()\n * ```\n */\nexport class LeaderboardSSE {\n private config: Required<GlobalLeaderboardsConfig>\n private connections: Map<string, EventSource> = new Map()\n private reconnectAttempts: Map<string, number> = new Map()\n private reconnectTimers: Map<string, NodeJS.Timeout> = new Map()\n\n /**\n * Create a new LeaderboardSSE client\n * \n * @param config - Configuration with API key and base URL\n */\n constructor(config: Required<GlobalLeaderboardsConfig>) {\n this.config = config\n }\n\n /**\n * Connect to a leaderboard's SSE stream\n * \n * @param leaderboardId - The leaderboard to connect to\n * @param handlers - Event handlers for different SSE events\n * @param options - Connection options\n * @returns Connection object with close method\n */\n connect(\n leaderboardId: string,\n handlers: SSEEventHandlers,\n options: SSEConnectionOptions = {}\n ): { close: () => void } {\n // Close existing connection if any\n this.disconnect(leaderboardId)\n\n // Build SSE URL\n const params = new URLSearchParams({\n api_key: this.config.apiKey,\n ...(options.userId && { user_id: options.userId }),\n include_metadata: String(options.includeMetadata ?? true),\n top_n: String(options.topN ?? 10)\n })\n\n const url = `${this.config.baseUrl}/v1/sse/leaderboards/${leaderboardId}?${params.toString()}`\n\n try {\n const eventSource = new EventSource(url)\n\n // Handle connection open\n eventSource.onopen = () => {\n console.debug('[LeaderboardSSE] Connection opened:', leaderboardId)\n this.reconnectAttempts.delete(leaderboardId)\n handlers.onConnect?.()\n }\n\n // Handle generic messages\n eventSource.onmessage = (event) => {\n try {\n const data = JSON.parse(event.data)\n console.debug('[LeaderboardSSE] Received message:', data)\n handlers.onMessage?.(data)\n } catch (error) {\n console.error('[LeaderboardSSE] Failed to parse message:', error)\n }\n }\n\n // Handle errors\n eventSource.onerror = (error) => {\n console.error('[LeaderboardSSE] Connection error:', error)\n \n if (eventSource.readyState === EventSource.CLOSED) {\n handlers.onDisconnect?.()\n this.attemptReconnection(leaderboardId, handlers, options)\n }\n }\n\n // Set up event handlers\n eventSource.addEventListener('connected', (event: MessageEvent) => {\n try {\n const data = JSON.parse(event.data)\n console.debug('[LeaderboardSSE] Connected event:', data)\n } catch (error) {\n console.error('[LeaderboardSSE] Failed to parse connected event:', error)\n }\n })\n\n // Handle enhanced leaderboard_update event (matches WebSocket format)\n eventSource.addEventListener('leaderboard_update', (event: MessageEvent) => {\n try {\n const data = JSON.parse(event.data) as SSELeaderboardUpdateEvent\n console.debug('[LeaderboardSSE] Received leaderboard_update:', {\n leaderboardId: data.leaderboardId,\n mutations: data.mutations.length,\n entries: data.leaderboard.entries.length,\n sequence: data.sequence\n })\n handlers.onLeaderboardUpdate?.(data)\n } catch (error) {\n console.error('[LeaderboardSSE] Failed to parse leaderboard_update event:', error)\n }\n })\n\n eventSource.addEventListener('heartbeat', (event: MessageEvent) => {\n try {\n const data = JSON.parse(event.data)\n handlers.onHeartbeat?.(data)\n } catch (error) {\n console.error('[LeaderboardSSE] Failed to parse heartbeat event:', error)\n }\n })\n\n eventSource.addEventListener('error', (event: MessageEvent) => {\n try {\n const data = JSON.parse(event.data)\n const error = new Error(data.error.message) as GlobalLeaderboardsError\n error.code = data.error.code\n handlers.onError?.(error)\n } catch (error) {\n console.error('[LeaderboardSSE] Failed to parse error event:', error)\n }\n })\n\n // Store connection\n this.connections.set(leaderboardId, eventSource)\n\n // Return connection control object\n return {\n close: () => this.disconnect(leaderboardId)\n }\n } catch (error) {\n const message = error instanceof Error ? error.message : 'Failed to create SSE connection'\n const sseError = new Error(message) as GlobalLeaderboardsError\n sseError.code = 'CONNECTION_FAILED'\n handlers.onError?.(sseError)\n \n throw sseError\n }\n }\n\n /**\n * Disconnect from a specific leaderboard\n * \n * @param leaderboardId - The leaderboard to disconnect from\n */\n disconnect(leaderboardId: string): void {\n const eventSource = this.connections.get(leaderboardId)\n if (eventSource) {\n eventSource.close()\n this.connections.delete(leaderboardId)\n }\n\n // Clear reconnection timer\n const timer = this.reconnectTimers.get(leaderboardId)\n if (timer) {\n clearTimeout(timer)\n this.reconnectTimers.delete(leaderboardId)\n }\n\n this.reconnectAttempts.delete(leaderboardId)\n }\n\n /**\n * Disconnect from all leaderboards\n */\n disconnectAll(): void {\n for (const leaderboardId of this.connections.keys()) {\n this.disconnect(leaderboardId)\n }\n }\n\n /**\n * Check if connected to a specific leaderboard\n * \n * @param leaderboardId - The leaderboard to check\n * @returns Whether connected to the leaderboard\n */\n isConnected(leaderboardId: string): boolean {\n const eventSource = this.connections.get(leaderboardId)\n return eventSource?.readyState === EventSource.OPEN\n }\n\n /**\n * Get connection status for all leaderboards\n * \n * @returns Map of leaderboard IDs to connection states\n */\n getConnectionStatus(): Map<string, 'connecting' | 'open' | 'closed'> {\n const status = new Map<string, 'connecting' | 'open' | 'closed'>()\n \n for (const [leaderboardId, eventSource] of this.connections) {\n switch (eventSource.readyState) {\n case EventSource.CONNECTING:\n status.set(leaderboardId, 'connecting')\n break\n case EventSource.OPEN:\n status.set(leaderboardId, 'open')\n break\n case EventSource.CLOSED:\n status.set(leaderboardId, 'closed')\n break\n }\n }\n \n return status\n }\n\n /**\n * Attempt to reconnect to a leaderboard\n * \n * @param leaderboardId - The leaderboard to reconnect to\n * @param handlers - Event handlers\n * @param options - Connection options\n */\n private attemptReconnection(\n leaderboardId: string,\n handlers: SSEEventHandlers,\n options: SSEConnectionOptions\n ): void {\n const attempts = this.reconnectAttempts.get(leaderboardId) || 0\n \n if (attempts >= (this.config.maxRetries || 3)) {\n console.error('[LeaderboardSSE] Max reconnection attempts reached:', leaderboardId)\n this.disconnect(leaderboardId)\n return\n }\n\n const delay = Math.min(1000 * Math.pow(2, attempts), 30000) // Exponential backoff, max 30s\n this.reconnectAttempts.set(leaderboardId, attempts + 1)\n\n console.debug('[LeaderboardSSE] Scheduling reconnection:', {\n leaderboardId,\n attempt: attempts + 1,\n delay\n })\n\n const timer = setTimeout(() => {\n console.debug('[LeaderboardSSE] Attempting reconnection:', leaderboardId)\n this.connect(leaderboardId, handlers, options)\n this.reconnectTimers.delete(leaderboardId)\n }, delay)\n\n this.reconnectTimers.set(leaderboardId, timer)\n }\n}","import { ulid } from 'ulid'\nimport {\n QueuedOperation,\n QueueEventType,\n QueueEventHandler,\n QueuedSubmitResponse\n} from './types'\n\n// Chrome storage types (simplified)\ndeclare global {\n interface Chrome {\n storage: {\n sync: {\n get(keys: string | string[], callback: (result: Record<string, unknown>) => void): void\n set(items: Record<string, unknown>, callback?: () => void): void\n remove(keys: string | string[], callback?: () => void): void\n }\n }\n runtime: {\n lastError?: { message: string }\n }\n }\n const chrome: Chrome | undefined\n}\n\n/**\n * Storage adapter interface for offline queue persistence\n */\ninterface StorageAdapter {\n get(key: string): Promise<QueuedOperation[]>\n set(key: string, value: QueuedOperation[]): Promise<void>\n clear(key: string): Promise<void>\n}\n\n/**\n * Chrome storage sync adapter\n * Uses chrome.storage.sync for cross-device synchronization\n */\nclass ChromeStorageAdapter implements StorageAdapter {\n async get(key: string): Promise<QueuedOperation[]> {\n // Check if chrome.storage is available\n if (typeof chrome !== 'undefined' && chrome.storage && chrome.storage.sync) {\n return new Promise((resolve) => {\n chrome.storage.sync.get(key, (result: Record<string, unknown>) => {\n resolve((result[key] || []) as QueuedOperation[])\n })\n })\n }\n // Fallback to localStorage\n const stored = localStorage.getItem(key)\n return stored ? JSON.parse(stored) : []\n }\n\n async set(key: string, value: QueuedOperation[]): Promise<void> {\n // Check if chrome.storage is available\n if (typeof chrome !== 'undefined' && chrome.storage && chrome.storage.sync) {\n return new Promise((resolve, reject) => {\n chrome.storage.sync.set({ [key]: value }, () => {\n if (chrome.runtime.lastError) {\n reject(new Error(chrome.runtime.lastError.message))\n } else {\n resolve()\n }\n })\n })\n }\n // Fallback to localStorage\n localStorage.setItem(key, JSON.stringify(value))\n }\n\n async clear(key: string): Promise<void> {\n // Check if chrome.storage is available\n if (typeof chrome !== 'undefined' && chrome.storage && chrome.storage.sync) {\n return new Promise((resolve) => {\n chrome.storage.sync.remove(key, () => resolve())\n })\n }\n // Fallback to localStorage\n localStorage.removeItem(key)\n }\n}\n\n/**\n * Offline queue for managing API operations when offline\n * \n * Features:\n * - Automatic persistence using chrome.storage.sync\n * - Intelligent batching of submit operations\n * - FIFO processing order\n * - Event-based notifications\n * - Automatic cleanup of expired items\n */\nexport class OfflineQueue {\n private queue: QueuedOperation[] = []\n private processing = false\n private storageKey: string\n private storage: StorageAdapter\n private eventHandlers: Map<QueueEventType, Set<QueueEventHandler>> = new Map()\n private readonly MAX_QUEUE_SIZE = 1000\n private readonly QUEUE_TTL_MS = 24 * 60 * 60 * 1000 // 24 hours\n private readonly MAX_BATCH_SIZE = 100\n\n constructor(apiKey: string) {\n // Use API key hash for storage key to ensure isolation\n this.storageKey = `gl_queue_${this.hashApiKey(apiKey)}`\n this.storage = new ChromeStorageAdapter()\n this.loadQueue()\n }\n\n /**\n * Add an operation to the queue\n */\n async enqueue(operation: Omit<QueuedOperation, 'queueId' | 'timestamp' | 'retryCount'>): Promise<QueuedSubmitResponse> {\n const queueId = ulid()\n const queuedOp: QueuedOperation = {\n ...operation,\n queueId,\n timestamp: Date.now(),\n retryCount: 0\n }\n\n // Check queue size limit\n if (this.queue.length >= this.MAX_QUEUE_SIZE) {\n throw new Error(`Queue is full (max ${this.MAX_QUEUE_SIZE} items)`)\n }\n\n this.queue.push(queuedOp)\n await this.persistQueue()\n\n // Emit event\n this.emit('queue:added', queuedOp)\n\n // Return queued response\n return {\n queued: true,\n queueId,\n queuePosition: this.queue.length,\n operation: 'insert',\n rank: -1\n }\n }\n\n /**\n * Check if queue has items\n */\n hasItems(): boolean {\n return this.queue.length > 0\n }\n\n /**\n * Get queue size\n */\n size(): number {\n return this.queue.length\n }\n\n /**\n * Check if currently processing\n */\n isProcessing(): boolean {\n return this.processing\n }\n\n /**\n * Get all queued operations\n */\n getQueue(): QueuedOperation[] {\n return [...this.queue]\n }\n\n /**\n * Group operations by leaderboard for intelligent batching\n */\n batchOperations(): Map<string, QueuedOperation[]> {\n const groups = new Map<string, QueuedOperation[]>()\n \n // Clean expired items first\n this.cleanExpiredItems()\n\n // Group submit operations by leaderboard\n for (const op of this.queue) {\n if (op.method === 'submit' && op.params.leaderboardId) {\n const key = op.params.leaderboardId\n if (!groups.has(key)) {\n groups.set(key, [])\n }\n groups.get(key)!.push(op)\n } else if (op.method === 'submitBulk') {\n // Bulk operations stay separate\n groups.set(`bulk_${op.queueId}`, [op])\n }\n }\n\n // Limit batch sizes\n const limitedGroups = new Map<string, QueuedOperation[]>()\n for (const [key, ops] of groups) {\n if (ops.length > this.MAX_BATCH_SIZE) {\n // Split into smaller batches\n for (let i = 0; i < ops.length; i += this.MAX_BATCH_SIZE) {\n limitedGroups.set(`${key}_${i}`, ops.slice(i, i + this.MAX_BATCH_SIZE))\n }\n } else {\n limitedGroups.set(key, ops)\n }\n }\n\n return limitedGroups\n }\n\n /**\n * Mark operations as processed and remove from queue\n */\n async removeProcessed(queueIds: string[]): Promise<void> {\n const idSet = new Set(queueIds)\n this.queue = this.queue.filter(op => !idSet.has(op.queueId))\n await this.persistQueue()\n }\n\n /**\n * Clear the entire queue\n */\n async clear(): Promise<void> {\n this.queue = []\n await this.storage.clear(this.storageKey)\n }\n\n /**\n * Mark operation as failed and increment retry count\n */\n async markFailed(queueId: string, permanent = false): Promise<void> {\n const op = this.queue.find(o => o.queueId === queueId)\n if (op) {\n if (permanent) {\n // Remove permanently failed items\n this.queue = this.queue.filter(o => o.queueId !== queueId)\n this.emit('queue:failed', { operation: op, permanent: true })\n } else {\n // Increment retry count\n op.retryCount = (op.retryCount || 0) + 1\n }\n await this.persistQueue()\n }\n