scorezilla 0.1.0-next.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,680 @@
1
+ // src/config.ts
2
+ var DEFAULT_BASE_URL = "https://api.scorezilla.dev";
3
+ var PUBLIC_KEY_PATTERN = /^pk_[a-z0-9-]+_[A-Za-z0-9]+$/;
4
+ var SECRET_KEY_PREFIX = "sk_live_";
5
+ function validateConfig(cfg) {
6
+ if (!cfg || typeof cfg !== "object") {
7
+ throw new Error("scorezilla: config must be an object with publicKey or secretKey");
8
+ }
9
+ const hasPublic = "publicKey" in cfg && cfg.publicKey !== void 0;
10
+ const hasSecret = "secretKey" in cfg && cfg.secretKey !== void 0;
11
+ if (hasPublic && hasSecret) {
12
+ throw new Error("scorezilla: config must not contain both publicKey and secretKey");
13
+ }
14
+ if (!hasPublic && !hasSecret) {
15
+ throw new Error("scorezilla: config must contain either publicKey or secretKey");
16
+ }
17
+ let auth;
18
+ if (hasPublic) {
19
+ const pk = cfg.publicKey;
20
+ if (typeof pk !== "string" || !PUBLIC_KEY_PATTERN.test(pk)) {
21
+ throw new Error(
22
+ `scorezilla: publicKey must match ${PUBLIC_KEY_PATTERN.toString()} (got: ${typeof pk === "string" ? pk.slice(0, 12) + "\u2026" : typeof pk})`
23
+ );
24
+ }
25
+ auth = { kind: "public", key: pk };
26
+ } else {
27
+ const sk = cfg.secretKey;
28
+ if (!sk || typeof sk !== "object" || typeof sk.id !== "string" || typeof sk.secret !== "string") {
29
+ throw new Error("scorezilla: secretKey must be an object with string `id` and `secret`");
30
+ }
31
+ if (!sk.secret.startsWith(SECRET_KEY_PREFIX)) {
32
+ throw new Error(
33
+ `scorezilla: secretKey.secret must start with "${SECRET_KEY_PREFIX}" (live keys only)`
34
+ );
35
+ }
36
+ auth = { kind: "secret", keyId: sk.id, secret: sk.secret };
37
+ }
38
+ const baseUrlRaw = cfg.baseUrl ?? DEFAULT_BASE_URL;
39
+ if (typeof baseUrlRaw !== "string" || baseUrlRaw.length === 0) {
40
+ throw new Error("scorezilla: baseUrl must be a non-empty string when provided");
41
+ }
42
+ return {
43
+ baseUrl: baseUrlRaw.replace(/\/+$/, ""),
44
+ fetch: cfg.fetch,
45
+ timeoutMs: cfg.timeoutMs,
46
+ maxRetries: cfg.maxRetries,
47
+ userAgent: cfg.userAgent,
48
+ auth
49
+ };
50
+ }
51
+
52
+ // src/paths.ts
53
+ function encodeSegment(value, label) {
54
+ if (typeof value !== "string" || value.length === 0) {
55
+ throw new Error(`scorezilla: ${label} must be a non-empty string`);
56
+ }
57
+ return encodeURIComponent(value);
58
+ }
59
+ function buildQueryString(params) {
60
+ const usp = new URLSearchParams();
61
+ for (const [k, v] of Object.entries(params)) {
62
+ if (v !== void 0) usp.set(k, String(v));
63
+ }
64
+ const qs = usp.toString();
65
+ return qs.length > 0 ? `?${qs}` : "";
66
+ }
67
+ function submitScorePath(boardId) {
68
+ return `/v1/boards/${encodeSegment(boardId, "boardId")}/scores`;
69
+ }
70
+ function getLeaderboardPath(boardId, q) {
71
+ return `/v1/boards/${encodeSegment(boardId, "boardId")}/leaderboard` + buildQueryString({ top: q?.top, offset: q?.offset });
72
+ }
73
+ function getPlayerRankPath(boardId, playerId) {
74
+ return `/v1/boards/${encodeSegment(boardId, "boardId")}/players/${encodeSegment(playerId, "playerId")}/rank`;
75
+ }
76
+ function getWindowAroundPath(boardId, playerId, q) {
77
+ return `/v1/boards/${encodeSegment(boardId, "boardId")}/players/${encodeSegment(playerId, "playerId")}/window` + buildQueryString({ before: q?.before, after: q?.after });
78
+ }
79
+
80
+ // src/errors.ts
81
+ var MESSAGE_MAX_CHARS = 500;
82
+ var TRUNCATION_SUFFIX = "\u2026 [truncated]";
83
+ var STATUS_NETWORK_ERROR = 0;
84
+ function truncateMessage(raw) {
85
+ if (typeof raw !== "string") return "";
86
+ if (raw.length <= MESSAGE_MAX_CHARS) return raw;
87
+ const sliceEnd = Math.max(0, MESSAGE_MAX_CHARS - TRUNCATION_SUFFIX.length);
88
+ return raw.slice(0, sliceEnd) + TRUNCATION_SUFFIX;
89
+ }
90
+ var ScorezillaError = class _ScorezillaError extends Error {
91
+ /** HTTP status of the response, or {@link STATUS_NETWORK_ERROR} (0) for
92
+ * network / abort / timeout. */
93
+ status;
94
+ /** Machine-stable error code from the API. Open union — see
95
+ * {@link ScorezillaErrorCode}. For network errors, this is `'network_error'`;
96
+ * for aborts, `'aborted'`; for timeouts, `'timeout'`. */
97
+ code;
98
+ /** Sub-classifier — present on `out_of_bounds` (`'below_min' | 'above_max'`)
99
+ * and possibly other codes in future minor releases. */
100
+ reason;
101
+ /** Seconds — present on `rate_limited`. Honored by the transport's retry
102
+ * policy (Step 2.4). */
103
+ retryAfter;
104
+ /** Server-issued request ID, lifted from the `X-Request-Id` response
105
+ * header. Pass this to support when filing bugs. */
106
+ requestId;
107
+ /** The bound value crossed on `out_of_bounds`. */
108
+ bound;
109
+ /** Which rate-limit layer fired on `rate_limited`. */
110
+ layer;
111
+ /** The underlying cause (e.g., a `TypeError: fetch failed`) for
112
+ * network/abort/timeout paths. `undefined` when the error came from a
113
+ * successfully-parsed API error body. */
114
+ cause;
115
+ constructor(message, init) {
116
+ super(truncateMessage(message));
117
+ this.name = "ScorezillaError";
118
+ this.status = init.status;
119
+ this.code = init.code;
120
+ this.reason = init.reason;
121
+ this.retryAfter = init.retryAfter;
122
+ this.requestId = init.requestId;
123
+ this.bound = init.bound;
124
+ this.layer = init.layer;
125
+ this.cause = init.cause;
126
+ Object.setPrototypeOf(this, _ScorezillaError.prototype);
127
+ if (typeof Error.captureStackTrace === "function") {
128
+ Error.captureStackTrace(this, _ScorezillaError);
129
+ }
130
+ }
131
+ // ─── Sub-message helpers ─────────────────────────────────────────────
132
+ // Stable wrappers over the `code` discriminator so consumers can write
133
+ // `if (err.isRateLimited())` instead of memorizing the code spelling.
134
+ /** `true` when this error is a 429 / `rate_limited`. */
135
+ isRateLimited() {
136
+ return this.code === "rate_limited";
137
+ }
138
+ /** `true` when this error is a 401 / `unauthorized` (or 403 / `forbidden`). */
139
+ isAuth() {
140
+ return this.code === "unauthorized" || this.code === "forbidden";
141
+ }
142
+ /** `true` when this error is a 404 / `not_found`. */
143
+ isNotFound() {
144
+ return this.code === "not_found";
145
+ }
146
+ /** `true` when this error is a 422 / `out_of_bounds` (score below/above board limit). */
147
+ isOutOfBounds() {
148
+ return this.code === "out_of_bounds";
149
+ }
150
+ /** `true` for transient / retryable conditions: network errors, timeouts,
151
+ * 5xx, and 429. The transport layer relies on this for its retry policy. */
152
+ isTransient() {
153
+ if (this.status === STATUS_NETWORK_ERROR) return true;
154
+ if (this.status >= 500 && this.status < 600) return true;
155
+ return this.isRateLimited();
156
+ }
157
+ // ─── Factory ─────────────────────────────────────────────────────────
158
+ /**
159
+ * Build a `ScorezillaError` from a fetch round-trip outcome.
160
+ *
161
+ * Prefer this over `new ScorezillaError(...)` from the transport layer —
162
+ * it does the mapping from API response shape to error fields in one
163
+ * place, so future fields like `correlationId` get added once here.
164
+ *
165
+ * @param init - status, optional parsed body, optional requestId, optional cause
166
+ */
167
+ static from(init) {
168
+ const { status, body, requestId, cause } = init;
169
+ if (body && body.ok === false && typeof body.error === "string") {
170
+ return new _ScorezillaError(body.message ?? `Request failed: ${body.error}`, {
171
+ status,
172
+ code: body.error,
173
+ reason: body.reason,
174
+ retryAfter: body.retryAfter,
175
+ bound: body.bound,
176
+ layer: body.layer,
177
+ requestId,
178
+ cause
179
+ });
180
+ }
181
+ const code = codeForStatus(status);
182
+ return new _ScorezillaError(`Request failed with status ${status}`, {
183
+ status,
184
+ code,
185
+ requestId,
186
+ cause
187
+ });
188
+ }
189
+ /**
190
+ * Build a `ScorezillaError` for a transport-level failure (no HTTP
191
+ * response received): network error, abort, or timeout.
192
+ */
193
+ static network(message, cause) {
194
+ return new _ScorezillaError(message, {
195
+ status: STATUS_NETWORK_ERROR,
196
+ code: "network_error",
197
+ cause
198
+ });
199
+ }
200
+ /** Build a `ScorezillaError` for an `AbortSignal`-triggered cancellation. */
201
+ static aborted(cause) {
202
+ return new _ScorezillaError("Request aborted", {
203
+ status: STATUS_NETWORK_ERROR,
204
+ code: "aborted",
205
+ cause
206
+ });
207
+ }
208
+ /** Build a `ScorezillaError` for a request that exceeded its timeout budget. */
209
+ static timeout(timeoutMs) {
210
+ return new _ScorezillaError(`Request timed out after ${timeoutMs}ms`, {
211
+ status: STATUS_NETWORK_ERROR,
212
+ code: "timeout"
213
+ });
214
+ }
215
+ };
216
+ function codeForStatus(status) {
217
+ if (status === 401) return "unauthorized";
218
+ if (status === 403) return "forbidden";
219
+ if (status === 404) return "not_found";
220
+ if (status === 422) return "out_of_bounds";
221
+ if (status === 429) return "rate_limited";
222
+ if (status >= 500) return "internal_error";
223
+ if (status >= 400) return "invalid_input";
224
+ return "internal_error";
225
+ }
226
+
227
+ // src/retry.ts
228
+ var DEFAULT_MAX_RETRIES = 2;
229
+ var BASE_DELAY_MS = 200;
230
+ var MAX_DELAY_MS = 4e3;
231
+ var MAX_RETRY_AFTER_SEC = 30;
232
+ function nextDelay(attempt, retryAfterSec, random = Math.random) {
233
+ if (typeof retryAfterSec === "number" && Number.isFinite(retryAfterSec) && retryAfterSec >= 0 && retryAfterSec <= MAX_RETRY_AFTER_SEC) {
234
+ return Math.round(retryAfterSec * 1e3);
235
+ }
236
+ const exponential = Math.min(BASE_DELAY_MS * 2 ** attempt, MAX_DELAY_MS);
237
+ const jitterFactor = 0.5 + random() * 0.5;
238
+ return Math.round(exponential * jitterFactor);
239
+ }
240
+ function shouldRetryStatus(status) {
241
+ if (status === 429) return true;
242
+ if (status >= 500 && status < 600) return true;
243
+ return false;
244
+ }
245
+ function shouldRetryError(err) {
246
+ if (err instanceof ScorezillaError) {
247
+ return err.code === "network_error";
248
+ }
249
+ return false;
250
+ }
251
+ function generateIdempotencyKey() {
252
+ const c = globalThis.crypto;
253
+ if (!c || typeof c.randomUUID !== "function") {
254
+ throw new Error(
255
+ "scorezilla: globalThis.crypto.randomUUID is unavailable. The SDK requires Node \u2265 20 or a modern browser. Check your runtime."
256
+ );
257
+ }
258
+ return c.randomUUID();
259
+ }
260
+ function sleep(ms, signal) {
261
+ return new Promise((resolve, reject) => {
262
+ if (signal?.aborted) {
263
+ reject(signal.reason ?? new DOMException("Aborted", "AbortError"));
264
+ return;
265
+ }
266
+ const timer = setTimeout(() => {
267
+ signal?.removeEventListener("abort", onAbort);
268
+ resolve();
269
+ }, ms);
270
+ const onAbort = () => {
271
+ clearTimeout(timer);
272
+ signal?.removeEventListener("abort", onAbort);
273
+ reject(signal?.reason ?? new DOMException("Aborted", "AbortError"));
274
+ };
275
+ signal?.addEventListener("abort", onAbort, { once: true });
276
+ });
277
+ }
278
+
279
+ // src/transport.ts
280
+ var DEFAULT_TIMEOUT_MS = 3e4;
281
+ async function request(opts) {
282
+ const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
283
+ if (typeof fetchImpl !== "function") {
284
+ throw new Error(
285
+ "scorezilla: globalThis.fetch is unavailable. Either upgrade your runtime (Node \u2265 20 has fetch built in) or pass `fetch: yourFetch` in the SDK config."
286
+ );
287
+ }
288
+ const url = buildUrl(opts.baseUrl, opts.path);
289
+ const maxRetries = opts.retry?.maxRetries ?? DEFAULT_MAX_RETRIES;
290
+ const random = opts.retry?.random ?? Math.random;
291
+ const sleepImpl = opts.retry?.sleepImpl ?? sleep;
292
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
293
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
294
+ throw new Error(
295
+ `scorezilla: timeoutMs must be a positive finite number (got ${String(timeoutMs)})`
296
+ );
297
+ }
298
+ const retrySleep = async (delay) => {
299
+ try {
300
+ await sleepImpl(delay, opts.signal);
301
+ } catch (cause) {
302
+ throw ScorezillaError.aborted(cause);
303
+ }
304
+ };
305
+ const idempotencyKey = opts.method === "POST" ? generateIdempotencyKey() : null;
306
+ let lastError;
307
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
308
+ const combined = combineSignalsWithTimeout(opts.signal, timeoutMs);
309
+ try {
310
+ const init = {
311
+ method: opts.method,
312
+ headers: buildHeaders(opts, idempotencyKey),
313
+ signal: combined.signal
314
+ };
315
+ if (opts.body !== void 0) {
316
+ init.body = JSON.stringify(opts.body);
317
+ }
318
+ const response = await fetchImpl(url, init);
319
+ combined.cleanup();
320
+ if (response.ok) {
321
+ return await parseJson(response);
322
+ }
323
+ const body = await safelyParseErrorBody(response);
324
+ const err = ScorezillaError.from({
325
+ status: response.status,
326
+ body,
327
+ requestId: response.headers.get("X-Request-Id") ?? void 0
328
+ });
329
+ if (shouldRetryStatus(response.status) && attempt < maxRetries) {
330
+ const retryAfter = readRetryAfter(response);
331
+ const delay = nextDelay(attempt, retryAfter, random);
332
+ await retrySleep(delay);
333
+ lastError = err;
334
+ continue;
335
+ }
336
+ throw err;
337
+ } catch (caught) {
338
+ combined.cleanup();
339
+ if (caught instanceof ScorezillaError) {
340
+ if (shouldRetryError(caught) && attempt < maxRetries) {
341
+ const delay = nextDelay(attempt, void 0, random);
342
+ await retrySleep(delay);
343
+ lastError = caught;
344
+ continue;
345
+ }
346
+ throw caught;
347
+ }
348
+ const mapped = mapTransportError(caught, opts.signal, timeoutMs, combined);
349
+ if (shouldRetryError(mapped) && attempt < maxRetries) {
350
+ const delay = nextDelay(attempt, void 0, random);
351
+ await retrySleep(delay);
352
+ lastError = mapped;
353
+ continue;
354
+ }
355
+ throw mapped;
356
+ }
357
+ }
358
+ throw lastError ?? new ScorezillaError("Request failed after retries", {
359
+ status: 0,
360
+ code: "internal_error"
361
+ });
362
+ }
363
+ function buildUrl(baseUrl, path) {
364
+ const base = baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl;
365
+ return base + path;
366
+ }
367
+ function buildHeaders(opts, idempotencyKey) {
368
+ const headers = { Accept: "application/json" };
369
+ if (opts.body !== void 0) {
370
+ headers["Content-Type"] = "application/json";
371
+ }
372
+ if (idempotencyKey !== null) {
373
+ headers["Idempotency-Key"] = idempotencyKey;
374
+ }
375
+ if (opts.headers) {
376
+ for (const [k, v] of Object.entries(opts.headers)) headers[k] = v;
377
+ }
378
+ return headers;
379
+ }
380
+ async function parseJson(response) {
381
+ try {
382
+ return await response.json();
383
+ } catch (cause) {
384
+ throw new ScorezillaError("Response body was not valid JSON", {
385
+ status: response.status,
386
+ code: "invalid_json",
387
+ requestId: response.headers.get("X-Request-Id") ?? void 0,
388
+ cause
389
+ });
390
+ }
391
+ }
392
+ async function safelyParseErrorBody(response) {
393
+ try {
394
+ const json = await response.json();
395
+ if (json && typeof json === "object" && "ok" in json && json.ok === false) {
396
+ return json;
397
+ }
398
+ return void 0;
399
+ } catch {
400
+ return void 0;
401
+ }
402
+ }
403
+ function readRetryAfter(response) {
404
+ const raw = response.headers.get("Retry-After");
405
+ if (!raw) return void 0;
406
+ const n = Number(raw);
407
+ return Number.isFinite(n) && n >= 0 ? n : void 0;
408
+ }
409
+ function combineSignalsWithTimeout(caller, timeoutMs) {
410
+ const ctrl = new AbortController();
411
+ let didTimeOut = false;
412
+ if (caller?.aborted) {
413
+ ctrl.abort(caller.reason);
414
+ return { signal: ctrl.signal, cleanup: () => {
415
+ }, timedOut: () => false };
416
+ }
417
+ const onCallerAbort = () => {
418
+ ctrl.abort(caller?.reason);
419
+ };
420
+ caller?.addEventListener("abort", onCallerAbort, { once: true });
421
+ const timer = setTimeout(() => {
422
+ didTimeOut = true;
423
+ ctrl.abort(new DOMException(`Request timed out after ${timeoutMs}ms`, "TimeoutError"));
424
+ }, timeoutMs);
425
+ return {
426
+ signal: ctrl.signal,
427
+ cleanup: () => {
428
+ clearTimeout(timer);
429
+ caller?.removeEventListener("abort", onCallerAbort);
430
+ },
431
+ timedOut: () => didTimeOut
432
+ };
433
+ }
434
+ function mapTransportError(caught, callerSignal, timeoutMs, combined) {
435
+ if (callerSignal?.aborted) {
436
+ return ScorezillaError.aborted(callerSignal.reason ?? caught);
437
+ }
438
+ if (combined.timedOut()) {
439
+ return ScorezillaError.timeout(timeoutMs);
440
+ }
441
+ const message = caught instanceof Error ? caught.message : "Network request failed";
442
+ return ScorezillaError.network(message, caught);
443
+ }
444
+
445
+ // src/user-agent.ts
446
+ function detectRuntime(g = globalThis) {
447
+ if (typeof g.Bun !== "undefined") return "bun";
448
+ if (typeof g.Deno !== "undefined") return "deno";
449
+ if (typeof g.navigator?.userAgent === "string" && g.navigator.userAgent.includes("Cloudflare-Workers")) {
450
+ return "workers";
451
+ }
452
+ if (typeof g.process?.versions?.node === "string") return "node";
453
+ if (typeof g.document !== "undefined") return "browser";
454
+ return "unknown";
455
+ }
456
+ function defaultUserAgent(version, runtime = detectRuntime()) {
457
+ return `scorezilla-js/${version} (${runtime})`;
458
+ }
459
+
460
+ // src/client.ts
461
+ var METADATA_MAX_BYTES = 4096;
462
+ function validateMetadata(metadata) {
463
+ if (metadata === null || typeof metadata !== "object" || Array.isArray(metadata)) {
464
+ throw new Error(
465
+ "scorezilla: metadata must be a plain object (got " + (Array.isArray(metadata) ? "array" : typeof metadata) + ")"
466
+ );
467
+ }
468
+ let serialized;
469
+ try {
470
+ serialized = JSON.stringify(metadata, (_key, value) => {
471
+ if (typeof value === "function") {
472
+ throw new Error("scorezilla: metadata may not contain functions");
473
+ }
474
+ if (typeof value === "symbol") {
475
+ throw new Error("scorezilla: metadata may not contain symbols");
476
+ }
477
+ if (typeof value === "bigint") {
478
+ throw new Error("scorezilla: metadata may not contain BigInt");
479
+ }
480
+ return value;
481
+ });
482
+ } catch (cause) {
483
+ if (cause instanceof Error) {
484
+ if (cause.message.startsWith("scorezilla:")) throw cause;
485
+ if (/circular|convert|cyclic/i.test(cause.message)) {
486
+ throw new Error("scorezilla: metadata contains circular references");
487
+ }
488
+ }
489
+ throw cause;
490
+ }
491
+ const byteLength = new TextEncoder().encode(serialized).length;
492
+ if (byteLength > METADATA_MAX_BYTES) {
493
+ throw new Error(
494
+ `scorezilla: metadata exceeds ${METADATA_MAX_BYTES} bytes (got ${byteLength} bytes when JSON-stringified)`
495
+ );
496
+ }
497
+ }
498
+ var Scorezilla = class _Scorezilla {
499
+ /** The package version, injected at build time from `package.json`. */
500
+ static version = "0.1.0-next.0";
501
+ #config;
502
+ #userAgent;
503
+ #authHeader;
504
+ /**
505
+ * @param config - public-key configuration. Passing `secretKey` throws —
506
+ * use the `scorezilla/server` adapter (v0.2.0) for HMAC.
507
+ */
508
+ constructor(config) {
509
+ const resolved = validateConfig(config);
510
+ if (resolved.auth.kind !== "public") {
511
+ throw new Error(
512
+ "scorezilla: the default `Scorezilla` client is public-key only. Secret-key (HMAC) auth ships in v0.2.0 via the `scorezilla/server` adapter \u2014 use that for server-side code; use `publicKey` for browser / public clients."
513
+ );
514
+ }
515
+ this.#config = resolved;
516
+ this.#userAgent = resolved.userAgent ?? defaultUserAgent(_Scorezilla.version);
517
+ this.#authHeader = `Bearer ${resolved.auth.key}`;
518
+ }
519
+ /**
520
+ * Submit a score to a board.
521
+ *
522
+ * Maps to `POST /v1/boards/:boardId/scores`. See [API.md](../API.md#submitscore)
523
+ * for the full contract.
524
+ *
525
+ * @example
526
+ * ```ts
527
+ * try {
528
+ * const r = await sz.submitScore({ boardId, playerId: 'alice', score: 9001 });
529
+ * if (r.isPersonalBest) console.log(`PB! Rank ${r.rank} of ${r.totalEntries}`);
530
+ * } catch (e) {
531
+ * if (e instanceof ScorezillaError && e.code === 'out_of_bounds') {
532
+ * console.warn(`Score outside board bounds (${e.reason}, limit ${e.bound})`);
533
+ * } else throw e;
534
+ * }
535
+ * ```
536
+ *
537
+ * @throws {ScorezillaError} `unauthorized` (bad publicKey), `forbidden`
538
+ * (key not bound to this board), `not_found` (board doesn't exist),
539
+ * `out_of_bounds` (score outside board's min/max), `rate_limited`
540
+ * (Layer 2/3 throttle hit), `invalid_input`, `network_error`, `timeout`.
541
+ * @since 0.1.0
542
+ * @stability stable
543
+ */
544
+ async submitScore(input) {
545
+ if (input.metadata !== void 0) {
546
+ validateMetadata(input.metadata);
547
+ }
548
+ const body = {
549
+ playerId: input.playerId,
550
+ score: input.score
551
+ };
552
+ if (input.metadata !== void 0) {
553
+ body.metadata = input.metadata;
554
+ }
555
+ return this.#request({
556
+ path: submitScorePath(input.boardId),
557
+ method: "POST",
558
+ body
559
+ });
560
+ }
561
+ /**
562
+ * Fetch the top-N leaderboard for a board.
563
+ *
564
+ * Maps to `GET /v1/boards/:boardId/leaderboard`.
565
+ *
566
+ * @example
567
+ * ```ts
568
+ * const { entries } = await sz.getLeaderboard({ boardId, top: 25 });
569
+ * for (const e of entries) console.log(`${e.rank}. ${e.playerId}: ${e.score}`);
570
+ * ```
571
+ *
572
+ * @throws {ScorezillaError} `not_found`, `network_error`, `timeout`.
573
+ * @since 0.1.0
574
+ * @stability stable
575
+ */
576
+ async getLeaderboard(input) {
577
+ const q = {};
578
+ if (input.top !== void 0) q.top = input.top;
579
+ if (input.offset !== void 0) q.offset = input.offset;
580
+ return this.#request({
581
+ path: getLeaderboardPath(input.boardId, q),
582
+ method: "GET"
583
+ });
584
+ }
585
+ /**
586
+ * Fetch a single player's rank on a board.
587
+ *
588
+ * Maps to `GET /v1/boards/:boardId/players/:playerId/rank`. Returns 404
589
+ * (`not_found`) if the player has no entry yet.
590
+ *
591
+ * @example
592
+ * ```ts
593
+ * try {
594
+ * const { rank, score } = await sz.getPlayerRank({ boardId, playerId: 'alice' });
595
+ * console.log(`Alice is rank ${rank} with score ${score}`);
596
+ * } catch (e) {
597
+ * if (e instanceof ScorezillaError && e.isNotFound()) {
598
+ * console.log('Alice has no submission on this board yet.');
599
+ * } else throw e;
600
+ * }
601
+ * ```
602
+ *
603
+ * @throws {ScorezillaError} `not_found` (player has no submission),
604
+ * `network_error`, `timeout`.
605
+ * @since 0.1.0
606
+ * @stability stable
607
+ */
608
+ async getPlayerRank(input) {
609
+ return this.#request({
610
+ path: getPlayerRankPath(input.boardId, input.playerId),
611
+ method: "GET"
612
+ });
613
+ }
614
+ /**
615
+ * Fetch the slice of entries surrounding a player.
616
+ *
617
+ * Maps to `GET /v1/boards/:boardId/players/:playerId/window?before=&after=`.
618
+ *
619
+ * @example
620
+ * ```ts
621
+ * const { entries } = await sz.getWindowAround({
622
+ * boardId, playerId: 'alice', before: 2, after: 2,
623
+ * });
624
+ * ```
625
+ *
626
+ * @throws {ScorezillaError} `network_error`, `timeout`.
627
+ * @since 0.1.0
628
+ * @stability stable
629
+ */
630
+ async getWindowAround(input) {
631
+ const q = {};
632
+ if (input.before !== void 0) q.before = input.before;
633
+ if (input.after !== void 0) q.after = input.after;
634
+ return this.#request({
635
+ path: getWindowAroundPath(input.boardId, input.playerId, q),
636
+ method: "GET"
637
+ });
638
+ }
639
+ // ─── Internal ────────────────────────────────────────────────────────
640
+ /**
641
+ * Common request wiring — auth header, default fetch impl, timeout, retry.
642
+ * Each public method composes its `path`, `method`, and (optionally) `body`
643
+ * and hands them to this thin pass-through. Keeps the four method bodies
644
+ * boilerplate-free and ensures every call shares identical defaults.
645
+ */
646
+ async #request(opts) {
647
+ const headers = {
648
+ Authorization: this.#authHeader,
649
+ // User-Agent: ignored by browsers (per Fetch spec), useful in
650
+ // Node/Bun/Deno/Workers for server-side observability.
651
+ "User-Agent": this.#userAgent,
652
+ // X-Scorezilla-Client: browser-honored, mirrors the UA for telemetry
653
+ // parity across runtimes.
654
+ "X-Scorezilla-Client": this.#userAgent
655
+ };
656
+ const requestOpts = {
657
+ baseUrl: this.#config.baseUrl,
658
+ path: opts.path,
659
+ method: opts.method,
660
+ headers
661
+ };
662
+ if (opts.body !== void 0) requestOpts.body = opts.body;
663
+ if (this.#config.fetch !== void 0) requestOpts.fetchImpl = this.#config.fetch;
664
+ if (this.#config.timeoutMs !== void 0) requestOpts.timeoutMs = this.#config.timeoutMs;
665
+ if (this.#config.maxRetries !== void 0) {
666
+ requestOpts.retry = { maxRetries: this.#config.maxRetries };
667
+ }
668
+ return request(requestOpts);
669
+ }
670
+ };
671
+ function createClient(config) {
672
+ return new Scorezilla(config);
673
+ }
674
+
675
+ // src/index.ts
676
+ var SDK_VERSION = "0.1.0-next.0";
677
+
678
+ export { SDK_VERSION, Scorezilla, ScorezillaError, createClient, detectRuntime };
679
+ //# sourceMappingURL=index.js.map
680
+ //# sourceMappingURL=index.js.map