@globalleaderboards/sdk
Version:
Official SDK for GlobalLeaderboards.net - Add competitive leaderboards to any application
1,634 lines (1,626 loc) • 53.2 kB
JavaScript
"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