@pickleball/server-sdk 0.1.1

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/proxy.cjs ADDED
@@ -0,0 +1,1018 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/proxy.ts
21
+ var proxy_exports = {};
22
+ __export(proxy_exports, {
23
+ createStableIdempotencyKey: () => createStableIdempotencyKey,
24
+ handleBootstrap: () => handleBootstrap,
25
+ handleSessionAction: () => handleSessionAction,
26
+ handleSessions: () => handleSessions
27
+ });
28
+ module.exports = __toCommonJS(proxy_exports);
29
+
30
+ // src/errors.ts
31
+ var PickleballLiveError = class extends Error {
32
+ constructor(message, code, options) {
33
+ super(message, options);
34
+ this.code = code;
35
+ this.name = "PickleballLiveError";
36
+ }
37
+ };
38
+ var PickleballConfigurationError = class extends PickleballLiveError {
39
+ constructor(message) {
40
+ super(message, "CONFIGURATION_ERROR");
41
+ this.name = "PickleballConfigurationError";
42
+ }
43
+ };
44
+ var PickleballApiError = class extends PickleballLiveError {
45
+ constructor(message, code, status) {
46
+ super(message, code);
47
+ this.status = status;
48
+ this.name = "PickleballApiError";
49
+ }
50
+ };
51
+ var PickleballHttpError = class extends PickleballLiveError {
52
+ constructor(status) {
53
+ super(`Request failed with HTTP ${status}`, "HTTP_ERROR");
54
+ this.status = status;
55
+ this.name = "PickleballHttpError";
56
+ }
57
+ };
58
+ var PickleballInvalidResponseError = class extends PickleballLiveError {
59
+ constructor() {
60
+ super("The server returned an invalid response", "INVALID_RESPONSE");
61
+ this.name = "PickleballInvalidResponseError";
62
+ }
63
+ };
64
+ var PickleballTimeoutError = class extends PickleballLiveError {
65
+ constructor() {
66
+ super("The request timed out", "TIMEOUT");
67
+ this.name = "PickleballTimeoutError";
68
+ }
69
+ };
70
+ var PickleballNetworkError = class extends PickleballLiveError {
71
+ constructor() {
72
+ super("The request failed because of a network error", "NETWORK_ERROR");
73
+ this.name = "PickleballNetworkError";
74
+ }
75
+ };
76
+ var PickleballWebhookVerificationError = class extends PickleballLiveError {
77
+ constructor(message = "Webhook verification failed") {
78
+ super(message, "WEBHOOK_VERIFICATION_FAILED");
79
+ this.name = "PickleballWebhookVerificationError";
80
+ }
81
+ };
82
+
83
+ // src/webhooks.ts
84
+ function fail() {
85
+ throw new PickleballWebhookVerificationError();
86
+ }
87
+ function headerValue(headers, name) {
88
+ if (headers instanceof Headers) return headers.get(name);
89
+ const entry = Object.entries(headers).find(([key]) => key.toLowerCase() === name);
90
+ if (!entry) return null;
91
+ const value = entry[1];
92
+ return Array.isArray(value) ? value.join(",") : value ?? null;
93
+ }
94
+ function bodyBytes(rawBody) {
95
+ return typeof rawBody === "string" ? new TextEncoder().encode(rawBody) : rawBody;
96
+ }
97
+ function signedBytes(timestamp, rawBody) {
98
+ const prefix = new TextEncoder().encode(`${timestamp}.`);
99
+ const body = bodyBytes(rawBody);
100
+ const result = new Uint8Array(prefix.length + body.length);
101
+ result.set(prefix);
102
+ result.set(body, prefix.length);
103
+ return result;
104
+ }
105
+ function decodeHex(value) {
106
+ if (!/^[0-9a-fA-F]{64}$/.test(value)) return null;
107
+ const bytes = new Uint8Array(value.length / 2);
108
+ for (let index = 0; index < bytes.length; index += 1) {
109
+ bytes[index] = Number.parseInt(value.slice(index * 2, index * 2 + 2), 16);
110
+ }
111
+ return bytes;
112
+ }
113
+ function constantTimeEqual(left, right) {
114
+ let difference = left.length ^ right.length;
115
+ const length = Math.max(left.length, right.length);
116
+ for (let index = 0; index < length; index += 1) {
117
+ difference |= (left[index] ?? 0) ^ (right[index] ?? 0);
118
+ }
119
+ return difference === 0;
120
+ }
121
+ function encodeHex(value) {
122
+ return Array.from(value, (byte) => byte.toString(16).padStart(2, "0")).join("");
123
+ }
124
+ function parseSignature(header) {
125
+ let timestamp = null;
126
+ const signatures = [];
127
+ for (const field of header.split(",")) {
128
+ const separator = field.indexOf("=");
129
+ if (separator === -1) continue;
130
+ const key = field.slice(0, separator).trim();
131
+ const value = field.slice(separator + 1).trim();
132
+ if (key === "t" && /^\d+$/.test(value)) timestamp = Number(value);
133
+ if (key === "v1") {
134
+ const decoded = decodeHex(value);
135
+ if (decoded) signatures.push(decoded);
136
+ }
137
+ }
138
+ if (timestamp === null || !Number.isSafeInteger(timestamp) || signatures.length === 0) fail();
139
+ return { timestamp, signatures };
140
+ }
141
+ function decodeJson(rawBody) {
142
+ let text;
143
+ try {
144
+ text = typeof rawBody === "string" ? rawBody : new TextDecoder("utf-8", { fatal: true }).decode(rawBody);
145
+ return JSON.parse(text);
146
+ } catch {
147
+ fail();
148
+ }
149
+ }
150
+ async function verifyWebhook(rawBody, headers, secret, options = {}) {
151
+ const id = headerValue(headers, "webhook-id");
152
+ const transportEvent = headerValue(headers, "webhook-event");
153
+ const signatureHeader = headerValue(headers, "webhook-signature");
154
+ if (!id?.trim() || !transportEvent?.trim() || !signatureHeader || typeof secret !== "string" || !secret.trim()) {
155
+ fail();
156
+ }
157
+ const toleranceSeconds = options.toleranceSeconds ?? 300;
158
+ const now = options.now ?? Date.now();
159
+ if (!Number.isFinite(toleranceSeconds) || toleranceSeconds < 0 || !Number.isFinite(now)) fail();
160
+ const { timestamp, signatures } = parseSignature(signatureHeader);
161
+ if (Math.abs(now / 1e3 - timestamp) > toleranceSeconds) fail();
162
+ const body = decodeJson(rawBody);
163
+ if (typeof body !== "object" || body === null || typeof body.event !== "string") fail();
164
+ if (!body.event.trim() || body.event !== transportEvent) fail();
165
+ const key = await crypto.subtle.importKey(
166
+ "raw",
167
+ new TextEncoder().encode(secret),
168
+ { name: "HMAC", hash: "SHA-256" },
169
+ false,
170
+ ["sign"]
171
+ );
172
+ const mac = new Uint8Array(
173
+ await crypto.subtle.sign(
174
+ "HMAC",
175
+ key,
176
+ Uint8Array.from(signedBytes(timestamp, rawBody)).buffer
177
+ )
178
+ );
179
+ let verified = false;
180
+ for (const signature of signatures) {
181
+ verified = constantTimeEqual(mac, signature) || verified;
182
+ }
183
+ if (!verified) fail();
184
+ const bodyHash = new Uint8Array(
185
+ await crypto.subtle.digest(
186
+ "SHA-256",
187
+ Uint8Array.from(bodyBytes(rawBody)).buffer
188
+ )
189
+ );
190
+ return {
191
+ transportId: id,
192
+ dedupeKey: `body-sha256:${encodeHex(bodyHash)}`,
193
+ event: body.event,
194
+ timestamp,
195
+ body
196
+ };
197
+ }
198
+
199
+ // src/validators.ts
200
+ function invalid() {
201
+ throw new PickleballInvalidResponseError();
202
+ }
203
+ function record(value) {
204
+ if (value === null || typeof value !== "object" || Array.isArray(value)) invalid();
205
+ return value;
206
+ }
207
+ function nonblank(value) {
208
+ if (typeof value !== "string" || value.trim() === "") invalid();
209
+ return value;
210
+ }
211
+ function finite(value) {
212
+ if (typeof value !== "number" || !Number.isFinite(value)) invalid();
213
+ return value;
214
+ }
215
+ function nullableString(value) {
216
+ if (value === null) return null;
217
+ return nonblank(value);
218
+ }
219
+ function nullableNumber(value) {
220
+ if (value === null) return null;
221
+ return finite(value);
222
+ }
223
+ function validateRemoteConfig(value) {
224
+ const data = record(value);
225
+ if (typeof data.enabled !== "boolean" || data.protocolVersion !== 1 || data.videoQuality !== 720 && data.videoQuality !== 1080) {
226
+ invalid();
227
+ }
228
+ const reconnectTimeoutMs = finite(data.reconnectTimeoutMs);
229
+ const backgroundGraceMs = finite(data.backgroundGraceMs);
230
+ const telemetryIntervalMs = finite(data.telemetryIntervalMs);
231
+ const maxSessionDurationMs = finite(data.maxSessionDurationMs);
232
+ if (reconnectTimeoutMs < 0 || backgroundGraceMs < 0 || telemetryIntervalMs <= 0 || maxSessionDurationMs <= 0) {
233
+ invalid();
234
+ }
235
+ return {
236
+ enabled: data.enabled,
237
+ protocolVersion: 1,
238
+ minSdkVersion: nonblank(data.minSdkVersion),
239
+ recommendedSdkVersion: nonblank(data.recommendedSdkVersion),
240
+ videoQuality: data.videoQuality,
241
+ reconnectTimeoutMs,
242
+ backgroundGraceMs,
243
+ telemetryIntervalMs,
244
+ maxSessionDurationMs,
245
+ ...data.standbyTimeoutMs === void 0 ? {} : { standbyTimeoutMs: finite(data.standbyTimeoutMs) }
246
+ };
247
+ }
248
+ function validateBootstrap(value) {
249
+ const data = record(value);
250
+ if (typeof data.rolloutEnabled !== "boolean") invalid();
251
+ if (data.message !== void 0 && typeof data.message !== "string") invalid();
252
+ return {
253
+ config: validateRemoteConfig(data.config),
254
+ serverTime: finite(data.serverTime),
255
+ rolloutEnabled: data.rolloutEnabled,
256
+ ...data.message === void 0 ? {} : { message: data.message }
257
+ };
258
+ }
259
+ function validateTelemetry(value) {
260
+ const data = record(value);
261
+ return {
262
+ endpoint: nonblank(data.endpoint),
263
+ token: nonblank(data.token),
264
+ expiresAt: finite(data.expiresAt)
265
+ };
266
+ }
267
+ function validateGrant(value) {
268
+ const data = record(value);
269
+ return {
270
+ sessionId: nonblank(data.sessionId),
271
+ serverUrl: nonblank(data.serverUrl),
272
+ participantToken: nonblank(data.participantToken),
273
+ playbackUrl: nullableString(data.playbackUrl),
274
+ tokenExpiresAt: finite(data.tokenExpiresAt),
275
+ telemetry: validateTelemetry(data.telemetry),
276
+ config: validateRemoteConfig(data.config)
277
+ };
278
+ }
279
+ function validateRefresh(value) {
280
+ const data = record(value);
281
+ return {
282
+ ...data.participantToken === void 0 ? {} : { participantToken: nonblank(data.participantToken) },
283
+ ...data.tokenExpiresAt === void 0 ? {} : { tokenExpiresAt: finite(data.tokenExpiresAt) },
284
+ ...data.telemetry === void 0 ? {} : { telemetry: validateTelemetry(data.telemetry) },
285
+ config: validateRemoteConfig(data.config)
286
+ };
287
+ }
288
+ function validateEnd(value, sessionId) {
289
+ const data = record(value);
290
+ if (data.sessionId !== sessionId || data.status !== "ended") invalid();
291
+ }
292
+ function validatePublishState(value, sessionId) {
293
+ const data = record(value);
294
+ if (data.sessionId !== sessionId) invalid();
295
+ if (data.status !== "scheduled" && data.status !== "live") invalid();
296
+ return { sessionId, status: data.status };
297
+ }
298
+ function validateSession(value) {
299
+ const data = record(value);
300
+ if (data.status !== "scheduled" && data.status !== "live" && data.status !== "ended") {
301
+ invalid();
302
+ }
303
+ if (data.visibility !== "public" && data.visibility !== "private") invalid();
304
+ const viewerCount = finite(data.viewerCount);
305
+ const likeCount = finite(data.likeCount);
306
+ const shareCount = finite(data.shareCount);
307
+ if (viewerCount < 0 || likeCount < 0 || shareCount < 0) invalid();
308
+ return {
309
+ id: nonblank(data.id),
310
+ title: nonblank(data.title),
311
+ status: data.status,
312
+ visibility: data.visibility,
313
+ matchRef: nullableString(data.matchRef),
314
+ playbackUrl: nullableString(data.playbackUrl),
315
+ watchUrl: nullableString(data.watchUrl),
316
+ scheduledAt: nullableNumber(data.scheduledAt),
317
+ startedAt: nullableNumber(data.startedAt),
318
+ endedAt: nullableNumber(data.endedAt),
319
+ viewerCount,
320
+ likeCount,
321
+ shareCount
322
+ };
323
+ }
324
+ function validateRecording(value, sessionId) {
325
+ const data = record(value);
326
+ if (data.sessionId !== sessionId) invalid();
327
+ if (data.type !== "clean" && data.type !== "derived" && data.type !== "device") invalid();
328
+ if (data.status !== "recording" && data.status !== "ready" && data.status !== "failed") {
329
+ invalid();
330
+ }
331
+ const durationSec = nullableNumber(data.durationSec);
332
+ if (durationSec !== null && durationSec < 0) invalid();
333
+ return {
334
+ id: nonblank(data.id),
335
+ sessionId,
336
+ type: data.type,
337
+ status: data.status,
338
+ url: nullableString(data.url),
339
+ durationSec
340
+ };
341
+ }
342
+ function validateRecordings(value, sessionId) {
343
+ if (!Array.isArray(value)) invalid();
344
+ return value.map((item) => validateRecording(item, sessionId));
345
+ }
346
+
347
+ // src/index.ts
348
+ var TRANSIENT_STATUSES = /* @__PURE__ */ new Set([408, 429]);
349
+ var RETRY_BASE_MS = 100;
350
+ var RETRY_MAX_MS = 2e3;
351
+ var RETRY_AFTER_MAX_MS = 3e4;
352
+ var RESPONSE_BODY_MAX_BYTES = 1024 * 1024;
353
+ var SDK_ERROR_CODES = /* @__PURE__ */ new Set([
354
+ "PERMISSION_DENIED",
355
+ "NETWORK_UNAVAILABLE",
356
+ "TOKEN_EXPIRED",
357
+ "SDK_UPGRADE_REQUIRED",
358
+ "SESSION_CONFLICT",
359
+ "RECORDING_FAILED",
360
+ "CONSENT_REQUIRED",
361
+ "SDK_DISABLED",
362
+ "UNKNOWN"
363
+ ]);
364
+ var DETERMINISTIC_ERROR_CODES = /* @__PURE__ */ new Set([
365
+ "PERMISSION_DENIED",
366
+ "TOKEN_EXPIRED",
367
+ "SDK_UPGRADE_REQUIRED",
368
+ "SESSION_CONFLICT",
369
+ "RECORDING_FAILED",
370
+ "CONSENT_REQUIRED",
371
+ "SDK_DISABLED"
372
+ ]);
373
+ function requiredNonblank(value, name) {
374
+ if (typeof value !== "string" || value.trim() === "") {
375
+ throw new PickleballConfigurationError(`${name} must be a non-empty string`);
376
+ }
377
+ return value;
378
+ }
379
+ function validatedBaseUrl(value) {
380
+ let url;
381
+ try {
382
+ url = new URL(value);
383
+ } catch {
384
+ throw new PickleballConfigurationError("baseUrl must be an absolute HTTP(S) URL");
385
+ }
386
+ const loopbackHosts = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "[::1]"]);
387
+ const secure = url.protocol === "https:";
388
+ const localDevelopment = url.protocol === "http:" && loopbackHosts.has(url.hostname);
389
+ if (!secure && !localDevelopment || url.username || url.password) {
390
+ throw new PickleballConfigurationError(
391
+ "baseUrl must use HTTPS (HTTP is allowed only for localhost loopback)"
392
+ );
393
+ }
394
+ url.pathname = url.pathname.replace(/\/+$/, "");
395
+ url.search = "";
396
+ url.hash = "";
397
+ return url.toString().replace(/\/$/, "");
398
+ }
399
+ function positiveFinite(value, name) {
400
+ if (!Number.isFinite(value) || value <= 0) {
401
+ throw new PickleballConfigurationError(`${name} must be a positive finite number`);
402
+ }
403
+ return value;
404
+ }
405
+ function validRetryCount(value) {
406
+ if (!Number.isInteger(value) || value < 0 || value > 10) {
407
+ throw new PickleballConfigurationError("maxRetries must be an integer between 0 and 10");
408
+ }
409
+ return value;
410
+ }
411
+ function isTransientStatus(status) {
412
+ return TRANSIENT_STATUSES.has(status) || status >= 500;
413
+ }
414
+ function isRetryableResponseError(error) {
415
+ return !(error instanceof PickleballApiError && DETERMINISTIC_ERROR_CODES.has(error.code));
416
+ }
417
+ function retryAfterMs(response2, now = Date.now()) {
418
+ const raw = response2.headers.get("retry-after");
419
+ if (raw === null) return null;
420
+ const seconds = Number(raw);
421
+ const delay = Number.isFinite(seconds) ? seconds * 1e3 : Date.parse(raw) - now;
422
+ if (!Number.isFinite(delay) || delay < 0) return null;
423
+ return Math.min(delay, RETRY_AFTER_MAX_MS);
424
+ }
425
+ function wait(ms) {
426
+ return new Promise((resolve) => setTimeout(resolve, ms));
427
+ }
428
+ function isRecord(value) {
429
+ return value !== null && typeof value === "object" && !Array.isArray(value);
430
+ }
431
+ function parseSdkError(payload, status, apiKey) {
432
+ if (!isRecord(payload) || !("error" in payload)) {
433
+ return new PickleballInvalidResponseError();
434
+ }
435
+ if (typeof payload.error === "string") {
436
+ return new PickleballHttpError(status);
437
+ }
438
+ if (!isRecord(payload.error)) {
439
+ return new PickleballInvalidResponseError();
440
+ }
441
+ const { code, message } = payload.error;
442
+ if (typeof code !== "string" || code.trim() === "" || typeof message !== "string") {
443
+ return new PickleballInvalidResponseError();
444
+ }
445
+ const safeMessage = apiKey === "" ? message : message.split(apiKey).join("[REDACTED]");
446
+ const safeCode = SDK_ERROR_CODES.has(code) ? code : "UNKNOWN";
447
+ return new PickleballApiError(safeMessage, safeCode, status);
448
+ }
449
+ async function readBoundedResponseText(response2) {
450
+ const declaredLength = Number(response2.headers.get("content-length"));
451
+ if (Number.isFinite(declaredLength) && declaredLength > RESPONSE_BODY_MAX_BYTES) {
452
+ await response2.body?.cancel().catch(() => void 0);
453
+ throw new PickleballInvalidResponseError();
454
+ }
455
+ if (!response2.body) return "";
456
+ const reader = response2.body.getReader();
457
+ const chunks = [];
458
+ let total = 0;
459
+ try {
460
+ while (true) {
461
+ const { done, value } = await reader.read();
462
+ if (done) break;
463
+ total += value.byteLength;
464
+ if (total > RESPONSE_BODY_MAX_BYTES) {
465
+ await reader.cancel().catch(() => void 0);
466
+ throw new PickleballInvalidResponseError();
467
+ }
468
+ chunks.push(value);
469
+ }
470
+ } finally {
471
+ reader.releaseLock();
472
+ }
473
+ const bytes = new Uint8Array(total);
474
+ let offset = 0;
475
+ for (const chunk of chunks) {
476
+ bytes.set(chunk, offset);
477
+ offset += chunk.byteLength;
478
+ }
479
+ return new TextDecoder().decode(bytes);
480
+ }
481
+ async function decodeResponse(response2, apiKey, validate) {
482
+ if (response2.status >= 300 && response2.status < 400) {
483
+ await response2.body?.cancel().catch(() => void 0);
484
+ throw new PickleballHttpError(response2.status);
485
+ }
486
+ const text = await readBoundedResponseText(response2);
487
+ let payload;
488
+ try {
489
+ payload = JSON.parse(text);
490
+ } catch {
491
+ throw new PickleballInvalidResponseError();
492
+ }
493
+ if (!response2.ok) throw parseSdkError(payload, response2.status, apiKey);
494
+ if (!isRecord(payload) || !("data" in payload)) {
495
+ throw new PickleballInvalidResponseError();
496
+ }
497
+ return validate(payload.data);
498
+ }
499
+ function createPickleballLiveClient(options) {
500
+ const baseUrl = validatedBaseUrl(options.baseUrl);
501
+ const appId = requiredNonblank(options.appId, "appId");
502
+ const apiKey = requiredNonblank(options.apiKey, "apiKey");
503
+ const fetchImplementation = options.fetch ?? globalThis.fetch;
504
+ if (typeof fetchImplementation !== "function") {
505
+ throw new PickleballConfigurationError("fetch must be available");
506
+ }
507
+ const timeoutMs = positiveFinite(options.timeoutMs ?? 1e4, "timeoutMs");
508
+ const maxRetries = validRetryCount(options.maxRetries ?? 2);
509
+ async function request({
510
+ method,
511
+ path,
512
+ body,
513
+ retryPolicy,
514
+ validate
515
+ }) {
516
+ const retryLimit = retryPolicy === "transient" ? maxRetries : 0;
517
+ for (let attempt = 0; attempt <= retryLimit; attempt += 1) {
518
+ const controller = new AbortController();
519
+ let response2;
520
+ let timedOut = false;
521
+ const timer = setTimeout(() => {
522
+ timedOut = true;
523
+ controller.abort();
524
+ }, timeoutMs);
525
+ try {
526
+ response2 = await fetchImplementation(`${baseUrl}${path}`, {
527
+ method,
528
+ headers: method === "POST" ? {
529
+ accept: "application/json",
530
+ "content-type": "application/json",
531
+ "x-api-key": apiKey
532
+ } : {
533
+ accept: "application/json",
534
+ "x-api-key": apiKey,
535
+ "x-pickleball-app-id": appId
536
+ },
537
+ ...body === void 0 ? {} : { body: JSON.stringify(body) },
538
+ signal: controller.signal,
539
+ redirect: "error"
540
+ });
541
+ try {
542
+ return await decodeResponse(response2, apiKey, validate);
543
+ } catch (error) {
544
+ if (isTransientStatus(response2.status) && attempt < retryLimit && isRetryableResponseError(error)) {
545
+ clearTimeout(timer);
546
+ const delay = retryAfterMs(response2) ?? Math.min(RETRY_BASE_MS * 2 ** attempt, RETRY_MAX_MS);
547
+ await response2.body?.cancel().catch(() => void 0);
548
+ await wait(delay);
549
+ continue;
550
+ }
551
+ throw error;
552
+ }
553
+ } catch (error) {
554
+ if (error instanceof PickleballApiError || error instanceof PickleballHttpError || error instanceof PickleballInvalidResponseError) {
555
+ throw error;
556
+ }
557
+ if (attempt < retryLimit) {
558
+ await wait(Math.min(RETRY_BASE_MS * 2 ** attempt, RETRY_MAX_MS));
559
+ continue;
560
+ }
561
+ if (timedOut) throw new PickleballTimeoutError();
562
+ throw new PickleballNetworkError();
563
+ } finally {
564
+ clearTimeout(timer);
565
+ if (timedOut) await response2?.body?.cancel().catch(() => void 0);
566
+ }
567
+ }
568
+ throw new PickleballNetworkError();
569
+ }
570
+ const post = (path, body, retryPolicy, validate) => request({ method: "POST", path, body, retryPolicy, validate });
571
+ const get = (path, validate) => request({ method: "GET", path, retryPolicy: "transient", validate });
572
+ const sessionPath = (sessionId) => `/api/v2/sdk/sessions/${encodeURIComponent(sessionId)}`;
573
+ return {
574
+ apps: {
575
+ bootstrap: (input) => post(
576
+ "/api/v2/sdk/bootstrap",
577
+ { ...input, appId },
578
+ "transient",
579
+ validateBootstrap
580
+ )
581
+ },
582
+ sessions: {
583
+ start: (input) => post(
584
+ "/api/v2/sdk/sessions",
585
+ { ...input, appId },
586
+ "transient",
587
+ validateGrant
588
+ ),
589
+ refresh: (sessionId, input) => post(
590
+ `${sessionPath(sessionId)}/refresh`,
591
+ { ...input, appId },
592
+ "never",
593
+ validateRefresh
594
+ ),
595
+ end: async (input) => {
596
+ await post(
597
+ `${sessionPath(input.sessionId)}/end`,
598
+ { ...input, appId },
599
+ "transient",
600
+ (value) => validateEnd(value, input.sessionId)
601
+ );
602
+ },
603
+ publish: (sessionId) => post(
604
+ `${sessionPath(sessionId)}/publish`,
605
+ { sessionId, appId },
606
+ "transient",
607
+ (value) => validatePublishState(value, sessionId)
608
+ ),
609
+ unpublish: (sessionId) => post(
610
+ `${sessionPath(sessionId)}/unpublish`,
611
+ { sessionId, appId },
612
+ "transient",
613
+ (value) => validatePublishState(value, sessionId)
614
+ ),
615
+ get: (sessionId) => get(sessionPath(sessionId), validateSession),
616
+ getRecordings: (sessionId) => get(
617
+ `${sessionPath(sessionId)}/recordings`,
618
+ (value) => validateRecordings(value, sessionId)
619
+ )
620
+ },
621
+ webhooks: { verify: verifyWebhook }
622
+ };
623
+ }
624
+
625
+ // src/proxy.ts
626
+ var MAX_BODY_BYTES = 16 * 1024;
627
+ var SEMVER = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
628
+ var BUNDLE_ID = /^[A-Za-z0-9](?:[A-Za-z0-9._-]{1,253}[A-Za-z0-9])?$/;
629
+ var INSTALLATION_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{5,255}$/;
630
+ var SAFE_UPSTREAM_CODES = /* @__PURE__ */ new Set([
631
+ "PERMISSION_DENIED",
632
+ "NETWORK_UNAVAILABLE",
633
+ "TOKEN_EXPIRED",
634
+ "SDK_UPGRADE_REQUIRED",
635
+ "SESSION_CONFLICT",
636
+ "RECORDING_FAILED",
637
+ "CONSENT_REQUIRED",
638
+ "SDK_DISABLED",
639
+ "UNKNOWN"
640
+ ]);
641
+ var InvalidRequest = class extends Error {
642
+ };
643
+ var AuthenticationRequired = class extends Error {
644
+ };
645
+ var AuthorizationDecision = class extends Error {
646
+ constructor(status, code) {
647
+ super(code);
648
+ this.status = status;
649
+ this.code = code;
650
+ }
651
+ };
652
+ var AuthorizationUnavailable = class extends Error {
653
+ };
654
+ function response(data, status = 200) {
655
+ return Response.json(data, {
656
+ status,
657
+ headers: {
658
+ "cache-control": "no-store",
659
+ "content-type": "application/json; charset=utf-8"
660
+ }
661
+ });
662
+ }
663
+ function errorResponse(status, code, message) {
664
+ return response({ error: { code, message } }, status);
665
+ }
666
+ function isObject(value) {
667
+ return value !== null && typeof value === "object" && !Array.isArray(value);
668
+ }
669
+ function hasOnlyKeys(value, allowed) {
670
+ const allowedKeys = new Set(allowed);
671
+ return Object.keys(value).every((key) => allowedKeys.has(key));
672
+ }
673
+ function nonblank2(value, maximum) {
674
+ return typeof value === "string" && value.trim().length > 0 && value.length <= maximum && !/[\u0000-\u001f\u007f]/.test(value);
675
+ }
676
+ async function bodyObject(request) {
677
+ const contentType = request.headers.get("content-type")?.split(";", 1)[0].trim();
678
+ if (contentType !== "application/json") throw new InvalidRequest();
679
+ const declaredLength = request.headers.get("content-length");
680
+ if (declaredLength !== null) {
681
+ if (!/^\d+$/.test(declaredLength) || Number(declaredLength) > MAX_BODY_BYTES) {
682
+ await request.body?.cancel().catch(() => void 0);
683
+ throw new InvalidRequest();
684
+ }
685
+ }
686
+ const reader = request.body?.getReader();
687
+ if (!reader) throw new InvalidRequest();
688
+ const chunks = [];
689
+ let byteLength = 0;
690
+ while (true) {
691
+ const { done, value } = await reader.read();
692
+ if (done) break;
693
+ byteLength += value.byteLength;
694
+ if (byteLength > MAX_BODY_BYTES) {
695
+ await reader.cancel().catch(() => void 0);
696
+ throw new InvalidRequest();
697
+ }
698
+ chunks.push(value);
699
+ }
700
+ const bytes = new Uint8Array(byteLength);
701
+ let offset = 0;
702
+ for (const chunk of chunks) {
703
+ bytes.set(chunk, offset);
704
+ offset += chunk.byteLength;
705
+ }
706
+ try {
707
+ const text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
708
+ const value = JSON.parse(text);
709
+ if (!isObject(value)) throw new InvalidRequest();
710
+ return value;
711
+ } catch (error) {
712
+ if (error instanceof InvalidRequest) throw error;
713
+ throw new InvalidRequest();
714
+ }
715
+ }
716
+ function userAuthorization(headers) {
717
+ const value = headers.get("authorization") ?? "";
718
+ if (!/^Bearer [^\s,]{1,4096}$/.test(value)) throw new AuthenticationRequired();
719
+ return value;
720
+ }
721
+ function appAttestation(headers) {
722
+ const value = headers.get("x-pickleball-app-attestation");
723
+ if (value === null) return void 0;
724
+ if (!nonblank2(value, 8192)) throw new InvalidRequest();
725
+ return value;
726
+ }
727
+ function identityFromHeaders(headers) {
728
+ const sdkVersion = headers.get("x-pickleball-sdk-version") ?? "";
729
+ const platform = headers.get("x-pickleball-platform") ?? "";
730
+ const bundleId = headers.get("x-pickleball-bundle-id") ?? "";
731
+ const installationId = headers.get("x-pickleball-installation-id") ?? "";
732
+ if (!SEMVER.test(sdkVersion) || platform !== "ios" && platform !== "android" || !BUNDLE_ID.test(bundleId) || !INSTALLATION_ID.test(installationId)) {
733
+ throw new InvalidRequest();
734
+ }
735
+ return { sdkVersion, platform, bundleId, installationId };
736
+ }
737
+ function bootstrapBody(value) {
738
+ if (!hasOnlyKeys(value, ["sdkVersion", "platform", "bundleId", "installationId"]) || !SEMVER.test(typeof value.sdkVersion === "string" ? value.sdkVersion : "") || value.platform !== "ios" && value.platform !== "android" || !BUNDLE_ID.test(typeof value.bundleId === "string" ? value.bundleId : "") || !INSTALLATION_ID.test(
739
+ typeof value.installationId === "string" ? value.installationId : ""
740
+ )) {
741
+ throw new InvalidRequest();
742
+ }
743
+ return value;
744
+ }
745
+ function identitiesMatch(left, right) {
746
+ return left.sdkVersion === right.sdkVersion && left.platform === right.platform && left.bundleId === right.bundleId && left.installationId === right.installationId;
747
+ }
748
+ function startBody(value) {
749
+ if (!hasOnlyKeys(value, [
750
+ "externalSessionId",
751
+ "title",
752
+ "visibility",
753
+ "consentVersion",
754
+ "metadata",
755
+ "standby"
756
+ ]) || !nonblank2(value.externalSessionId, 256) || !nonblank2(value.title, 200) || !nonblank2(value.consentVersion, 100) || value.visibility !== void 0 && value.visibility !== "public" && value.visibility !== "private" || value.standby !== void 0 && typeof value.standby !== "boolean") {
757
+ throw new InvalidRequest();
758
+ }
759
+ if (value.metadata !== void 0) {
760
+ if (!isObject(value.metadata) || Object.keys(value.metadata).length > 50) {
761
+ throw new InvalidRequest();
762
+ }
763
+ for (const [key, entry] of Object.entries(value.metadata)) {
764
+ if (!nonblank2(key, 100) || !nonblank2(entry, 500)) throw new InvalidRequest();
765
+ }
766
+ }
767
+ return value;
768
+ }
769
+ function endBody(value, sessionId) {
770
+ if (!hasOnlyKeys(value, ["sessionId", "reason"]) || value.sessionId !== sessionId || ![
771
+ "user",
772
+ "background_timeout",
773
+ "network_timeout",
774
+ "standby_timeout",
775
+ "error"
776
+ ].includes(typeof value.reason === "string" ? value.reason : "")) {
777
+ throw new InvalidRequest();
778
+ }
779
+ return value;
780
+ }
781
+ function apiEnvironment(value) {
782
+ if (!nonblank2(value.PICKLEBALL_API_BASE_URL, 2048) || !nonblank2(value.PICKLEBALL_APP_ID, 256) || !nonblank2(value.PICKLEBALL_API_KEY, 1024)) {
783
+ throw new PickleballConfigurationError("Proxy is not configured");
784
+ }
785
+ return {
786
+ PICKLEBALL_API_BASE_URL: value.PICKLEBALL_API_BASE_URL,
787
+ PICKLEBALL_APP_ID: value.PICKLEBALL_APP_ID,
788
+ PICKLEBALL_API_KEY: value.PICKLEBALL_API_KEY
789
+ };
790
+ }
791
+ function authorizationEnvironment(value) {
792
+ if (!nonblank2(value.PICKLEBALL_PROXY_AUTHZ_URL, 2048) || !nonblank2(value.PICKLEBALL_PROXY_AUTHZ_SERVICE_TOKEN, 1024)) {
793
+ throw new AuthorizationUnavailable();
794
+ }
795
+ try {
796
+ const url = new URL(value.PICKLEBALL_PROXY_AUTHZ_URL);
797
+ if (url.protocol !== "https:" || url.username || url.password) throw new Error();
798
+ } catch {
799
+ throw new AuthorizationUnavailable();
800
+ }
801
+ return {
802
+ url: value.PICKLEBALL_PROXY_AUTHZ_URL,
803
+ serviceToken: value.PICKLEBALL_PROXY_AUTHZ_SERVICE_TOKEN
804
+ };
805
+ }
806
+ function defaultCreateClient(env) {
807
+ return createPickleballLiveClient({
808
+ baseUrl: env.PICKLEBALL_API_BASE_URL,
809
+ appId: env.PICKLEBALL_APP_ID,
810
+ apiKey: env.PICKLEBALL_API_KEY
811
+ });
812
+ }
813
+ function clientFor(deps) {
814
+ const configured = apiEnvironment(deps.env);
815
+ return (deps.createClient ?? defaultCreateClient)(configured);
816
+ }
817
+ async function authorize(deps, authorization, operation, identity, scope = {}, attestation) {
818
+ const configured = authorizationEnvironment(deps.env);
819
+ const fetchImplementation = deps.authzFetch ?? globalThis.fetch;
820
+ if (typeof fetchImplementation !== "function") throw new AuthorizationUnavailable();
821
+ const controller = new AbortController();
822
+ const timeout = setTimeout(() => controller.abort(), 5e3);
823
+ let result;
824
+ try {
825
+ result = await fetchImplementation(configured.url, {
826
+ method: "POST",
827
+ headers: {
828
+ accept: "application/json",
829
+ authorization,
830
+ "content-type": "application/json",
831
+ "x-pickleball-proxy-service-token": configured.serviceToken,
832
+ ...attestation === void 0 ? {} : { "x-pickleball-app-attestation": attestation }
833
+ },
834
+ body: JSON.stringify({ operation, identity, ...scope }),
835
+ signal: controller.signal,
836
+ redirect: "error"
837
+ });
838
+ } catch {
839
+ throw new AuthorizationUnavailable();
840
+ } finally {
841
+ clearTimeout(timeout);
842
+ }
843
+ if (result.status === 401 || result.status === 403 || result.status === 429) {
844
+ await result.body?.cancel().catch(() => void 0);
845
+ if (result.status === 401) throw new AuthorizationDecision(401, "UNAUTHORIZED");
846
+ if (result.status === 403) throw new AuthorizationDecision(403, "FORBIDDEN");
847
+ throw new AuthorizationDecision(429, "RATE_LIMITED");
848
+ }
849
+ if (!result.ok) {
850
+ await result.body?.cancel().catch(() => void 0);
851
+ throw new AuthorizationUnavailable();
852
+ }
853
+ let payload;
854
+ try {
855
+ payload = await result.json();
856
+ } catch {
857
+ throw new AuthorizationUnavailable();
858
+ }
859
+ if (!isObject(payload) || !isObject(payload.data) || payload.data.allowed !== true || !nonblank2(payload.data.principalId, 256)) {
860
+ throw new AuthorizationUnavailable();
861
+ }
862
+ return { principalId: payload.data.principalId };
863
+ }
864
+ function safeLog(deps, error) {
865
+ const candidate = error;
866
+ (deps.logger ?? console).error("Pickleball proxy request failed", {
867
+ name: typeof candidate?.name === "string" ? candidate.name : "Error",
868
+ code: typeof candidate?.code === "string" ? candidate.code : "UNKNOWN",
869
+ status: typeof candidate?.status === "number" ? candidate.status : void 0
870
+ });
871
+ }
872
+ function mappedError(error, deps) {
873
+ if (error instanceof InvalidRequest) {
874
+ return errorResponse(400, "INVALID_REQUEST", "Invalid request");
875
+ }
876
+ if (error instanceof AuthenticationRequired) {
877
+ return errorResponse(401, "UNAUTHORIZED", "Authentication required");
878
+ }
879
+ if (error instanceof AuthorizationDecision) {
880
+ const message = error.code === "UNAUTHORIZED" ? "Authentication required" : error.code === "RATE_LIMITED" ? "Too many requests" : "Operation not permitted";
881
+ return errorResponse(error.status, error.code, message);
882
+ }
883
+ if (error instanceof AuthorizationUnavailable) {
884
+ safeLog(deps, error);
885
+ return errorResponse(503, "AUTHZ_UNAVAILABLE", "Authorization service unavailable");
886
+ }
887
+ safeLog(deps, error);
888
+ const candidate = error;
889
+ if (error instanceof PickleballApiError || candidate?.name === "PickleballApiError") {
890
+ const status = typeof candidate.status === "number" && candidate.status >= 400 && candidate.status <= 599 ? candidate.status : 502;
891
+ const code = SAFE_UPSTREAM_CODES.has(candidate.code ?? "") ? candidate.code : "UNKNOWN";
892
+ return errorResponse(status, code, "Livestream request was rejected");
893
+ }
894
+ if (error instanceof PickleballConfigurationError) {
895
+ return errorResponse(500, "CONFIGURATION_ERROR", "Proxy is not configured");
896
+ }
897
+ if (error instanceof PickleballTimeoutError || candidate?.name === "PickleballTimeoutError") {
898
+ return errorResponse(504, "TIMEOUT", "Upstream request timed out");
899
+ }
900
+ if (error instanceof PickleballNetworkError || candidate?.name === "PickleballNetworkError") {
901
+ return errorResponse(502, "NETWORK_UNAVAILABLE", "Upstream network unavailable");
902
+ }
903
+ if (error instanceof PickleballInvalidResponseError || candidate?.name === "PickleballInvalidResponseError") {
904
+ return errorResponse(502, "INVALID_RESPONSE", "Invalid upstream response");
905
+ }
906
+ if (error instanceof PickleballHttpError || candidate?.name === "PickleballHttpError") {
907
+ return errorResponse(502, "UNKNOWN", "Upstream request failed");
908
+ }
909
+ return errorResponse(500, "UNKNOWN", "Proxy request failed");
910
+ }
911
+ async function execute(deps, action) {
912
+ try {
913
+ return response({ data: await action() });
914
+ } catch (error) {
915
+ return mappedError(error, deps);
916
+ }
917
+ }
918
+ async function createStableIdempotencyKey(input) {
919
+ const canonical = [
920
+ "pickleball-sdk-v1",
921
+ input.appId,
922
+ input.installationId,
923
+ input.externalSessionId,
924
+ input.consentVersion
925
+ ].join("\0");
926
+ const digest = new Uint8Array(
927
+ await crypto.subtle.digest("SHA-256", new TextEncoder().encode(canonical))
928
+ );
929
+ return Array.from(digest, (byte) => byte.toString(16).padStart(2, "0")).join("");
930
+ }
931
+ async function handleBootstrap(request, deps) {
932
+ return execute(deps, async () => {
933
+ const authorization = userAuthorization(request.headers);
934
+ const attestation = appAttestation(request.headers);
935
+ const identity = identityFromHeaders(request.headers);
936
+ const input = bootstrapBody(await bodyObject(request));
937
+ if (!identitiesMatch(identity, input)) throw new InvalidRequest();
938
+ await authorize(deps, authorization, "bootstrap", identity, {}, attestation);
939
+ return clientFor(deps).apps.bootstrap(input);
940
+ });
941
+ }
942
+ async function handleSessions(request, deps) {
943
+ return execute(deps, async () => {
944
+ const authorization = userAuthorization(request.headers);
945
+ const attestation = appAttestation(request.headers);
946
+ const identity = identityFromHeaders(request.headers);
947
+ const input = startBody(await bodyObject(request));
948
+ const startAuthorization = await authorize(deps, authorization, "start", identity, {
949
+ externalSessionId: input.externalSessionId
950
+ }, attestation);
951
+ const configured = apiEnvironment(deps.env);
952
+ const idempotencyKey = await createStableIdempotencyKey({
953
+ appId: configured.PICKLEBALL_APP_ID,
954
+ installationId: identity.installationId,
955
+ externalSessionId: input.externalSessionId,
956
+ consentVersion: input.consentVersion
957
+ });
958
+ const client = (deps.createClient ?? defaultCreateClient)(configured);
959
+ const grant = await client.sessions.start({ ...identity, ...input, idempotencyKey });
960
+ try {
961
+ await authorize(deps, authorization, "claim", identity, {
962
+ sessionId: grant.sessionId,
963
+ externalSessionId: input.externalSessionId,
964
+ principalId: startAuthorization.principalId
965
+ }, attestation);
966
+ } catch (error) {
967
+ try {
968
+ await client.sessions.end({ sessionId: grant.sessionId, reason: "error" });
969
+ } catch (cleanupError) {
970
+ safeLog(deps, cleanupError);
971
+ }
972
+ throw error;
973
+ }
974
+ return grant;
975
+ });
976
+ }
977
+ async function handleSessionAction(request, params, deps) {
978
+ if (params.action !== "refresh" && params.action !== "end" && params.action !== "publish" && params.action !== "unpublish") {
979
+ return errorResponse(404, "NOT_FOUND", "Route not found");
980
+ }
981
+ return execute(deps, async () => {
982
+ const authorization = userAuthorization(request.headers);
983
+ const attestation = appAttestation(request.headers);
984
+ if (!nonblank2(params.id, 256)) throw new InvalidRequest();
985
+ const identity = identityFromHeaders(request.headers);
986
+ const body = await bodyObject(request);
987
+ if (params.action === "refresh") {
988
+ if (Object.keys(body).length !== 0) throw new InvalidRequest();
989
+ await authorize(deps, authorization, "refresh", identity, {
990
+ sessionId: params.id
991
+ }, attestation);
992
+ return clientFor(deps).sessions.refresh(params.id, identity);
993
+ }
994
+ if (params.action === "publish" || params.action === "unpublish") {
995
+ if (!hasOnlyKeys(body, ["sessionId"]) || body.sessionId !== params.id) {
996
+ throw new InvalidRequest();
997
+ }
998
+ await authorize(deps, authorization, params.action, identity, {
999
+ sessionId: params.id
1000
+ }, attestation);
1001
+ const sessions = clientFor(deps).sessions;
1002
+ return params.action === "publish" ? sessions.publish(params.id) : sessions.unpublish(params.id);
1003
+ }
1004
+ const input = endBody(body, params.id);
1005
+ await authorize(deps, authorization, "end", identity, {
1006
+ sessionId: params.id
1007
+ }, attestation);
1008
+ await clientFor(deps).sessions.end(input);
1009
+ return null;
1010
+ });
1011
+ }
1012
+ // Annotate the CommonJS export names for ESM import in node:
1013
+ 0 && (module.exports = {
1014
+ createStableIdempotencyKey,
1015
+ handleBootstrap,
1016
+ handleSessionAction,
1017
+ handleSessions
1018
+ });