scorezilla 0.1.0-next.0 → 0.1.0-next.3
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/CHANGELOG.md +116 -0
- package/README.md +37 -4
- package/dist/errors-CUAQsaVS.d.cts +292 -0
- package/dist/errors-CUAQsaVS.d.ts +292 -0
- package/dist/index.cjs +89 -32
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +3 -283
- package/dist/index.d.ts +3 -283
- package/dist/index.js +89 -32
- package/dist/index.js.map +1 -1
- package/dist/server.cjs +688 -3
- package/dist/server.cjs.map +1 -1
- package/dist/server.d.cts +141 -1
- package/dist/server.d.ts +141 -1
- package/dist/server.js +687 -3
- package/dist/server.js.map +1 -1
- package/package.json +26 -21
package/dist/server.cjs
CHANGED
|
@@ -1,8 +1,693 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
// src/config.ts
|
|
4
|
+
var DEFAULT_BASE_URL = "https://api.scorezilla.dev";
|
|
5
|
+
var SECRET_KEY_PREFIX = "sk_live_";
|
|
6
|
+
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]+$/;
|
|
7
|
+
function validateSecretKey(cfg) {
|
|
8
|
+
if (!cfg || typeof cfg !== "object") {
|
|
9
|
+
throw new Error("scorezilla/server: config must be an object with a secretKey field");
|
|
10
|
+
}
|
|
11
|
+
const sk = cfg.secretKey;
|
|
12
|
+
if (typeof sk !== "string") {
|
|
13
|
+
throw new Error(
|
|
14
|
+
`scorezilla/server: secretKey must be a single string of the shape ${SECRET_KEY_PREFIX}<keyId>_<random> (got: ${typeof sk})`
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
const match = SECRET_KEY_PATTERN.exec(sk);
|
|
18
|
+
if (!match) {
|
|
19
|
+
const shape = `string of length ${sk.length}`;
|
|
20
|
+
throw new Error(
|
|
21
|
+
`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.`
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
return { keyId: match[1], secret: sk };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// src/hmac.ts
|
|
28
|
+
var enc = new TextEncoder();
|
|
29
|
+
var HMAC_AUTH_SCHEME = "Scorezilla-HMAC-SHA256";
|
|
30
|
+
var MIN_NONCE_LENGTH = 16;
|
|
31
|
+
var HMAC_SIGNING_VERSION_LATEST = 2;
|
|
32
|
+
async function buildSigningString(method, pathAndQuery, ts, nonce, body, host, version = HMAC_SIGNING_VERSION_LATEST) {
|
|
33
|
+
const bodyHash = await sha256Hex(body);
|
|
34
|
+
const upperMethod = method.toUpperCase();
|
|
35
|
+
if (version === 1) {
|
|
36
|
+
return `${ts}
|
|
37
|
+
${nonce}
|
|
38
|
+
${upperMethod}
|
|
39
|
+
${pathAndQuery}
|
|
40
|
+
${bodyHash}`;
|
|
41
|
+
}
|
|
42
|
+
return `${ts}
|
|
43
|
+
${nonce}
|
|
44
|
+
${upperMethod}
|
|
45
|
+
${host.toLowerCase()}
|
|
46
|
+
${pathAndQuery}
|
|
47
|
+
${bodyHash}`;
|
|
48
|
+
}
|
|
49
|
+
async function hmacSha256B64u(secret, message) {
|
|
50
|
+
const key = await crypto.subtle.importKey(
|
|
51
|
+
"raw",
|
|
52
|
+
enc.encode(secret),
|
|
53
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
54
|
+
false,
|
|
55
|
+
["sign"]
|
|
56
|
+
);
|
|
57
|
+
const sig = await crypto.subtle.sign("HMAC", key, enc.encode(message));
|
|
58
|
+
return base64UrlEncode(new Uint8Array(sig));
|
|
59
|
+
}
|
|
60
|
+
async function sha256Hex(message) {
|
|
61
|
+
const digest = await crypto.subtle.digest("SHA-256", enc.encode(message));
|
|
62
|
+
return Array.from(new Uint8Array(digest)).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
63
|
+
}
|
|
64
|
+
async function buildHmacAuthHeader(args) {
|
|
65
|
+
const ts = args.nowSeconds ?? Math.floor(Date.now() / 1e3);
|
|
66
|
+
if (args.nonce !== void 0) {
|
|
67
|
+
if (typeof args.nonce !== "string" || args.nonce.length < MIN_NONCE_LENGTH) {
|
|
68
|
+
throw new Error(
|
|
69
|
+
`scorezilla: nonce must be a string of at least ${MIN_NONCE_LENGTH} characters; use the default (UUIDv4 via crypto.randomUUID) unless you have a specific reason.`
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
const nonce = args.nonce ?? generateNonce();
|
|
74
|
+
const version = args.version ?? HMAC_SIGNING_VERSION_LATEST;
|
|
75
|
+
const signingString = await buildSigningString(
|
|
76
|
+
args.method,
|
|
77
|
+
args.pathAndQuery,
|
|
78
|
+
ts,
|
|
79
|
+
nonce,
|
|
80
|
+
args.body,
|
|
81
|
+
args.host,
|
|
82
|
+
version
|
|
83
|
+
);
|
|
84
|
+
const signature = await hmacSha256B64u(args.secret, signingString);
|
|
85
|
+
const vParam = version === 1 ? "" : `, v=${version}`;
|
|
86
|
+
return `${HMAC_AUTH_SCHEME} keyId=${args.keyId}, ts=${ts}, nonce=${nonce}, signature=${signature}${vParam}`;
|
|
87
|
+
}
|
|
88
|
+
function generateNonce() {
|
|
89
|
+
const c = globalThis.crypto;
|
|
90
|
+
if (!c || typeof c.randomUUID !== "function") {
|
|
91
|
+
throw new Error(
|
|
92
|
+
"scorezilla: globalThis.crypto.randomUUID is unavailable. The HMAC server adapter requires Node \u2265 20 or a modern runtime."
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
return c.randomUUID();
|
|
96
|
+
}
|
|
97
|
+
function base64UrlEncode(bytes) {
|
|
98
|
+
let bin = "";
|
|
99
|
+
for (const b of bytes) bin += String.fromCharCode(b);
|
|
100
|
+
return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// src/paths.ts
|
|
104
|
+
function encodeSegment(value, label) {
|
|
105
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
106
|
+
throw new Error(`scorezilla: ${label} must be a non-empty string`);
|
|
107
|
+
}
|
|
108
|
+
return encodeURIComponent(value);
|
|
109
|
+
}
|
|
110
|
+
function buildQueryString(params) {
|
|
111
|
+
const usp = new URLSearchParams();
|
|
112
|
+
for (const [k, v] of Object.entries(params)) {
|
|
113
|
+
if (v !== void 0) usp.set(k, String(v));
|
|
114
|
+
}
|
|
115
|
+
const qs = usp.toString();
|
|
116
|
+
return qs.length > 0 ? `?${qs}` : "";
|
|
117
|
+
}
|
|
118
|
+
function submitScoreSecurePath() {
|
|
119
|
+
return "/v1/secure/scores";
|
|
120
|
+
}
|
|
121
|
+
function getLeaderboardPath(boardId, q) {
|
|
122
|
+
return `/v1/boards/${encodeSegment(boardId, "boardId")}/leaderboard` + buildQueryString({ top: q?.top, offset: q?.offset });
|
|
123
|
+
}
|
|
124
|
+
function getPlayerRankPath(boardId, playerId) {
|
|
125
|
+
return `/v1/boards/${encodeSegment(boardId, "boardId")}/players/${encodeSegment(playerId, "playerId")}/rank`;
|
|
126
|
+
}
|
|
127
|
+
function getWindowAroundPath(boardId, playerId, q) {
|
|
128
|
+
return `/v1/boards/${encodeSegment(boardId, "boardId")}/players/${encodeSegment(playerId, "playerId")}/window` + buildQueryString({ before: q?.before, after: q?.after });
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// src/errors.ts
|
|
132
|
+
var MESSAGE_MAX_CHARS = 500;
|
|
133
|
+
var TRUNCATION_SUFFIX = "\u2026 [truncated]";
|
|
134
|
+
var STATUS_NETWORK_ERROR = 0;
|
|
135
|
+
function truncateMessage(raw) {
|
|
136
|
+
if (typeof raw !== "string") return "";
|
|
137
|
+
if (raw.length <= MESSAGE_MAX_CHARS) return raw;
|
|
138
|
+
const sliceEnd = Math.max(0, MESSAGE_MAX_CHARS - TRUNCATION_SUFFIX.length);
|
|
139
|
+
return raw.slice(0, sliceEnd) + TRUNCATION_SUFFIX;
|
|
140
|
+
}
|
|
141
|
+
function truncateField(raw) {
|
|
142
|
+
if (typeof raw !== "string") return void 0;
|
|
143
|
+
if (raw.length <= MESSAGE_MAX_CHARS) return raw;
|
|
144
|
+
const sliceEnd = Math.max(0, MESSAGE_MAX_CHARS - TRUNCATION_SUFFIX.length);
|
|
145
|
+
return raw.slice(0, sliceEnd) + TRUNCATION_SUFFIX;
|
|
146
|
+
}
|
|
147
|
+
var ScorezillaError = class _ScorezillaError extends Error {
|
|
148
|
+
/** HTTP status of the response, or {@link STATUS_NETWORK_ERROR} (0) for
|
|
149
|
+
* network / abort / timeout. */
|
|
150
|
+
status;
|
|
151
|
+
/** Machine-stable error code from the API. Open union — see
|
|
152
|
+
* {@link ScorezillaErrorCode}. For network errors, this is `'network_error'`;
|
|
153
|
+
* for aborts, `'aborted'`; for timeouts, `'timeout'`. */
|
|
154
|
+
code;
|
|
155
|
+
/** Sub-classifier — present on `out_of_bounds` (`'below_min' | 'above_max'`)
|
|
156
|
+
* and possibly other codes in future minor releases. */
|
|
157
|
+
reason;
|
|
158
|
+
/** Seconds — present on `rate_limited`. Honored by the transport's retry
|
|
159
|
+
* policy (Step 2.4). */
|
|
160
|
+
retryAfter;
|
|
161
|
+
/** Server-issued request ID, lifted from the `X-Request-Id` response
|
|
162
|
+
* header. Pass this to support when filing bugs. */
|
|
163
|
+
requestId;
|
|
164
|
+
/** The bound value crossed on `out_of_bounds`. */
|
|
165
|
+
bound;
|
|
166
|
+
/** Which rate-limit layer fired on `rate_limited`. */
|
|
167
|
+
layer;
|
|
168
|
+
/** The underlying cause (e.g., a `TypeError: fetch failed`) for
|
|
169
|
+
* network/abort/timeout paths. `undefined` when the error came from a
|
|
170
|
+
* successfully-parsed API error body. */
|
|
171
|
+
cause;
|
|
172
|
+
constructor(message, init) {
|
|
173
|
+
super(truncateMessage(message));
|
|
174
|
+
this.name = "ScorezillaError";
|
|
175
|
+
this.status = init.status;
|
|
176
|
+
this.code = init.code;
|
|
177
|
+
this.reason = truncateField(init.reason);
|
|
178
|
+
this.retryAfter = init.retryAfter;
|
|
179
|
+
this.requestId = truncateField(init.requestId);
|
|
180
|
+
this.bound = init.bound;
|
|
181
|
+
this.layer = truncateField(init.layer);
|
|
182
|
+
this.cause = init.cause;
|
|
183
|
+
Object.setPrototypeOf(this, _ScorezillaError.prototype);
|
|
184
|
+
if (typeof Error.captureStackTrace === "function") {
|
|
185
|
+
Error.captureStackTrace(this, _ScorezillaError);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
// ─── Sub-message helpers ─────────────────────────────────────────────
|
|
189
|
+
// Stable wrappers over the `code` discriminator so consumers can write
|
|
190
|
+
// `if (err.isRateLimited())` instead of memorizing the code spelling.
|
|
191
|
+
/** `true` when this error is a 429 / `rate_limited`. */
|
|
192
|
+
isRateLimited() {
|
|
193
|
+
return this.code === "rate_limited";
|
|
194
|
+
}
|
|
195
|
+
/** `true` when this error is a 401 / `unauthorized` (or 403 / `forbidden`). */
|
|
196
|
+
isAuth() {
|
|
197
|
+
return this.code === "unauthorized" || this.code === "forbidden";
|
|
198
|
+
}
|
|
199
|
+
/** `true` when this error is a 404 / `not_found`. */
|
|
200
|
+
isNotFound() {
|
|
201
|
+
return this.code === "not_found";
|
|
202
|
+
}
|
|
203
|
+
/** `true` when this error is a 422 / `out_of_bounds` (score below/above board limit). */
|
|
204
|
+
isOutOfBounds() {
|
|
205
|
+
return this.code === "out_of_bounds";
|
|
206
|
+
}
|
|
207
|
+
/** `true` for transient / retryable conditions: network errors, timeouts,
|
|
208
|
+
* 5xx, and 429. The transport layer relies on this for its retry policy. */
|
|
209
|
+
isTransient() {
|
|
210
|
+
if (this.status === STATUS_NETWORK_ERROR) return true;
|
|
211
|
+
if (this.status >= 500 && this.status < 600) return true;
|
|
212
|
+
return this.isRateLimited();
|
|
213
|
+
}
|
|
214
|
+
// ─── Factory ─────────────────────────────────────────────────────────
|
|
215
|
+
/**
|
|
216
|
+
* Build a `ScorezillaError` from a fetch round-trip outcome.
|
|
217
|
+
*
|
|
218
|
+
* Prefer this over `new ScorezillaError(...)` from the transport layer —
|
|
219
|
+
* it does the mapping from API response shape to error fields in one
|
|
220
|
+
* place, so future fields like `correlationId` get added once here.
|
|
221
|
+
*
|
|
222
|
+
* @param init - status, optional parsed body, optional requestId, optional cause
|
|
223
|
+
*/
|
|
224
|
+
static from(init) {
|
|
225
|
+
const { status, body, requestId, cause } = init;
|
|
226
|
+
if (body && body.ok === false && typeof body.error === "string") {
|
|
227
|
+
return new _ScorezillaError(body.message ?? `Request failed: ${body.error}`, {
|
|
228
|
+
status,
|
|
229
|
+
code: body.error,
|
|
230
|
+
reason: body.reason,
|
|
231
|
+
retryAfter: body.retryAfter,
|
|
232
|
+
bound: body.bound,
|
|
233
|
+
layer: body.layer,
|
|
234
|
+
requestId,
|
|
235
|
+
cause
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
const code = codeForStatus(status);
|
|
239
|
+
return new _ScorezillaError(`Request failed with status ${status}`, {
|
|
240
|
+
status,
|
|
241
|
+
code,
|
|
242
|
+
requestId,
|
|
243
|
+
cause
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* Build a `ScorezillaError` for a transport-level failure (no HTTP
|
|
248
|
+
* response received): network error, abort, or timeout.
|
|
249
|
+
*/
|
|
250
|
+
static network(message, cause) {
|
|
251
|
+
return new _ScorezillaError(message, {
|
|
252
|
+
status: STATUS_NETWORK_ERROR,
|
|
253
|
+
code: "network_error",
|
|
254
|
+
cause
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
/** Build a `ScorezillaError` for an `AbortSignal`-triggered cancellation. */
|
|
258
|
+
static aborted(cause) {
|
|
259
|
+
return new _ScorezillaError("Request aborted", {
|
|
260
|
+
status: STATUS_NETWORK_ERROR,
|
|
261
|
+
code: "aborted",
|
|
262
|
+
cause
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
/** Build a `ScorezillaError` for a request that exceeded its timeout budget. */
|
|
266
|
+
static timeout(timeoutMs) {
|
|
267
|
+
return new _ScorezillaError(`Request timed out after ${timeoutMs}ms`, {
|
|
268
|
+
status: STATUS_NETWORK_ERROR,
|
|
269
|
+
code: "timeout"
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
};
|
|
273
|
+
function codeForStatus(status) {
|
|
274
|
+
if (status === 401) return "unauthorized";
|
|
275
|
+
if (status === 403) return "forbidden";
|
|
276
|
+
if (status === 404) return "not_found";
|
|
277
|
+
if (status === 422) return "out_of_bounds";
|
|
278
|
+
if (status === 429) return "rate_limited";
|
|
279
|
+
if (status >= 500) return "internal_error";
|
|
280
|
+
if (status >= 400) return "invalid_input";
|
|
281
|
+
return "internal_error";
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// src/retry.ts
|
|
285
|
+
var DEFAULT_MAX_RETRIES = 2;
|
|
286
|
+
var BASE_DELAY_MS = 200;
|
|
287
|
+
var MAX_DELAY_MS = 4e3;
|
|
288
|
+
var MAX_RETRY_AFTER_SEC = 30;
|
|
289
|
+
function nextDelay(attempt, retryAfterSec, random = Math.random) {
|
|
290
|
+
if (typeof retryAfterSec === "number" && Number.isFinite(retryAfterSec) && retryAfterSec >= 0 && retryAfterSec <= MAX_RETRY_AFTER_SEC) {
|
|
291
|
+
return Math.round(retryAfterSec * 1e3);
|
|
292
|
+
}
|
|
293
|
+
const exponential = Math.min(BASE_DELAY_MS * 2 ** attempt, MAX_DELAY_MS);
|
|
294
|
+
const jitterFactor = 0.5 + random() * 0.5;
|
|
295
|
+
return Math.round(exponential * jitterFactor);
|
|
296
|
+
}
|
|
297
|
+
function shouldRetryStatus(status) {
|
|
298
|
+
if (status === 429) return true;
|
|
299
|
+
if (status >= 500 && status < 600) return true;
|
|
300
|
+
return false;
|
|
301
|
+
}
|
|
302
|
+
function shouldRetryError(err) {
|
|
303
|
+
if (err instanceof ScorezillaError) {
|
|
304
|
+
return err.code === "network_error";
|
|
305
|
+
}
|
|
306
|
+
return false;
|
|
307
|
+
}
|
|
308
|
+
function generateIdempotencyKey() {
|
|
309
|
+
const c = globalThis.crypto;
|
|
310
|
+
if (!c || typeof c.randomUUID !== "function") {
|
|
311
|
+
throw new ScorezillaError(
|
|
312
|
+
"scorezilla: globalThis.crypto.randomUUID is unavailable. The SDK requires Node \u2265 20 or a modern browser. Check your runtime.",
|
|
313
|
+
{ status: 0, code: "internal_error" }
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
return c.randomUUID();
|
|
317
|
+
}
|
|
318
|
+
function sleep(ms, signal) {
|
|
319
|
+
return new Promise((resolve, reject) => {
|
|
320
|
+
if (signal?.aborted) {
|
|
321
|
+
reject(signal.reason ?? new DOMException("Aborted", "AbortError"));
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
const timer = setTimeout(() => {
|
|
325
|
+
signal?.removeEventListener("abort", onAbort);
|
|
326
|
+
resolve();
|
|
327
|
+
}, ms);
|
|
328
|
+
const onAbort = () => {
|
|
329
|
+
clearTimeout(timer);
|
|
330
|
+
signal?.removeEventListener("abort", onAbort);
|
|
331
|
+
reject(signal?.reason ?? new DOMException("Aborted", "AbortError"));
|
|
332
|
+
};
|
|
333
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// src/transport.ts
|
|
338
|
+
var DEFAULT_TIMEOUT_MS = 3e4;
|
|
339
|
+
async function request(opts) {
|
|
340
|
+
const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
|
|
341
|
+
if (typeof fetchImpl !== "function") {
|
|
342
|
+
throw new Error(
|
|
343
|
+
"scorezilla: globalThis.fetch is unavailable. Either upgrade your runtime (Node \u2265 20 has fetch built in) or pass `fetch: yourFetch` in the SDK config."
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
const url = buildUrl(opts.baseUrl, opts.path);
|
|
347
|
+
const maxRetries = opts.retry?.maxRetries ?? DEFAULT_MAX_RETRIES;
|
|
348
|
+
const random = opts.retry?.random ?? Math.random;
|
|
349
|
+
const sleepImpl = opts.retry?.sleepImpl ?? sleep;
|
|
350
|
+
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
351
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
|
|
352
|
+
throw new Error(
|
|
353
|
+
`scorezilla: timeoutMs must be a positive finite number (got ${String(timeoutMs)})`
|
|
354
|
+
);
|
|
355
|
+
}
|
|
356
|
+
const retrySleep = async (delay) => {
|
|
357
|
+
try {
|
|
358
|
+
await sleepImpl(delay, opts.signal);
|
|
359
|
+
} catch (cause) {
|
|
360
|
+
throw ScorezillaError.aborted(cause);
|
|
361
|
+
}
|
|
362
|
+
};
|
|
363
|
+
const idempotencyKey = opts.method === "POST" ? generateIdempotencyKey() : null;
|
|
364
|
+
let lastError;
|
|
365
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
366
|
+
const combined = combineSignalsWithTimeout(opts.signal, timeoutMs);
|
|
367
|
+
try {
|
|
368
|
+
const bodyString = opts.body !== void 0 ? JSON.stringify(opts.body) : "";
|
|
369
|
+
const perAttemptHeaders = { ...opts.headers ?? {} };
|
|
370
|
+
if (opts.signRequest) {
|
|
371
|
+
perAttemptHeaders.Authorization = await opts.signRequest({
|
|
372
|
+
method: opts.method,
|
|
373
|
+
pathAndQuery: opts.path,
|
|
374
|
+
body: bodyString
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
const init = {
|
|
378
|
+
method: opts.method,
|
|
379
|
+
headers: buildHeaders({ ...opts, headers: perAttemptHeaders }, idempotencyKey),
|
|
380
|
+
signal: combined.signal
|
|
381
|
+
};
|
|
382
|
+
if (opts.body !== void 0) {
|
|
383
|
+
init.body = bodyString;
|
|
384
|
+
}
|
|
385
|
+
const response = await fetchImpl(url, init);
|
|
386
|
+
if (response.ok) {
|
|
387
|
+
return await parseJson(response);
|
|
388
|
+
}
|
|
389
|
+
const body = await safelyParseErrorBody(response);
|
|
390
|
+
const err = ScorezillaError.from({
|
|
391
|
+
status: response.status,
|
|
392
|
+
body,
|
|
393
|
+
requestId: response.headers.get("X-Request-Id") ?? void 0
|
|
394
|
+
});
|
|
395
|
+
if (shouldRetryStatus(response.status) && attempt < maxRetries) {
|
|
396
|
+
const retryAfter = readRetryAfter(response);
|
|
397
|
+
const delay = nextDelay(attempt, retryAfter, random);
|
|
398
|
+
await retrySleep(delay);
|
|
399
|
+
lastError = err;
|
|
400
|
+
continue;
|
|
401
|
+
}
|
|
402
|
+
throw err;
|
|
403
|
+
} catch (caught) {
|
|
404
|
+
if (caught instanceof ScorezillaError) {
|
|
405
|
+
if (shouldRetryError(caught) && attempt < maxRetries) {
|
|
406
|
+
const delay = nextDelay(attempt, void 0, random);
|
|
407
|
+
await retrySleep(delay);
|
|
408
|
+
lastError = caught;
|
|
409
|
+
continue;
|
|
410
|
+
}
|
|
411
|
+
throw caught;
|
|
412
|
+
}
|
|
413
|
+
const mapped = mapTransportError(caught, opts.signal, timeoutMs, combined);
|
|
414
|
+
if (shouldRetryError(mapped) && attempt < maxRetries) {
|
|
415
|
+
const delay = nextDelay(attempt, void 0, random);
|
|
416
|
+
await retrySleep(delay);
|
|
417
|
+
lastError = mapped;
|
|
418
|
+
continue;
|
|
419
|
+
}
|
|
420
|
+
throw mapped;
|
|
421
|
+
} finally {
|
|
422
|
+
combined.cleanup();
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
throw lastError ?? new ScorezillaError("Request failed after retries", {
|
|
426
|
+
status: 0,
|
|
427
|
+
code: "internal_error"
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
function buildUrl(baseUrl, path) {
|
|
431
|
+
const base = baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl;
|
|
432
|
+
return base + path;
|
|
433
|
+
}
|
|
434
|
+
function buildHeaders(opts, idempotencyKey) {
|
|
435
|
+
const headers = { Accept: "application/json" };
|
|
436
|
+
if (opts.body !== void 0) {
|
|
437
|
+
headers["Content-Type"] = "application/json";
|
|
438
|
+
}
|
|
439
|
+
if (idempotencyKey !== null) {
|
|
440
|
+
headers["Idempotency-Key"] = idempotencyKey;
|
|
441
|
+
}
|
|
442
|
+
if (opts.headers) {
|
|
443
|
+
for (const [k, v] of Object.entries(opts.headers)) headers[k] = v;
|
|
444
|
+
}
|
|
445
|
+
return headers;
|
|
446
|
+
}
|
|
447
|
+
async function parseJson(response) {
|
|
448
|
+
const requestId = response.headers.get("X-Request-Id") ?? void 0;
|
|
449
|
+
let parsed;
|
|
450
|
+
try {
|
|
451
|
+
parsed = await response.json();
|
|
452
|
+
} catch (cause) {
|
|
453
|
+
throw new ScorezillaError("Response body was not valid JSON", {
|
|
454
|
+
status: response.status,
|
|
455
|
+
code: "invalid_json",
|
|
456
|
+
requestId,
|
|
457
|
+
cause
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
if (parsed === null || typeof parsed !== "object") {
|
|
461
|
+
const observed = parsed === null ? "null" : typeof parsed;
|
|
462
|
+
throw new ScorezillaError(`Response body was not a JSON object (got ${observed})`, {
|
|
463
|
+
status: response.status,
|
|
464
|
+
code: "invalid_json",
|
|
465
|
+
requestId
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
const okField = parsed.ok;
|
|
469
|
+
if (okField !== true) {
|
|
470
|
+
throw new ScorezillaError(
|
|
471
|
+
`Response body on a 2xx is missing the \`ok: true\` discriminator (got ok=${String(okField)})`,
|
|
472
|
+
{
|
|
473
|
+
status: response.status,
|
|
474
|
+
code: "invalid_json",
|
|
475
|
+
requestId
|
|
476
|
+
}
|
|
477
|
+
);
|
|
478
|
+
}
|
|
479
|
+
return parsed;
|
|
480
|
+
}
|
|
481
|
+
async function safelyParseErrorBody(response) {
|
|
482
|
+
try {
|
|
483
|
+
const json = await response.json();
|
|
484
|
+
if (json && typeof json === "object" && "ok" in json && json.ok === false) {
|
|
485
|
+
return json;
|
|
486
|
+
}
|
|
487
|
+
return void 0;
|
|
488
|
+
} catch {
|
|
489
|
+
return void 0;
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
function readRetryAfter(response) {
|
|
493
|
+
const raw = response.headers.get("Retry-After");
|
|
494
|
+
if (!raw) return void 0;
|
|
495
|
+
const n = Number(raw);
|
|
496
|
+
return Number.isFinite(n) && n >= 0 ? n : void 0;
|
|
497
|
+
}
|
|
498
|
+
function combineSignalsWithTimeout(caller, timeoutMs) {
|
|
499
|
+
const ctrl = new AbortController();
|
|
500
|
+
let didTimeOut = false;
|
|
501
|
+
if (caller?.aborted) {
|
|
502
|
+
ctrl.abort(caller.reason);
|
|
503
|
+
return { signal: ctrl.signal, cleanup: () => {
|
|
504
|
+
}, timedOut: () => false };
|
|
505
|
+
}
|
|
506
|
+
const onCallerAbort = () => {
|
|
507
|
+
ctrl.abort(caller?.reason);
|
|
508
|
+
};
|
|
509
|
+
caller?.addEventListener("abort", onCallerAbort, { once: true });
|
|
510
|
+
const timer = setTimeout(() => {
|
|
511
|
+
didTimeOut = true;
|
|
512
|
+
ctrl.abort(new DOMException(`Request timed out after ${timeoutMs}ms`, "TimeoutError"));
|
|
513
|
+
}, timeoutMs);
|
|
514
|
+
return {
|
|
515
|
+
signal: ctrl.signal,
|
|
516
|
+
cleanup: () => {
|
|
517
|
+
clearTimeout(timer);
|
|
518
|
+
caller?.removeEventListener("abort", onCallerAbort);
|
|
519
|
+
},
|
|
520
|
+
timedOut: () => didTimeOut
|
|
521
|
+
};
|
|
522
|
+
}
|
|
523
|
+
function mapTransportError(caught, callerSignal, timeoutMs, combined) {
|
|
524
|
+
if (callerSignal?.aborted) {
|
|
525
|
+
return ScorezillaError.aborted(callerSignal.reason ?? caught);
|
|
526
|
+
}
|
|
527
|
+
if (combined.timedOut()) {
|
|
528
|
+
return ScorezillaError.timeout(timeoutMs);
|
|
529
|
+
}
|
|
530
|
+
const message = caught instanceof Error ? caught.message : "Network request failed";
|
|
531
|
+
return ScorezillaError.network(message, caught);
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// src/user-agent.ts
|
|
535
|
+
function detectRuntime(g = globalThis) {
|
|
536
|
+
if (typeof g.Bun !== "undefined") return "bun";
|
|
537
|
+
if (typeof g.Deno !== "undefined") return "deno";
|
|
538
|
+
if (typeof g.navigator?.userAgent === "string" && g.navigator.userAgent.includes("Cloudflare-Workers")) {
|
|
539
|
+
return "workers";
|
|
540
|
+
}
|
|
541
|
+
if (typeof g.process?.versions?.node === "string") return "node";
|
|
542
|
+
if (typeof g.document !== "undefined") return "browser";
|
|
543
|
+
return "unknown";
|
|
544
|
+
}
|
|
545
|
+
function defaultUserAgent(version, runtime = detectRuntime()) {
|
|
546
|
+
return `scorezilla-js/${version} (${runtime})`;
|
|
547
|
+
}
|
|
548
|
+
|
|
3
549
|
// src/server.ts
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
550
|
+
var Scorezilla = class _Scorezilla {
|
|
551
|
+
/** The package version, injected at build time from `package.json`.
|
|
552
|
+
* Mirrors the static on the public-key client so consumers can log
|
|
553
|
+
* the running SDK build the same way regardless of which surface
|
|
554
|
+
* they imported. */
|
|
555
|
+
static version = "0.1.0-next.3";
|
|
556
|
+
#keyId;
|
|
557
|
+
#secret;
|
|
558
|
+
#baseUrl;
|
|
559
|
+
/** Host portion of `#baseUrl` (e.g. "api.scorezilla.dev"). Captured at
|
|
560
|
+
* construction so every signed request binds to this exact origin —
|
|
561
|
+
* see `buildSigningString` v=2 in `hmac.ts`. */
|
|
562
|
+
#host;
|
|
563
|
+
#fetchImpl;
|
|
564
|
+
#timeoutMs;
|
|
565
|
+
#maxRetries;
|
|
566
|
+
#sleepImpl;
|
|
567
|
+
#userAgent;
|
|
568
|
+
constructor(config) {
|
|
569
|
+
if (isRealBrowserEnvironment()) {
|
|
570
|
+
throw new Error(
|
|
571
|
+
"scorezilla/server: this adapter is server-only and must not run in browsers. Your bundler is shipping `scorezilla/server` into a browser bundle \u2014 check that it honors the `browser` export condition in package.json. Use the public-key client (`import { Scorezilla } from 'scorezilla'`) from browser code."
|
|
572
|
+
);
|
|
573
|
+
}
|
|
574
|
+
const resolved = validateSecretKey(config);
|
|
575
|
+
this.#keyId = resolved.keyId;
|
|
576
|
+
this.#secret = resolved.secret;
|
|
577
|
+
this.#baseUrl = (config.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
|
|
578
|
+
try {
|
|
579
|
+
this.#host = new URL(this.#baseUrl).host;
|
|
580
|
+
} catch {
|
|
581
|
+
throw new Error(
|
|
582
|
+
`scorezilla/server: baseUrl must be a valid absolute URL (got: ${this.#baseUrl})`
|
|
583
|
+
);
|
|
584
|
+
}
|
|
585
|
+
this.#fetchImpl = config.fetch;
|
|
586
|
+
this.#timeoutMs = config.timeoutMs;
|
|
587
|
+
this.#maxRetries = config.maxRetries;
|
|
588
|
+
this.#sleepImpl = config.sleepImpl;
|
|
589
|
+
this.#userAgent = config.userAgent ?? defaultUserAgent(_Scorezilla.version);
|
|
590
|
+
}
|
|
591
|
+
/**
|
|
592
|
+
* Submit a score to a board. Signed end-to-end — the API verifies
|
|
593
|
+
* before any state change.
|
|
594
|
+
*
|
|
595
|
+
* **Behavioral note vs. the public-key client**: the server adapter
|
|
596
|
+
* does NOT perform local `metadata` validation. The public-key
|
|
597
|
+
* client (`scorezilla`) validates size + structure client-side and
|
|
598
|
+
* fails fast; the server adapter relies on the API to reject
|
|
599
|
+
* malformed metadata with `invalid_input`. Trade-off: smaller bundle
|
|
600
|
+
* + simpler server-side logic vs. one extra network round-trip on
|
|
601
|
+
* caller mistakes. If you want fast-fail behavior, validate metadata
|
|
602
|
+
* yourself before calling this method.
|
|
603
|
+
*/
|
|
604
|
+
async submitScore(input) {
|
|
605
|
+
return this.#request({
|
|
606
|
+
path: submitScoreSecurePath(),
|
|
607
|
+
method: "POST",
|
|
608
|
+
body: {
|
|
609
|
+
boardId: input.boardId,
|
|
610
|
+
playerId: input.playerId,
|
|
611
|
+
score: input.score,
|
|
612
|
+
...input.metadata !== void 0 ? { metadata: input.metadata } : {}
|
|
613
|
+
}
|
|
614
|
+
});
|
|
615
|
+
}
|
|
616
|
+
/** Fetch the top-N entries on a board. */
|
|
617
|
+
async getLeaderboard(input) {
|
|
618
|
+
return this.#request({
|
|
619
|
+
path: getLeaderboardPath(input.boardId, {
|
|
620
|
+
...input.top !== void 0 ? { top: input.top } : {},
|
|
621
|
+
...input.offset !== void 0 ? { offset: input.offset } : {}
|
|
622
|
+
}),
|
|
623
|
+
method: "GET"
|
|
624
|
+
});
|
|
625
|
+
}
|
|
626
|
+
/** Look up a single player's rank on a board. */
|
|
627
|
+
async getPlayerRank(input) {
|
|
628
|
+
return this.#request({
|
|
629
|
+
path: getPlayerRankPath(input.boardId, input.playerId),
|
|
630
|
+
method: "GET"
|
|
631
|
+
});
|
|
632
|
+
}
|
|
633
|
+
/** Fetch the slice of entries surrounding a player. */
|
|
634
|
+
async getWindowAround(input) {
|
|
635
|
+
return this.#request({
|
|
636
|
+
path: getWindowAroundPath(input.boardId, input.playerId, {
|
|
637
|
+
...input.before !== void 0 ? { before: input.before } : {},
|
|
638
|
+
...input.after !== void 0 ? { after: input.after } : {}
|
|
639
|
+
}),
|
|
640
|
+
method: "GET"
|
|
641
|
+
});
|
|
642
|
+
}
|
|
643
|
+
/**
|
|
644
|
+
* Thin wrapper around `transport.request` that wires the HMAC signer
|
|
645
|
+
* for every attempt. Each retry calls `signRequest` again, producing
|
|
646
|
+
* a fresh `(ts, nonce)` pair — the server's replay protection (10-min
|
|
647
|
+
* KV) requires this.
|
|
648
|
+
*/
|
|
649
|
+
async #request(opts) {
|
|
650
|
+
const baseHeaders = {
|
|
651
|
+
// User-Agent: useful in Node/Bun/Deno/Workers for server observability.
|
|
652
|
+
// (The browser stub blocks this code path from running browser-side.)
|
|
653
|
+
"User-Agent": this.#userAgent,
|
|
654
|
+
"X-Scorezilla-Client": this.#userAgent
|
|
655
|
+
};
|
|
656
|
+
const requestOpts = {
|
|
657
|
+
baseUrl: this.#baseUrl,
|
|
658
|
+
path: opts.path,
|
|
659
|
+
method: opts.method,
|
|
660
|
+
headers: baseHeaders,
|
|
661
|
+
signRequest: async ({ method, pathAndQuery, body }) => buildHmacAuthHeader({
|
|
662
|
+
keyId: this.#keyId,
|
|
663
|
+
secret: this.#secret,
|
|
664
|
+
method,
|
|
665
|
+
pathAndQuery,
|
|
666
|
+
host: this.#host,
|
|
667
|
+
body
|
|
668
|
+
})
|
|
669
|
+
};
|
|
670
|
+
if (opts.body !== void 0) requestOpts.body = opts.body;
|
|
671
|
+
if (this.#fetchImpl !== void 0) requestOpts.fetchImpl = this.#fetchImpl;
|
|
672
|
+
if (this.#timeoutMs !== void 0) requestOpts.timeoutMs = this.#timeoutMs;
|
|
673
|
+
if (this.#maxRetries !== void 0 || this.#sleepImpl !== void 0) {
|
|
674
|
+
requestOpts.retry = {
|
|
675
|
+
...this.#maxRetries !== void 0 ? { maxRetries: this.#maxRetries } : {},
|
|
676
|
+
...this.#sleepImpl !== void 0 ? { sleepImpl: this.#sleepImpl } : {}
|
|
677
|
+
};
|
|
678
|
+
}
|
|
679
|
+
return request(requestOpts);
|
|
680
|
+
}
|
|
681
|
+
};
|
|
682
|
+
function isRealBrowserEnvironment() {
|
|
683
|
+
const g = globalThis;
|
|
684
|
+
const hasBrowserGlobals = typeof g.window !== "undefined" && typeof g.document !== "undefined";
|
|
685
|
+
if (!hasBrowserGlobals) return false;
|
|
686
|
+
const hasNodeLikeHost = Boolean(g.process?.versions?.node) || typeof g.Deno !== "undefined" || typeof g.Bun !== "undefined";
|
|
687
|
+
return !hasNodeLikeHost;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
exports.Scorezilla = Scorezilla;
|
|
691
|
+
exports.ScorezillaError = ScorezillaError;
|
|
7
692
|
//# sourceMappingURL=server.cjs.map
|
|
8
693
|
//# sourceMappingURL=server.cjs.map
|