scorezilla 0.3.1 → 0.5.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.
@@ -0,0 +1,882 @@
1
+ 'use strict';
2
+
3
+ // src/config.ts
4
+ var DEFAULT_BASE_URL = "https://api.scorezilla.dev";
5
+ var PUBLIC_KEY_PATTERN = /^pk_[a-z0-9-]+_[A-Za-z0-9]+$/;
6
+ var SECRET_KEY_PREFIX = "sk_live_";
7
+ function validateConfig(cfg) {
8
+ if (!cfg || typeof cfg !== "object") {
9
+ throw new Error("scorezilla: config must be an object with publicKey or secretKey");
10
+ }
11
+ const hasPublic = "publicKey" in cfg && cfg.publicKey !== void 0;
12
+ const hasSecret = "secretKey" in cfg && cfg.secretKey !== void 0;
13
+ if (hasPublic && hasSecret) {
14
+ throw new Error("scorezilla: config must not contain both publicKey and secretKey");
15
+ }
16
+ if (!hasPublic && !hasSecret) {
17
+ throw new Error("scorezilla: config must contain either publicKey or secretKey");
18
+ }
19
+ let auth;
20
+ if (hasPublic) {
21
+ auth = { kind: "public", key: validatePublicKeyValue(cfg.publicKey) };
22
+ } else {
23
+ const resolved = validateSecretKey(cfg);
24
+ auth = { kind: "secret", keyId: resolved.keyId, secret: resolved.secret };
25
+ }
26
+ const baseUrlRaw = cfg.baseUrl ?? DEFAULT_BASE_URL;
27
+ if (typeof baseUrlRaw !== "string" || baseUrlRaw.length === 0) {
28
+ throw new Error("scorezilla: baseUrl must be a non-empty string when provided");
29
+ }
30
+ return {
31
+ baseUrl: baseUrlRaw.replace(/\/+$/, ""),
32
+ fetch: cfg.fetch,
33
+ timeoutMs: cfg.timeoutMs,
34
+ maxRetries: cfg.maxRetries,
35
+ sleepImpl: cfg.sleepImpl,
36
+ warn: cfg.warn,
37
+ userAgent: cfg.userAgent,
38
+ auth
39
+ };
40
+ }
41
+ function validatePublicKeyValue(pk) {
42
+ if (typeof pk !== "string" || !PUBLIC_KEY_PATTERN.test(pk)) {
43
+ const shape = typeof pk === "string" ? `string of length ${pk.length}` : typeof pk;
44
+ throw new Error(
45
+ `scorezilla: publicKey must match ${PUBLIC_KEY_PATTERN.toString()} (got: ${shape})`
46
+ );
47
+ }
48
+ return pk;
49
+ }
50
+ var SECRET_KEY_PATTERN = /^sk_live_([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})_[A-Za-z0-9]+$/;
51
+ function validateSecretKey(cfg) {
52
+ if (!cfg || typeof cfg !== "object") {
53
+ throw new Error("scorezilla/server: config must be an object with a secretKey field");
54
+ }
55
+ const sk = cfg.secretKey;
56
+ if (typeof sk !== "string") {
57
+ throw new Error(
58
+ `scorezilla/server: secretKey must be a single string of the shape ${SECRET_KEY_PREFIX}<keyId>_<random> (got: ${typeof sk})`
59
+ );
60
+ }
61
+ const match = SECRET_KEY_PATTERN.exec(sk);
62
+ if (!match) {
63
+ const shape = `string of length ${sk.length}`;
64
+ throw new Error(
65
+ `scorezilla/server: secretKey must match ${SECRET_KEY_PATTERN.toString()} (got: ${shape}). v0.1.0-next.2 switched to a single-token format \u2014 if you have a pre-next.2 pair, issue a fresh key in the dashboard to upgrade.`
66
+ );
67
+ }
68
+ return { keyId: match[1], secret: sk };
69
+ }
70
+
71
+ // src/paths.ts
72
+ function encodeSegment(value, label) {
73
+ if (typeof value !== "string" || value.length === 0) {
74
+ throw new Error(`scorezilla: ${label} must be a non-empty string`);
75
+ }
76
+ return encodeURIComponent(value);
77
+ }
78
+ function buildQueryString(params) {
79
+ const usp = new URLSearchParams();
80
+ for (const [k, v] of Object.entries(params)) {
81
+ if (v !== void 0) usp.set(k, String(v));
82
+ }
83
+ const qs = usp.toString();
84
+ return qs.length > 0 ? `?${qs}` : "";
85
+ }
86
+ function submitScorePath(boardId) {
87
+ return `/v1/boards/${encodeSegment(boardId, "boardId")}/scores`;
88
+ }
89
+ function getLeaderboardPath(boardId, q) {
90
+ return `/v1/boards/${encodeSegment(boardId, "boardId")}/leaderboard` + buildQueryString({ top: q?.top, offset: q?.offset });
91
+ }
92
+ function getPlayerRankPath(boardId, playerId) {
93
+ return `/v1/boards/${encodeSegment(boardId, "boardId")}/players/${encodeSegment(playerId, "playerId")}/rank`;
94
+ }
95
+ function getWindowAroundPath(boardId, playerId, q) {
96
+ return `/v1/boards/${encodeSegment(boardId, "boardId")}/players/${encodeSegment(playerId, "playerId")}/window` + buildQueryString({ before: q?.before, after: q?.after });
97
+ }
98
+
99
+ // src/errors.ts
100
+ var MESSAGE_MAX_CHARS = 500;
101
+ var TRUNCATION_SUFFIX = "\u2026 [truncated]";
102
+ var STATUS_NETWORK_ERROR = 0;
103
+ function truncateMessage(raw) {
104
+ if (typeof raw !== "string") return "";
105
+ if (raw.length <= MESSAGE_MAX_CHARS) return raw;
106
+ const sliceEnd = Math.max(0, MESSAGE_MAX_CHARS - TRUNCATION_SUFFIX.length);
107
+ return raw.slice(0, sliceEnd) + TRUNCATION_SUFFIX;
108
+ }
109
+ function truncateField(raw) {
110
+ if (typeof raw !== "string") return void 0;
111
+ if (raw.length <= MESSAGE_MAX_CHARS) return raw;
112
+ const sliceEnd = Math.max(0, MESSAGE_MAX_CHARS - TRUNCATION_SUFFIX.length);
113
+ return raw.slice(0, sliceEnd) + TRUNCATION_SUFFIX;
114
+ }
115
+ var ScorezillaError = class _ScorezillaError extends Error {
116
+ /** HTTP status of the response, or {@link STATUS_NETWORK_ERROR} (0) for
117
+ * network / abort / timeout. */
118
+ status;
119
+ /** Machine-stable error code from the API. Open union — see
120
+ * {@link ScorezillaErrorCode}. For network errors, this is `'network_error'`;
121
+ * for aborts, `'aborted'`; for timeouts, `'timeout'`. */
122
+ code;
123
+ /** Sub-classifier — present on:
124
+ * - `out_of_bounds`: `'below_min' | 'above_max'`
125
+ * - `usage_cap_exceeded`: `'over_cap' | 'suspended'`
126
+ * and possibly other codes in future minor releases. */
127
+ reason;
128
+ /** Seconds — present on `rate_limited`. Honored by the transport's retry
129
+ * policy (Step 2.4). */
130
+ retryAfter;
131
+ /** Server-issued request ID, lifted from the `X-Request-Id` response
132
+ * header. Pass this to support when filing bugs. */
133
+ requestId;
134
+ /** The bound value crossed on `out_of_bounds`. */
135
+ bound;
136
+ /** Which rate-limit layer fired on `rate_limited`. */
137
+ layer;
138
+ /** Tenant's billing tier — present on `usage_cap_exceeded`. */
139
+ tier;
140
+ /** The cap value crossed on `usage_cap_exceeded`. `0` indicates a
141
+ * suspended tenant. `undefined` on all other error codes. */
142
+ cap;
143
+ /** The post-increment submit count on `usage_cap_exceeded`. Always
144
+ * `> cap` when `reason === 'over_cap'`. */
145
+ count;
146
+ /** The period the count belongs to on `usage_cap_exceeded`, in `YYYY-MM`
147
+ * UTC form. */
148
+ period;
149
+ /** ISO-8601 timestamp of midnight UTC on the 1st of the next month —
150
+ * the counter's natural reset point on `usage_cap_exceeded`. */
151
+ resetsAt;
152
+ /** The underlying cause (e.g., a `TypeError: fetch failed`) for
153
+ * network/abort/timeout paths. `undefined` when the error came from a
154
+ * successfully-parsed API error body. */
155
+ cause;
156
+ constructor(message, init) {
157
+ super(truncateMessage(message));
158
+ this.name = "ScorezillaError";
159
+ this.status = init.status;
160
+ this.code = init.code;
161
+ this.reason = truncateField(init.reason);
162
+ this.retryAfter = init.retryAfter;
163
+ this.requestId = truncateField(init.requestId);
164
+ this.bound = init.bound;
165
+ this.layer = truncateField(init.layer);
166
+ this.tier = truncateField(init.tier);
167
+ this.cap = init.cap;
168
+ this.count = init.count;
169
+ this.period = truncateField(init.period);
170
+ this.resetsAt = truncateField(init.resetsAt);
171
+ this.cause = init.cause;
172
+ Object.setPrototypeOf(this, _ScorezillaError.prototype);
173
+ if (typeof Error.captureStackTrace === "function") {
174
+ Error.captureStackTrace(this, _ScorezillaError);
175
+ }
176
+ }
177
+ // ─── Sub-message helpers ─────────────────────────────────────────────
178
+ // Stable wrappers over the `code` discriminator so consumers can write
179
+ // `if (err.isRateLimited())` instead of memorizing the code spelling.
180
+ /** `true` when this error is a 429 / `rate_limited`. */
181
+ isRateLimited() {
182
+ return this.code === "rate_limited";
183
+ }
184
+ /**
185
+ * `true` when this error is a 402 / `usage_cap_exceeded`. The tenant
186
+ * has either hit their tier's monthly submit cap (`reason ===
187
+ * 'over_cap'`) or is suspended (`reason === 'suspended'`).
188
+ *
189
+ * Consumers SHOULD NOT auto-retry on this error — the cap doesn't lift
190
+ * until `resetsAt`. Surface to the developer with an upgrade prompt
191
+ * (over_cap) or contact-support message (suspended).
192
+ */
193
+ isUsageCapExceeded() {
194
+ return this.code === "usage_cap_exceeded";
195
+ }
196
+ /** `true` when this error is a 402 + reason 'suspended' (vs over-cap). */
197
+ isSuspended() {
198
+ return this.code === "usage_cap_exceeded" && this.reason === "suspended";
199
+ }
200
+ /** `true` when this error is a 401 / `unauthorized` (or 403 / `forbidden`). */
201
+ isAuth() {
202
+ return this.code === "unauthorized" || this.code === "forbidden";
203
+ }
204
+ /** `true` when this error is a 404 / `not_found`. */
205
+ isNotFound() {
206
+ return this.code === "not_found";
207
+ }
208
+ /** `true` when this error is a 422 / `out_of_bounds` (score below/above board limit). */
209
+ isOutOfBounds() {
210
+ return this.code === "out_of_bounds";
211
+ }
212
+ /** `true` for the SDK's retryable conditions: pure network errors, 5xx, and
213
+ * 429. Deliberately excludes `timeout` and `aborted` — those are caller-
214
+ * observable terminal states, not transient. Aligned with `shouldRetryError`
215
+ * in `retry.ts` so a consumer mirroring the SDK's retry policy gets the
216
+ * same answer the transport does. */
217
+ isTransient() {
218
+ if (this.code === "network_error") return true;
219
+ if (this.status >= 500 && this.status < 600) return true;
220
+ return this.isRateLimited();
221
+ }
222
+ /** `true` when this error is a 409 / `conflict` (idempotency-key conflict
223
+ * on retry). */
224
+ isConflict() {
225
+ return this.code === "conflict";
226
+ }
227
+ // ─── Factory ─────────────────────────────────────────────────────────
228
+ /**
229
+ * Build a `ScorezillaError` from a fetch round-trip outcome.
230
+ *
231
+ * Prefer this over `new ScorezillaError(...)` from the transport layer —
232
+ * it does the mapping from API response shape to error fields in one
233
+ * place, so future fields like `correlationId` get added once here.
234
+ *
235
+ * @param init - status, optional parsed body, optional requestId, optional cause
236
+ */
237
+ static from(init) {
238
+ const { status, body, requestId, cause } = init;
239
+ if (body && body.ok === false && typeof body.error === "string") {
240
+ return new _ScorezillaError(body.message ?? `Request failed: ${body.error}`, {
241
+ status,
242
+ code: body.error,
243
+ reason: body.reason,
244
+ retryAfter: body.retryAfter,
245
+ bound: body.bound,
246
+ layer: body.layer,
247
+ // Usage-cap fields from `ApiError` (populated by the server on
248
+ // 402 responses; undefined on other errors).
249
+ tier: body.tier,
250
+ cap: body.cap,
251
+ count: body.count,
252
+ period: body.period,
253
+ resetsAt: body.resetsAt,
254
+ requestId,
255
+ cause
256
+ });
257
+ }
258
+ const code = codeForStatus(status);
259
+ return new _ScorezillaError(`Request failed with status ${status}`, {
260
+ status,
261
+ code,
262
+ requestId,
263
+ cause
264
+ });
265
+ }
266
+ /**
267
+ * Build a `ScorezillaError` for a transport-level failure (no HTTP
268
+ * response received): network error, abort, or timeout.
269
+ */
270
+ static network(message, cause) {
271
+ return new _ScorezillaError(message, {
272
+ status: STATUS_NETWORK_ERROR,
273
+ code: "network_error",
274
+ cause
275
+ });
276
+ }
277
+ /** Build a `ScorezillaError` for an `AbortSignal`-triggered cancellation. */
278
+ static aborted(cause) {
279
+ return new _ScorezillaError("Request aborted", {
280
+ status: STATUS_NETWORK_ERROR,
281
+ code: "aborted",
282
+ cause
283
+ });
284
+ }
285
+ /** Build a `ScorezillaError` for a request that exceeded its timeout budget. */
286
+ static timeout(timeoutMs) {
287
+ return new _ScorezillaError(`Request timed out after ${timeoutMs}ms`, {
288
+ status: STATUS_NETWORK_ERROR,
289
+ code: "timeout"
290
+ });
291
+ }
292
+ };
293
+ function codeForStatus(status) {
294
+ if (status === 401) return "unauthorized";
295
+ if (status === 402) return "usage_cap_exceeded";
296
+ if (status === 403) return "forbidden";
297
+ if (status === 404) return "not_found";
298
+ if (status === 409) return "conflict";
299
+ if (status === 422) return "out_of_bounds";
300
+ if (status === 429) return "rate_limited";
301
+ if (status >= 500) return "internal_error";
302
+ if (status >= 400) return "invalid_input";
303
+ return "internal_error";
304
+ }
305
+
306
+ // src/uuid.ts
307
+ function uuidV4FromBytes(bytes) {
308
+ bytes[6] = (bytes[6] ?? 0) & 15 | 64;
309
+ bytes[8] = (bytes[8] ?? 0) & 63 | 128;
310
+ let hex = "";
311
+ for (const b of bytes) {
312
+ hex += b.toString(16).padStart(2, "0");
313
+ }
314
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
315
+ }
316
+ function randomUUID() {
317
+ const c = globalThis.crypto;
318
+ if (c && typeof c.randomUUID === "function") {
319
+ return c.randomUUID();
320
+ }
321
+ if (c && typeof c.getRandomValues === "function") {
322
+ return uuidV4FromBytes(c.getRandomValues(new Uint8Array(16)));
323
+ }
324
+ throw new Error(
325
+ "scorezilla: no Web Crypto RNG available (neither crypto.randomUUID nor crypto.getRandomValues). The SDK requires Node \u2265 20 or a modern browser."
326
+ );
327
+ }
328
+
329
+ // src/retry.ts
330
+ var DEFAULT_MAX_RETRIES = 2;
331
+ var BASE_DELAY_MS = 200;
332
+ var MAX_DELAY_MS = 4e3;
333
+ var MAX_RETRY_AFTER_SEC = 30;
334
+ function nextDelay(attempt, retryAfterSec, random = Math.random) {
335
+ if (typeof retryAfterSec === "number" && Number.isFinite(retryAfterSec) && retryAfterSec >= 0 && retryAfterSec <= MAX_RETRY_AFTER_SEC) {
336
+ return Math.round(retryAfterSec * 1e3);
337
+ }
338
+ const exponential = Math.min(BASE_DELAY_MS * 2 ** attempt, MAX_DELAY_MS);
339
+ const jitterFactor = 0.5 + random() * 0.5;
340
+ return Math.round(exponential * jitterFactor);
341
+ }
342
+ function shouldRetryStatus(status) {
343
+ if (status === 429) return true;
344
+ if (status >= 500 && status < 600) return true;
345
+ return false;
346
+ }
347
+ function shouldRetryError(err) {
348
+ if (err instanceof ScorezillaError) {
349
+ return err.code === "network_error";
350
+ }
351
+ return false;
352
+ }
353
+ function generateIdempotencyKey() {
354
+ try {
355
+ return randomUUID();
356
+ } catch {
357
+ throw new ScorezillaError(
358
+ "scorezilla: no Web Crypto RNG available (neither crypto.randomUUID nor crypto.getRandomValues). The SDK requires Node \u2265 20 or a modern browser.",
359
+ { status: 0, code: "internal_error" }
360
+ );
361
+ }
362
+ }
363
+ function sleep(ms, signal) {
364
+ return new Promise((resolve, reject) => {
365
+ if (signal?.aborted) {
366
+ reject(signal.reason ?? new DOMException("Aborted", "AbortError"));
367
+ return;
368
+ }
369
+ const timer = setTimeout(() => {
370
+ signal?.removeEventListener("abort", onAbort);
371
+ resolve();
372
+ }, ms);
373
+ const onAbort = () => {
374
+ clearTimeout(timer);
375
+ signal?.removeEventListener("abort", onAbort);
376
+ reject(signal?.reason ?? new DOMException("Aborted", "AbortError"));
377
+ };
378
+ signal?.addEventListener("abort", onAbort, { once: true });
379
+ });
380
+ }
381
+
382
+ // src/transport.ts
383
+ var DEFAULT_TIMEOUT_MS = 3e4;
384
+ async function request(opts) {
385
+ const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
386
+ if (typeof fetchImpl !== "function") {
387
+ throw new Error(
388
+ "scorezilla: globalThis.fetch is unavailable. Either upgrade your runtime (Node \u2265 20 has fetch built in) or pass `fetch: yourFetch` in the SDK config."
389
+ );
390
+ }
391
+ const url = buildUrl(opts.baseUrl, opts.path);
392
+ const maxRetries = opts.retry?.maxRetries ?? DEFAULT_MAX_RETRIES;
393
+ const random = opts.retry?.random ?? Math.random;
394
+ const sleepImpl = opts.retry?.sleepImpl ?? sleep;
395
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
396
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
397
+ throw new Error(
398
+ `scorezilla: timeoutMs must be a positive finite number (got ${String(timeoutMs)})`
399
+ );
400
+ }
401
+ const retrySleep = async (delay) => {
402
+ try {
403
+ await sleepImpl(delay, opts.signal);
404
+ } catch (cause) {
405
+ throw ScorezillaError.aborted(cause);
406
+ }
407
+ };
408
+ const idempotencyKey = opts.method === "POST" ? generateIdempotencyKey() : null;
409
+ let lastError;
410
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
411
+ const combined = combineSignalsWithTimeout(opts.signal, timeoutMs);
412
+ try {
413
+ const bodyString = opts.body !== void 0 ? JSON.stringify(opts.body) : "";
414
+ const perAttemptHeaders = { ...opts.headers ?? {} };
415
+ if (opts.signRequest) {
416
+ perAttemptHeaders.Authorization = await opts.signRequest({
417
+ method: opts.method,
418
+ pathAndQuery: opts.path,
419
+ body: bodyString
420
+ });
421
+ }
422
+ const init = {
423
+ method: opts.method,
424
+ headers: buildHeaders({ ...opts, headers: perAttemptHeaders }, idempotencyKey),
425
+ signal: combined.signal
426
+ };
427
+ if (opts.body !== void 0) {
428
+ init.body = bodyString;
429
+ }
430
+ const response = await fetchImpl(url, init);
431
+ if (response.ok) {
432
+ warnOnDeprecationOnce(response, opts.warnImpl);
433
+ return await parseJson(response);
434
+ }
435
+ const body = await safelyParseErrorBody(response);
436
+ const err = ScorezillaError.from({
437
+ status: response.status,
438
+ body,
439
+ requestId: response.headers.get("X-Request-Id") ?? void 0
440
+ });
441
+ if (shouldRetryStatus(response.status) && attempt < maxRetries) {
442
+ const retryAfter = readRetryAfter(response);
443
+ const delay = nextDelay(attempt, retryAfter, random);
444
+ await retrySleep(delay);
445
+ lastError = err;
446
+ continue;
447
+ }
448
+ throw err;
449
+ } catch (caught) {
450
+ if (caught instanceof ScorezillaError) {
451
+ if (shouldRetryError(caught) && attempt < maxRetries) {
452
+ const delay = nextDelay(attempt, void 0, random);
453
+ await retrySleep(delay);
454
+ lastError = caught;
455
+ continue;
456
+ }
457
+ throw caught;
458
+ }
459
+ const mapped = mapTransportError(caught, opts.signal, timeoutMs, combined);
460
+ if (shouldRetryError(mapped) && attempt < maxRetries) {
461
+ const delay = nextDelay(attempt, void 0, random);
462
+ await retrySleep(delay);
463
+ lastError = mapped;
464
+ continue;
465
+ }
466
+ throw mapped;
467
+ } finally {
468
+ combined.cleanup();
469
+ }
470
+ }
471
+ throw lastError ?? new ScorezillaError("Request failed after retries", {
472
+ status: 0,
473
+ code: "internal_error"
474
+ });
475
+ }
476
+ function buildUrl(baseUrl, path) {
477
+ const base = baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl;
478
+ return base + path;
479
+ }
480
+ function buildHeaders(opts, idempotencyKey) {
481
+ const headers = { Accept: "application/json" };
482
+ if (opts.body !== void 0) {
483
+ headers["Content-Type"] = "application/json";
484
+ }
485
+ if (idempotencyKey !== null) {
486
+ headers["Idempotency-Key"] = idempotencyKey;
487
+ }
488
+ if (opts.headers) {
489
+ for (const [k, v] of Object.entries(opts.headers)) headers[k] = v;
490
+ }
491
+ return headers;
492
+ }
493
+ async function parseJson(response) {
494
+ const requestId = response.headers.get("X-Request-Id") ?? void 0;
495
+ let parsed;
496
+ try {
497
+ parsed = await response.json();
498
+ } catch (cause) {
499
+ throw new ScorezillaError("Response body was not valid JSON", {
500
+ status: response.status,
501
+ code: "invalid_json",
502
+ requestId,
503
+ cause
504
+ });
505
+ }
506
+ if (parsed === null || typeof parsed !== "object") {
507
+ const observed = parsed === null ? "null" : typeof parsed;
508
+ throw new ScorezillaError(`Response body was not a JSON object (got ${observed})`, {
509
+ status: response.status,
510
+ code: "invalid_json",
511
+ requestId
512
+ });
513
+ }
514
+ const okField = parsed.ok;
515
+ if (okField !== true) {
516
+ throw new ScorezillaError(
517
+ `Response body on a 2xx is missing the \`ok: true\` discriminator (got ok=${String(okField)})`,
518
+ {
519
+ status: response.status,
520
+ code: "invalid_json",
521
+ requestId
522
+ }
523
+ );
524
+ }
525
+ return parsed;
526
+ }
527
+ async function safelyParseErrorBody(response) {
528
+ try {
529
+ const json = await response.json();
530
+ if (json && typeof json === "object" && "ok" in json && json.ok === false) {
531
+ return json;
532
+ }
533
+ return void 0;
534
+ } catch {
535
+ return void 0;
536
+ }
537
+ }
538
+ function readRetryAfter(response) {
539
+ const raw = response.headers.get("Retry-After");
540
+ if (!raw) return void 0;
541
+ const n = Number(raw);
542
+ return Number.isFinite(n) && n >= 0 ? n : void 0;
543
+ }
544
+ var seenDeprecations = /* @__PURE__ */ new Set();
545
+ function warnOnDeprecationOnce(response, warnImpl) {
546
+ const deprecation = response.headers.get("Deprecation");
547
+ const sunset = response.headers.get("Sunset");
548
+ if (!deprecation && !sunset) return;
549
+ const link = response.headers.get("Link") ?? "";
550
+ const key = `${deprecation ?? ""}|${sunset ?? ""}|${link}`;
551
+ if (seenDeprecations.has(key)) return;
552
+ seenDeprecations.add(key);
553
+ const detail = [];
554
+ if (deprecation === "true" || deprecation) detail.push(`Deprecation: ${deprecation}`);
555
+ if (sunset) detail.push(`Sunset: ${sunset}`);
556
+ if (link) {
557
+ const m = link.match(/<([^>]+)>/);
558
+ if (m) detail.push(`Docs: ${m[1]}`);
559
+ }
560
+ const message = `[scorezilla-sdk] API responded with deprecation signal: ${detail.join(" \xB7 ")}. Upgrade your SDK before the sunset date.`;
561
+ if (warnImpl) {
562
+ warnImpl(message);
563
+ } else {
564
+ console.warn(message);
565
+ }
566
+ }
567
+ function combineSignalsWithTimeout(caller, timeoutMs) {
568
+ const ctrl = new AbortController();
569
+ let didTimeOut = false;
570
+ if (caller?.aborted) {
571
+ ctrl.abort(caller.reason);
572
+ return { signal: ctrl.signal, cleanup: () => {
573
+ }, timedOut: () => false };
574
+ }
575
+ const onCallerAbort = () => {
576
+ ctrl.abort(caller?.reason);
577
+ };
578
+ caller?.addEventListener("abort", onCallerAbort, { once: true });
579
+ const timer = setTimeout(() => {
580
+ didTimeOut = true;
581
+ ctrl.abort(new DOMException(`Request timed out after ${timeoutMs}ms`, "TimeoutError"));
582
+ }, timeoutMs);
583
+ return {
584
+ signal: ctrl.signal,
585
+ cleanup: () => {
586
+ clearTimeout(timer);
587
+ caller?.removeEventListener("abort", onCallerAbort);
588
+ },
589
+ timedOut: () => didTimeOut
590
+ };
591
+ }
592
+ function mapTransportError(caught, callerSignal, timeoutMs, combined) {
593
+ if (callerSignal?.aborted) {
594
+ return ScorezillaError.aborted(callerSignal.reason ?? caught);
595
+ }
596
+ if (combined.timedOut()) {
597
+ return ScorezillaError.timeout(timeoutMs);
598
+ }
599
+ const message = caught instanceof Error ? caught.message : "Network request failed";
600
+ return ScorezillaError.network(message, caught);
601
+ }
602
+
603
+ // src/user-agent.ts
604
+ function detectRuntime(g = globalThis) {
605
+ if (typeof g.Bun !== "undefined") return "bun";
606
+ if (typeof g.Deno !== "undefined") return "deno";
607
+ if (typeof g.navigator?.userAgent === "string" && g.navigator.userAgent.includes("Cloudflare-Workers")) {
608
+ return "workers";
609
+ }
610
+ if (typeof g.process?.versions?.node === "string") return "node";
611
+ if (typeof g.document !== "undefined") return "browser";
612
+ return "unknown";
613
+ }
614
+ function defaultUserAgent(version, runtime = detectRuntime()) {
615
+ return `scorezilla-js/${version} (${runtime})`;
616
+ }
617
+
618
+ // src/client.ts
619
+ var METADATA_MAX_BYTES = 4096;
620
+ function validateMetadata(metadata) {
621
+ if (metadata === null || typeof metadata !== "object" || Array.isArray(metadata)) {
622
+ throw new Error(
623
+ "scorezilla: metadata must be a plain object (got " + (Array.isArray(metadata) ? "array" : typeof metadata) + ")"
624
+ );
625
+ }
626
+ let serialized;
627
+ try {
628
+ serialized = JSON.stringify(metadata, (_key, value) => {
629
+ if (typeof value === "function") {
630
+ throw new Error("scorezilla: metadata may not contain functions");
631
+ }
632
+ if (typeof value === "symbol") {
633
+ throw new Error("scorezilla: metadata may not contain symbols");
634
+ }
635
+ if (typeof value === "bigint") {
636
+ throw new Error("scorezilla: metadata may not contain BigInt");
637
+ }
638
+ return value;
639
+ });
640
+ } catch (cause) {
641
+ if (cause instanceof Error) {
642
+ if (cause.message.startsWith("scorezilla:")) throw cause;
643
+ if (/circular|convert|cyclic/i.test(cause.message)) {
644
+ throw new Error("scorezilla: metadata contains circular references");
645
+ }
646
+ }
647
+ throw cause;
648
+ }
649
+ const byteLength = new TextEncoder().encode(serialized).length;
650
+ if (byteLength > METADATA_MAX_BYTES) {
651
+ throw new Error(
652
+ `scorezilla: metadata exceeds ${METADATA_MAX_BYTES} bytes (got ${byteLength} bytes when JSON-stringified)`
653
+ );
654
+ }
655
+ return serialized;
656
+ }
657
+ var Scorezilla = class _Scorezilla {
658
+ /** The package version, injected at build time from `package.json`. */
659
+ static version = "0.5.0";
660
+ #config;
661
+ #userAgent;
662
+ #authHeader;
663
+ /**
664
+ * @param config - public-key configuration. Passing `secretKey` throws —
665
+ * use the `scorezilla/server` adapter (v0.2.0) for HMAC.
666
+ */
667
+ constructor(config) {
668
+ const resolved = validateConfig(config);
669
+ if (resolved.auth.kind !== "public") {
670
+ throw new Error(
671
+ "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."
672
+ );
673
+ }
674
+ this.#config = resolved;
675
+ this.#userAgent = resolved.userAgent ?? defaultUserAgent(_Scorezilla.version);
676
+ this.#authHeader = `Bearer ${resolved.auth.key}`;
677
+ }
678
+ /**
679
+ * Submit a score to a board.
680
+ *
681
+ * Maps to `POST /v1/boards/:boardId/scores`. See [API.md](../API.md#submitscore)
682
+ * for the full contract.
683
+ *
684
+ * @example
685
+ * ```ts
686
+ * try {
687
+ * const r = await sz.submitScore({ boardId, playerId: 'alice', score: 9001 });
688
+ * if (r.isPersonalBest) console.log(`PB! Rank ${r.rank} of ${r.totalEntries}`);
689
+ * } catch (e) {
690
+ * if (e instanceof ScorezillaError && e.code === 'out_of_bounds') {
691
+ * console.warn(`Score outside board bounds (${e.reason}, limit ${e.bound})`);
692
+ * } else throw e;
693
+ * }
694
+ * ```
695
+ *
696
+ * @throws {ScorezillaError} `unauthorized` (bad publicKey), `forbidden`
697
+ * (key not bound to this board), `not_found` (board doesn't exist),
698
+ * `out_of_bounds` (score outside board's min/max), `rate_limited`
699
+ * (Layer 2/3 throttle hit), `invalid_input`, `network_error`, `timeout`.
700
+ * @since 0.1.0
701
+ * @stability stable
702
+ */
703
+ async submitScore(input) {
704
+ if (input.metadata !== void 0) {
705
+ validateMetadata(input.metadata);
706
+ }
707
+ const body = {
708
+ playerId: input.playerId,
709
+ score: input.score
710
+ };
711
+ if (input.metadata !== void 0) {
712
+ body.metadata = input.metadata;
713
+ }
714
+ if (input.name !== void 0) {
715
+ body.name = input.name;
716
+ }
717
+ if (input.turnstileToken !== void 0) {
718
+ body.turnstileToken = input.turnstileToken;
719
+ }
720
+ return this.#request({
721
+ path: submitScorePath(input.boardId),
722
+ method: "POST",
723
+ body,
724
+ signal: input.signal
725
+ });
726
+ }
727
+ /**
728
+ * Fetch the top-N leaderboard for a board.
729
+ *
730
+ * Maps to `GET /v1/boards/:boardId/leaderboard`.
731
+ *
732
+ * @example
733
+ * ```ts
734
+ * const { entries } = await sz.getLeaderboard({ boardId, top: 25 });
735
+ * for (const e of entries) console.log(`${e.rank}. ${e.playerId}: ${e.score}`);
736
+ * ```
737
+ *
738
+ * @throws {ScorezillaError} `not_found`, `network_error`, `timeout`.
739
+ * @since 0.1.0
740
+ * @stability stable
741
+ */
742
+ async getLeaderboard(input) {
743
+ const q = {};
744
+ if (input.top !== void 0) q.top = input.top;
745
+ if (input.offset !== void 0) q.offset = input.offset;
746
+ return this.#request({
747
+ path: getLeaderboardPath(input.boardId, q),
748
+ method: "GET",
749
+ signal: input.signal
750
+ });
751
+ }
752
+ /**
753
+ * Fetch a single player's rank on a board.
754
+ *
755
+ * Maps to `GET /v1/boards/:boardId/players/:playerId/rank`. "No entry yet"
756
+ * is a normal result, NOT an error: the response is `{ ranked: false }`
757
+ * (narrow on `ranked` before reading `rank`). A `not_found` is thrown only
758
+ * when the board itself doesn't exist.
759
+ *
760
+ * @example
761
+ * ```ts
762
+ * const r = await sz.getPlayerRank({ boardId, playerId: 'alice' });
763
+ * if (r.ranked) console.log(`Alice is rank ${r.rank} with score ${r.score}`);
764
+ * else console.log('Alice has no submission on this board yet.');
765
+ * ```
766
+ *
767
+ * @throws {ScorezillaError} `not_found` (board does not exist),
768
+ * `network_error`, `timeout`.
769
+ * @since 0.1.0
770
+ * @stability stable
771
+ */
772
+ async getPlayerRank(input) {
773
+ return this.#request({
774
+ path: getPlayerRankPath(input.boardId, input.playerId),
775
+ method: "GET",
776
+ signal: input.signal
777
+ });
778
+ }
779
+ /**
780
+ * Fetch the slice of entries surrounding a player.
781
+ *
782
+ * Maps to `GET /v1/boards/:boardId/players/:playerId/window?before=&after=`.
783
+ *
784
+ * @example
785
+ * ```ts
786
+ * const { entries } = await sz.getWindowAround({
787
+ * boardId, playerId: 'alice', before: 2, after: 2,
788
+ * });
789
+ * ```
790
+ *
791
+ * @throws {ScorezillaError} `network_error`, `timeout`.
792
+ * @since 0.1.0
793
+ * @stability stable
794
+ */
795
+ async getWindowAround(input) {
796
+ const q = {};
797
+ if (input.before !== void 0) q.before = input.before;
798
+ if (input.after !== void 0) q.after = input.after;
799
+ return this.#request({
800
+ path: getWindowAroundPath(input.boardId, input.playerId, q),
801
+ method: "GET",
802
+ signal: input.signal
803
+ });
804
+ }
805
+ // ─── Internal ────────────────────────────────────────────────────────
806
+ /**
807
+ * Common request wiring — auth header, default fetch impl, timeout, retry.
808
+ * Each public method composes its `path`, `method`, and (optionally) `body`
809
+ * and hands them to this thin pass-through. Keeps the four method bodies
810
+ * boilerplate-free and ensures every call shares identical defaults.
811
+ */
812
+ async #request(opts) {
813
+ const headers = {
814
+ Authorization: this.#authHeader,
815
+ // User-Agent: ignored by browsers (per Fetch spec), useful in
816
+ // Node/Bun/Deno/Workers for server-side observability.
817
+ "User-Agent": this.#userAgent,
818
+ // X-Scorezilla-Client: browser-honored, mirrors the UA for telemetry
819
+ // parity across runtimes.
820
+ "X-Scorezilla-Client": this.#userAgent
821
+ };
822
+ const requestOpts = {
823
+ baseUrl: this.#config.baseUrl,
824
+ path: opts.path,
825
+ method: opts.method,
826
+ headers
827
+ };
828
+ if (opts.body !== void 0) requestOpts.body = opts.body;
829
+ if (opts.signal !== void 0) requestOpts.signal = opts.signal;
830
+ if (this.#config.fetch !== void 0) requestOpts.fetchImpl = this.#config.fetch;
831
+ if (this.#config.warn !== void 0) requestOpts.warnImpl = this.#config.warn;
832
+ if (this.#config.timeoutMs !== void 0) requestOpts.timeoutMs = this.#config.timeoutMs;
833
+ if (this.#config.maxRetries !== void 0 || this.#config.sleepImpl !== void 0) {
834
+ requestOpts.retry = {
835
+ ...this.#config.maxRetries !== void 0 ? { maxRetries: this.#config.maxRetries } : {},
836
+ ...this.#config.sleepImpl !== void 0 ? { sleepImpl: this.#config.sleepImpl } : {}
837
+ };
838
+ }
839
+ return request(requestOpts);
840
+ }
841
+ };
842
+
843
+ // src/headless.ts
844
+ function createHeadlessClient(config) {
845
+ const sz = new Scorezilla(config);
846
+ return {
847
+ async submit(input) {
848
+ try {
849
+ const r = await sz.submitScore(input);
850
+ return {
851
+ ok: true,
852
+ rank: r.rank,
853
+ totalEntries: r.totalEntries,
854
+ isPersonalBest: r.isPersonalBest
855
+ };
856
+ } catch {
857
+ return null;
858
+ }
859
+ },
860
+ async getLeaderboard(input) {
861
+ try {
862
+ const { entries } = await sz.getLeaderboard(input);
863
+ return entries;
864
+ } catch {
865
+ return [];
866
+ }
867
+ }
868
+ };
869
+ }
870
+ function isCrossOrigin(homeOrigin, currentOrigin = globalThis.location?.origin) {
871
+ if (!currentOrigin) return false;
872
+ try {
873
+ return currentOrigin !== new URL(homeOrigin).origin;
874
+ } catch {
875
+ return false;
876
+ }
877
+ }
878
+
879
+ exports.createHeadlessClient = createHeadlessClient;
880
+ exports.isCrossOrigin = isCrossOrigin;
881
+ //# sourceMappingURL=headless.cjs.map
882
+ //# sourceMappingURL=headless.cjs.map