@powly/sdk 0.0.0-stage → 0.4.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/powly.js ADDED
@@ -0,0 +1,1376 @@
1
+ /* Powly SDK 0.4.1: defines window.Powly */
2
+ (function () {
3
+ "use strict";
4
+ /** This SDK's version; Powly's Studio shows it next to the downloads. */
5
+ const SDK_VERSION = "0.4.1";
6
+
7
+ // The messages between a game (this SDK, inside Powly's iframe) and the Powly page that framed it.
8
+ // Shared with the host side (apps/web/components/player/sdk-host.ts).
9
+ //
10
+ // 1. The game creates a MessageChannel and posts `InitMessage` to `window.parent`, with the Powly
11
+ // origin it expects as targetOrigin (so the port only ever reaches Powly) and port2 attached.
12
+ // 2. Powly answers only the iframe it created, from the origin it expects that frame to have,
13
+ // with `ReadyMessage` over the port. Everything after that goes over the port.
14
+ // 3. From then on the game also reports to Powly Anti-Cheat over the port (`PacSample`, src/pac.ts).
15
+ const PROTOCOL = "powly-sdk";
16
+ const PROTOCOL_VERSION = 1;
17
+ const isInitMessage = (data) => typeof data === "object" &&
18
+ data !== null &&
19
+ data.protocol === PROTOCOL &&
20
+ data.type === "init" &&
21
+ data.version === PROTOCOL_VERSION;
22
+ const isRequestMessage = (data) => typeof data === "object" &&
23
+ data !== null &&
24
+ data.type === "request" &&
25
+ Number.isInteger(data.id) &&
26
+ ["getSessionToken", "purchases.open", "fullscreen.toggle", "invites.open"].includes(data.method);
27
+
28
+ // The Powly origins this build of the SDK trusts by default: a game only handshakes with a parent
29
+ // page on one of them (unless it passes `powlyOrigin` to `Powly.init()` itself). Set at build
30
+ // time with POWLY_SDK_ORIGINS (comma-separated origins); scripts/bundle.ts writes the list
31
+ // into dist. Without it: Powly's production origin and the local development app.
32
+ const POWLY_ORIGINS = ["https://powly.gg", "http://localhost:3000"];
33
+
34
+ // The error every SDK call fails with.
35
+ class PowlyError extends Error {
36
+ status;
37
+ /** For realtime calls (rooms): what went wrong, e.g. "full", "locked", "not_found". */
38
+ code;
39
+ constructor(message, status, code) {
40
+ super(message);
41
+ this.name = "PowlyError";
42
+ this.status = status;
43
+ this.code = code;
44
+ }
45
+ }
46
+
47
+ // Powly Anti-Cheat (PAC), the game's side. After `Powly.init()` the SDK reports to the Powly page
48
+ // every few seconds over its private port: the game's clocks (Powly compares them with its own and
49
+ // the server's to catch speed hacks), browser functions that were replaced (speed hack scripts and
50
+ // extensions patch timers), traces of automated browsers, and how input arrives (scripted events,
51
+ // clicks without pointer movement as AI agents make them, machine-regular timing as macros and
52
+ // auto-clickers make them). Nothing is sent anywhere else, and nothing here reads what the player
53
+ // types: only event counts and timings.
54
+ //
55
+ // In real players' runs (not developer mode) the SDK also guards the game's code against being
56
+ // copied (`guard`): the browser's context menu and its view-source, save-page and developer-tools
57
+ // shortcuts don't open over the game (the game still gets the events), and a debugger trap both
58
+ // detects open developer tools and pauses the game whenever someone inspects it.
59
+ //
60
+ // None of this is a security boundary (the game runs in the player's browser). The Powly server
61
+ // decides what a reading means (apps/web/lib/server/pac/). `detectAutomation` and
62
+ // `tamperedFunctions` are shared with the Powly page (apps/web/components/player/pac-sensor.ts).
63
+ /** How often the SDK reports (ms). */
64
+ const PAC_SAMPLE_MS = 5000;
65
+ const isPacSample = (data) => typeof data === "object" &&
66
+ data !== null &&
67
+ data.type === "pac" &&
68
+ typeof data.clock === "number" &&
69
+ typeof data.input === "object";
70
+ const NATIVE = /\{\s*\[native code\]\s*\}\s*$/;
71
+ /**
72
+ * Whether `fn` is the browser's own function. Known wrappers that keep the original (error
73
+ * trackers, Zone.js) count as native when what they wrap is.
74
+ */
75
+ function isNative(fn, fnToString) {
76
+ if (typeof fn !== "function")
77
+ return false;
78
+ try {
79
+ if (NATIVE.test(fnToString.call(fn)))
80
+ return true;
81
+ }
82
+ catch {
83
+ return false;
84
+ }
85
+ const wrapped = fn;
86
+ const original = wrapped.__sentry_original__ ?? wrapped.__zone_symbol__OriginalDelegate;
87
+ return original !== undefined && original !== fn && isNative(original, fnToString);
88
+ }
89
+ /**
90
+ * Browser functions a speed hack or cheat script replaces, by name, when they aren't the
91
+ * browser's own. The clocks ("performance.now", "Date.now") and Function.prototype.toString (which
92
+ * hides patches) are what PAC treats as critical.
93
+ */
94
+ function tamperedFunctions(win) {
95
+ const found = [];
96
+ const fnToString = win.Function.prototype.toString;
97
+ // Checked with itself first: a replaced toString could vouch for everything else.
98
+ if (!NATIVE.test(fnToString.call(fnToString))) {
99
+ found.push("Function.prototype.toString");
100
+ }
101
+ const checks = [
102
+ ["performance.now", win.performance?.now],
103
+ ["Date.now", win.Date.now],
104
+ ["Date", win.Date],
105
+ ["requestAnimationFrame", win.requestAnimationFrame],
106
+ ["setTimeout", win.setTimeout],
107
+ ["setInterval", win.setInterval],
108
+ ];
109
+ for (const [name, fn] of checks)
110
+ if (!isNative(fn, fnToString))
111
+ found.push(name);
112
+ // The browser's own `now` lives on Performance.prototype; a patch puts one on the instance.
113
+ if (win.performance && Object.hasOwn(win.performance, "now"))
114
+ found.push("performance.now");
115
+ return [...new Set(found)];
116
+ }
117
+ // Globals and attributes that drivers of automated browsers leave behind.
118
+ const AUTOMATION_GLOBALS = [
119
+ [
120
+ /^\$?cdc_|^\$wdc_|^__webdriver|^__driver_|^__fxdriver|^__selenium|^_selenium|^callSelenium|^_Selenium_IDE/,
121
+ "selenium",
122
+ ],
123
+ [/^__playwright|^__pw_/, "playwright"],
124
+ [/^__puppeteer/, "puppeteer"],
125
+ [/^callPhantom$|^_phantom$|^__phantomas$/, "phantom"],
126
+ [/^__nightmare$/, "nightmare"],
127
+ [/^domAutomation(Controller)?$/, "chrome-automation"],
128
+ [/^__lastWatirAlert|^__lastWatirConfirm|^__lastWatirPrompt/, "watir"],
129
+ ];
130
+ /** Traces of an automated browser (WebDriver, DevTools Protocol drivers, headless browsers). */
131
+ function detectAutomation(win) {
132
+ const found = new Set();
133
+ const nav = win.navigator;
134
+ try {
135
+ if (nav.webdriver)
136
+ found.add("webdriver");
137
+ // The browser defines `webdriver` on Navigator.prototype; stealth plugins hide it on the
138
+ // instance instead.
139
+ if (Object.getOwnPropertyDescriptor(nav, "webdriver"))
140
+ found.add("webdriver-hidden");
141
+ }
142
+ catch { }
143
+ if (/HeadlessChrome|PhantomJS|SlimerJS|HtmlUnit/i.test(nav.userAgent))
144
+ found.add("headless");
145
+ const names = [...Object.getOwnPropertyNames(win), ...Object.getOwnPropertyNames(win.document)];
146
+ for (const name of names) {
147
+ for (const [pattern, label] of AUTOMATION_GLOBALS)
148
+ if (pattern.test(name))
149
+ found.add(label);
150
+ }
151
+ const root = win.document.documentElement;
152
+ for (const attribute of ["webdriver", "selenium", "driver"]) {
153
+ if (root?.hasAttribute(attribute))
154
+ found.add("selenium");
155
+ }
156
+ return [...found];
157
+ }
158
+ /** Standard deviation of the intervals between `times` (ms), or null for too few. */
159
+ function intervalSd(times) {
160
+ if (times.length < 3)
161
+ return null;
162
+ const intervals = times.slice(1).map((time, index) => time - (times[index] ?? time));
163
+ const mean = intervals.reduce((sum, value) => sum + value, 0) / intervals.length;
164
+ // Only paced input (macros, auto-clickers) is interesting, not held keys or pauses.
165
+ if (mean < 20 || mean > 2000)
166
+ return null;
167
+ const variance = intervals.reduce((sum, value) => sum + (value - mean) ** 2, 0) / intervals.length;
168
+ return Math.sqrt(variance);
169
+ }
170
+ /**
171
+ * Shortcuts that open view-source, save the page or open developer tools: F12, Ctrl+U/S,
172
+ * Ctrl+Shift+I/J/C, and their Cmd(+Option) versions on macOS.
173
+ */
174
+ function isInspectShortcut(event) {
175
+ if (event.key === "F12")
176
+ return true;
177
+ const mod = event.ctrlKey || event.metaKey;
178
+ if (!mod)
179
+ return false;
180
+ const key = event.code.replace(/^Key/, "").toUpperCase();
181
+ if (key === "U")
182
+ return true;
183
+ if (key === "S")
184
+ return !event.shiftKey;
185
+ return (event.shiftKey || event.altKey) && (key === "I" || key === "J" || key === "C");
186
+ }
187
+ /** A paused debugger stops the clock for at least this long (ms) when developer tools are open. */
188
+ const TRAP_PAUSE_MS = 100;
189
+ /**
190
+ * The debugger trap: does nothing unless developer tools are open, where it pauses. Built at run
191
+ * time so minifiers that drop `debugger` statements (terser's default) keep it; games whose own
192
+ * Content-Security-Policy forbids that simply go without.
193
+ */
194
+ function makeTrap(win) {
195
+ try {
196
+ return new win.Function("debugger");
197
+ }
198
+ catch {
199
+ return null;
200
+ }
201
+ }
202
+ /** How many recent key presses and clicks the timing check looks at. */
203
+ const TIMING_WINDOW = 40;
204
+ /**
205
+ * Starts reporting to the Powly page over `port`. Returns a function that stops it.
206
+ */
207
+ function startPacSensor(port, { guard = false } = {}, win = window) {
208
+ const input = {
209
+ trusted: 0,
210
+ synthetic: 0,
211
+ clicks: 0,
212
+ teleportClicks: 0,
213
+ };
214
+ const keyTimes = [];
215
+ const clickTimes = [];
216
+ // The pointer's last known position, from any pointer event, and whether it moved since.
217
+ let pointer = null;
218
+ const remember = (list, time) => {
219
+ list.push(time);
220
+ if (list.length > TIMING_WINDOW)
221
+ list.shift();
222
+ };
223
+ const onInput = (event) => {
224
+ const counted = event.type === "keydown" || event.type === "pointerdown";
225
+ if (!event.isTrusted) {
226
+ if (counted)
227
+ input.synthetic += 1;
228
+ return;
229
+ }
230
+ if (event.type === "pointermove" || event.type === "pointerover") {
231
+ const move = event;
232
+ pointer = { x: move.clientX, y: move.clientY };
233
+ return;
234
+ }
235
+ if (!counted)
236
+ return;
237
+ input.trusted += 1;
238
+ if (event.type === "keydown") {
239
+ if (!event.repeat)
240
+ remember(keyTimes, event.timeStamp);
241
+ return;
242
+ }
243
+ const down = event;
244
+ if (down.pointerType !== "mouse")
245
+ return;
246
+ input.clicks += 1;
247
+ remember(clickTimes, event.timeStamp);
248
+ // A real mouse moves to where it clicks, and the frame hears that; a driver that clicks at
249
+ // coordinates doesn't. Only a click somewhere else than the pointer last was counts.
250
+ if (pointer && Math.hypot(down.clientX - pointer.x, down.clientY - pointer.y) > 4) {
251
+ input.teleportClicks += 1;
252
+ }
253
+ pointer = { x: down.clientX, y: down.clientY };
254
+ };
255
+ const types = ["keydown", "pointerdown", "pointermove", "pointerover"];
256
+ for (const type of types) {
257
+ win.addEventListener(type, onInput, { capture: true, passive: true });
258
+ }
259
+ // The guard: only the browser's own action is prevented; the game's listeners still run.
260
+ const onGuardKey = (event) => {
261
+ if (isInspectShortcut(event))
262
+ event.preventDefault();
263
+ };
264
+ const onContextMenu = (event) => event.preventDefault();
265
+ if (guard) {
266
+ win.addEventListener("keydown", onGuardKey, { capture: true });
267
+ win.addEventListener("contextmenu", onContextMenu, { capture: true });
268
+ }
269
+ const trap = guard ? makeTrap(win) : null;
270
+ let devtools = false;
271
+ const springTrap = () => {
272
+ if (!trap)
273
+ return;
274
+ const start = win.performance.now();
275
+ trap();
276
+ if (win.performance.now() - start > TRAP_PAUSE_MS)
277
+ devtools = true;
278
+ };
279
+ const now = () => win.performance.now();
280
+ let lastClock = now();
281
+ let lastDate = win.Date.now();
282
+ let dateJump = 0;
283
+ let automation = detectAutomation(win);
284
+ // The date is compared with the monotonic clock more often than reports go out, so a jump in
285
+ // between isn't averaged away.
286
+ const watchDate = () => {
287
+ const clock = now();
288
+ const date = win.Date.now();
289
+ const jump = Math.abs(date - lastDate - (clock - lastClock));
290
+ if (jump > dateJump)
291
+ dateJump = jump;
292
+ lastClock = clock;
293
+ lastDate = date;
294
+ };
295
+ const report = () => {
296
+ watchDate();
297
+ // Drivers can attach after the game started.
298
+ automation = [...new Set([...automation, ...detectAutomation(win)])];
299
+ const sample = {
300
+ type: "pac",
301
+ clock: now(),
302
+ date: win.Date.now(),
303
+ dateJump: Math.round(dateJump),
304
+ devtools,
305
+ tampered: tamperedFunctions(win),
306
+ automation,
307
+ input: {
308
+ ...input,
309
+ keyIntervals: Math.max(keyTimes.length - 1, 0),
310
+ keyIntervalSd: intervalSd(keyTimes),
311
+ clickIntervals: Math.max(clickTimes.length - 1, 0),
312
+ clickIntervalSd: intervalSd(clickTimes),
313
+ },
314
+ };
315
+ try {
316
+ port.postMessage(sample);
317
+ }
318
+ catch { }
319
+ input.trusted = 0;
320
+ input.synthetic = 0;
321
+ input.clicks = 0;
322
+ input.teleportClicks = 0;
323
+ dateJump = 0;
324
+ devtools = false;
325
+ };
326
+ // The browser's own timers, taken before anything could replace them later.
327
+ const setInterval = win.setInterval.bind(win);
328
+ const clearInterval = win.clearInterval.bind(win);
329
+ const dateTimer = setInterval(watchDate, 1000);
330
+ const reportTimer = setInterval(report, PAC_SAMPLE_MS);
331
+ const trapTimer = trap ? setInterval(springTrap, 1500) : undefined;
332
+ report();
333
+ return () => {
334
+ clearInterval(dateTimer);
335
+ clearInterval(reportTimer);
336
+ if (trapTimer !== undefined)
337
+ clearInterval(trapTimer);
338
+ for (const type of types)
339
+ win.removeEventListener(type, onInput, { capture: true });
340
+ win.removeEventListener("keydown", onGuardKey, { capture: true });
341
+ win.removeEventListener("contextmenu", onContextMenu, { capture: true });
342
+ };
343
+ }
344
+
345
+ // The wire protocol of Powly's realtime service (apps/realtime): JSON text frames over one
346
+ // WebSocket. Games use it through the SDK (`powly.rooms`, src/rooms.ts); Powly's own pages use it
347
+ // for live notifications (apps/web/lib/realtime-client.ts). Shared as types only: the service
348
+ // validates every frame itself.
349
+ //
350
+ // 1. The client gets a ticket from Powly (games: POST /api/platform/v1/realtime with a session
351
+ // token; Powly's pages: POST /api/realtime/ticket with the session cookie) and connects to the
352
+ // ticket's URL.
353
+ // 2. Its first frame is `{ op: "auth", ticket }`; the service answers `welcome`. Tickets live two
354
+ // minutes: the client sends a fresh one (`auth` again) before that, or the service closes the
355
+ // connection. That is how ended launches, bans and sign-outs reach open connections.
356
+ // 3. Requests carry an `id` and get one `reply`; `room.send` has none (fire and forget).
357
+ const REALTIME_PROTOCOL_VERSION = 1;
358
+ /** Close codes the service uses (4000–4999 are free for applications). */
359
+ const REALTIME_CLOSE = {
360
+ /** The ticket was invalid, or expired without a fresh one. Get a new ticket to reconnect. */
361
+ unauthorized: 4001,
362
+ /** The same player connected too many times; the oldest connection is closed. */
363
+ replaced: 4002,
364
+ /** Too many messages, or the client couldn't keep up with what it was sent. */
365
+ overloaded: 4008,
366
+ /** A frame that isn't valid JSON or protocol. */
367
+ invalid: 4009,
368
+ };
369
+
370
+
371
+
372
+ const REQUEST_TIMEOUT_MS = 10_000;
373
+ const TICKET_REFRESH_MS = 45_000;
374
+ const MAX_RETRY_MS = 30_000;
375
+ /** With no room and no invite listener, the connection closes after this (ms). */
376
+ const IDLE_CLOSE_MS = 60_000;
377
+ function emit(listeners, event, payload) {
378
+ for (const listener of listeners.get(event) ?? []) {
379
+ try {
380
+ listener(payload);
381
+ }
382
+ catch (error) {
383
+ // A game's listener throwing mustn't break the connection.
384
+ setTimeout(() => {
385
+ throw error;
386
+ });
387
+ }
388
+ }
389
+ }
390
+ function listen(listeners, event, listener) {
391
+ const set = listeners.get(event) ?? new Set();
392
+ set.add(listener);
393
+ listeners.set(event, set);
394
+ return () => {
395
+ set.delete(listener);
396
+ };
397
+ }
398
+ /**
399
+ * The game's realtime connection. `getTicket` asks the platform API for a ticket (POST
400
+ * /realtime); `invite` sends a Powly invite.
401
+ */
402
+ function createRooms(options) {
403
+ let socket = null;
404
+ let status = "offline";
405
+ let ready = null;
406
+ let stopped = false;
407
+ let attempt = 0;
408
+ let retryTimer;
409
+ let refreshTimer;
410
+ let idleTimer;
411
+ let nextId = 1;
412
+ const pending = new Map();
413
+ const statusListeners = new Map();
414
+ const inviteListeners = new Set();
415
+ let current = null;
416
+ const setStatus = (next) => {
417
+ if (status === next)
418
+ return;
419
+ status = next;
420
+ emit(statusListeners, "status", next);
421
+ };
422
+ /** Whether anything still needs the connection. */
423
+ const needed = () => !stopped && (current !== null || inviteListeners.size > 0);
424
+ const failPending = (message) => {
425
+ for (const [id, waiting] of pending) {
426
+ clearTimeout(waiting.timer);
427
+ waiting.reject(new PowlyError(message, undefined, "unavailable"));
428
+ pending.delete(id);
429
+ }
430
+ };
431
+ const scheduleRefresh = () => {
432
+ clearTimeout(refreshTimer);
433
+ refreshTimer = setTimeout(async () => {
434
+ try {
435
+ const fresh = await options.getTicket();
436
+ if (socket?.readyState === WebSocket.OPEN) {
437
+ socket.send(JSON.stringify({ op: "auth", ticket: fresh.ticket }));
438
+ }
439
+ }
440
+ catch {
441
+ // The launch ended, or Powly is unreachable: the service closes the connection when the
442
+ // ticket runs out, and reconnecting asks again.
443
+ }
444
+ if (socket)
445
+ scheduleRefresh();
446
+ }, TICKET_REFRESH_MS);
447
+ };
448
+ const onMessage = (message) => {
449
+ if (message.type === "reply") {
450
+ const waiting = pending.get(message.id);
451
+ if (!waiting)
452
+ return;
453
+ pending.delete(message.id);
454
+ clearTimeout(waiting.timer);
455
+ if (message.ok)
456
+ waiting.resolve(message.result);
457
+ else
458
+ waiting.reject(new PowlyError(message.error.message, undefined, message.error.code));
459
+ }
460
+ else if (message.type === "invite") {
461
+ for (const listener of inviteListeners)
462
+ listener(message.invite);
463
+ }
464
+ else if (message.type.startsWith("room.")) {
465
+ current?.apply(message);
466
+ }
467
+ };
468
+ const connect = () => {
469
+ if (stopped)
470
+ return Promise.reject(new PowlyError("The launch has ended.", 401));
471
+ clearTimeout(idleTimer);
472
+ ready ??= new Promise((resolve, reject) => {
473
+ setStatus(attempt === 0 && status === "offline" ? "connecting" : "reconnecting");
474
+ let welcomed = false;
475
+ options
476
+ .getTicket()
477
+ .then((ticket) => {
478
+ if (stopped)
479
+ throw new PowlyError("The launch has ended.", 401);
480
+ const ws = new WebSocket(ticket.url);
481
+ socket = ws;
482
+ ws.onopen = () => ws.send(JSON.stringify({ op: "auth", ticket: ticket.ticket }));
483
+ ws.onmessage = (event) => {
484
+ let message;
485
+ try {
486
+ message = JSON.parse(String(event.data));
487
+ }
488
+ catch {
489
+ return;
490
+ }
491
+ if (message.type === "welcome") {
492
+ welcomed = true;
493
+ attempt = 0;
494
+ setStatus("connected");
495
+ scheduleRefresh();
496
+ resolve();
497
+ }
498
+ else {
499
+ onMessage(message);
500
+ }
501
+ };
502
+ ws.onclose = (event) => {
503
+ if (socket !== ws)
504
+ return;
505
+ socket = null;
506
+ ready = null;
507
+ clearTimeout(refreshTimer);
508
+ failPending("The connection to Powly was lost.");
509
+ if (!welcomed)
510
+ reject(new PowlyError("Couldn’t connect to Powly.", undefined, "unavailable"));
511
+ onDisconnected(event.code);
512
+ };
513
+ })
514
+ .catch((error) => {
515
+ ready = null;
516
+ setStatus("offline");
517
+ reject(error instanceof PowlyError ? error : new PowlyError("Couldn’t connect to Powly."));
518
+ // The ticket was refused (launch over): nothing to retry.
519
+ if (error instanceof PowlyError && error.status === 401)
520
+ return;
521
+ if (needed())
522
+ retry();
523
+ });
524
+ });
525
+ return ready;
526
+ };
527
+ const retry = () => {
528
+ clearTimeout(retryTimer);
529
+ const delay = Math.min(MAX_RETRY_MS, 1000 * 2 ** attempt) * (0.5 + Math.random() / 2);
530
+ attempt += 1;
531
+ setStatus("reconnecting");
532
+ retryTimer = setTimeout(() => {
533
+ if (!needed())
534
+ return setStatus("offline");
535
+ connect()
536
+ .then(rejoin)
537
+ .catch(() => { });
538
+ }, delay);
539
+ };
540
+ const onDisconnected = (code) => {
541
+ if (code === REALTIME_CLOSE.replaced) {
542
+ // This player connected again elsewhere (another tab): that one has the room now.
543
+ current?.close("replaced");
544
+ setStatus("offline");
545
+ return;
546
+ }
547
+ if (!needed())
548
+ return setStatus("offline");
549
+ retry();
550
+ };
551
+ /** Back after a dropped connection: take the seat in the room again. */
552
+ const rejoin = async () => {
553
+ const room = current;
554
+ if (!room)
555
+ return;
556
+ try {
557
+ const state = (await request({
558
+ op: "room.join",
559
+ code: room.code,
560
+ me: room.myData(),
561
+ }));
562
+ if (current === room)
563
+ room.reset(state);
564
+ }
565
+ catch (error) {
566
+ if (current === room && !(error instanceof PowlyError && error.code === "unavailable")) {
567
+ room.close("connection");
568
+ }
569
+ }
570
+ };
571
+ const request = async (message) => {
572
+ await connect();
573
+ const ws = socket;
574
+ if (!ws || ws.readyState !== WebSocket.OPEN) {
575
+ throw new PowlyError("Not connected to Powly.", undefined, "unavailable");
576
+ }
577
+ const id = nextId++;
578
+ return new Promise((resolve, reject) => {
579
+ const timer = setTimeout(() => {
580
+ pending.delete(id);
581
+ reject(new PowlyError("Powly didn’t answer in time.", undefined, "unavailable"));
582
+ }, REQUEST_TIMEOUT_MS);
583
+ pending.set(id, { resolve, reject, timer });
584
+ ws.send(JSON.stringify({ ...message, id }));
585
+ });
586
+ };
587
+ const disconnect = () => {
588
+ clearTimeout(retryTimer);
589
+ clearTimeout(refreshTimer);
590
+ clearTimeout(idleTimer);
591
+ const ws = socket;
592
+ socket = null;
593
+ ready = null;
594
+ ws?.close(1000);
595
+ setStatus("offline");
596
+ };
597
+ /** Closes the connection once nothing has needed it for a minute. */
598
+ const disconnectIfIdle = () => {
599
+ clearTimeout(idleTimer);
600
+ if (needed())
601
+ return;
602
+ if (stopped)
603
+ return disconnect();
604
+ idleTimer = setTimeout(() => {
605
+ if (!needed())
606
+ disconnect();
607
+ }, IDLE_CLOSE_MS);
608
+ };
609
+ function createRoom(initial) {
610
+ let state = initial;
611
+ let active = true;
612
+ const listeners = new Map();
613
+ const view = {
614
+ get id() {
615
+ return state.id;
616
+ },
617
+ get code() {
618
+ return state.code;
619
+ },
620
+ get hostId() {
621
+ return state.hostId;
622
+ },
623
+ get isHost() {
624
+ return state.hostId === options.playerId;
625
+ },
626
+ get maxPlayers() {
627
+ return state.maxPlayers;
628
+ },
629
+ get locked() {
630
+ return state.locked;
631
+ },
632
+ get listed() {
633
+ return state.listed;
634
+ },
635
+ get data() {
636
+ return state.data;
637
+ },
638
+ get members() {
639
+ return state.members;
640
+ },
641
+ get active() {
642
+ return active;
643
+ },
644
+ send(event, data, sendOptions) {
645
+ if (!active || socket?.readyState !== WebSocket.OPEN || status !== "connected")
646
+ return false;
647
+ socket.send(JSON.stringify({
648
+ op: "room.send",
649
+ event,
650
+ data,
651
+ ...(sendOptions?.to !== undefined && { to: sendOptions.to }),
652
+ }));
653
+ return true;
654
+ },
655
+ on: (event, listener) => listen(listeners, event, listener),
656
+ update: async (changes) => {
657
+ handle.reset((await request({ op: "room.update", ...changes })));
658
+ },
659
+ setMyData: async (data) => {
660
+ const member = (await request({ op: "room.me", data }));
661
+ state = {
662
+ ...state,
663
+ members: state.members.map((entry) => entry.playerId === member.playerId ? member : entry),
664
+ };
665
+ },
666
+ kick: async (playerId) => {
667
+ await request({ op: "room.kick", playerId });
668
+ },
669
+ makeHost: async (playerId) => {
670
+ handle.reset((await request({ op: "room.host", playerId })));
671
+ },
672
+ invite: (playerId) => options.invite(playerId, { room: state.code }),
673
+ leave: async () => {
674
+ if (!active)
675
+ return;
676
+ const open = socket?.readyState === WebSocket.OPEN;
677
+ handle.close("left");
678
+ if (open)
679
+ await request({ op: "room.leave" });
680
+ },
681
+ };
682
+ const replaceMember = (member) => {
683
+ const known = state.members.some((entry) => entry.playerId === member.playerId);
684
+ state = {
685
+ ...state,
686
+ members: known
687
+ ? state.members.map((entry) => (entry.playerId === member.playerId ? member : entry))
688
+ : [...state.members, member],
689
+ };
690
+ };
691
+ const handle = {
692
+ view,
693
+ get code() {
694
+ return state.code;
695
+ },
696
+ /** What you share in the room, to keep it when rejoining. */
697
+ myData() {
698
+ return state.members.find((entry) => entry.playerId === options.playerId)?.data ?? null;
699
+ },
700
+ reset(next) {
701
+ const changed = next.hostId !== state.hostId ||
702
+ next.locked !== state.locked ||
703
+ next.listed !== state.listed ||
704
+ JSON.stringify(next.data) !== JSON.stringify(state.data);
705
+ state = next;
706
+ if (changed)
707
+ emit(listeners, "update", view);
708
+ },
709
+ apply(message) {
710
+ if (!active)
711
+ return;
712
+ switch (message.type) {
713
+ case "room.message":
714
+ emit(listeners, "message", {
715
+ from: message.from,
716
+ event: message.event,
717
+ data: message.data,
718
+ });
719
+ break;
720
+ case "room.member_joined":
721
+ replaceMember(message.member);
722
+ emit(listeners, "join", message.member);
723
+ break;
724
+ case "room.member_updated":
725
+ replaceMember(message.member);
726
+ emit(listeners, "member", message.member);
727
+ break;
728
+ case "room.member_left":
729
+ state = {
730
+ ...state,
731
+ members: state.members.filter((entry) => entry.playerId !== message.playerId),
732
+ };
733
+ emit(listeners, "leave", { playerId: message.playerId, reason: message.reason });
734
+ break;
735
+ case "room.updated":
736
+ state = {
737
+ ...state,
738
+ hostId: message.hostId,
739
+ data: message.data,
740
+ locked: message.locked,
741
+ listed: message.listed,
742
+ };
743
+ emit(listeners, "update", view);
744
+ break;
745
+ case "room.left":
746
+ handle.close(message.reason === "closed" ? "connection" : message.reason);
747
+ break;
748
+ }
749
+ },
750
+ close(reason) {
751
+ if (!active)
752
+ return;
753
+ active = false;
754
+ if (current === handle)
755
+ current = null;
756
+ emit(listeners, "closed", { reason });
757
+ disconnectIfIdle();
758
+ },
759
+ };
760
+ return handle;
761
+ }
762
+ const enter = (state) => {
763
+ // Creating or joining a room left the one before.
764
+ current?.close("left");
765
+ const handle = createRoom(state);
766
+ current = handle;
767
+ return handle.view;
768
+ };
769
+ return {
770
+ rooms: {
771
+ create: async (createOptions = {}) => enter((await request({ op: "room.create", ...createOptions }))),
772
+ join: async (code, joinOptions = {}) => {
773
+ if (typeof code !== "string" || !code.trim()) {
774
+ throw new PowlyError("Give a room code.", undefined, "invalid");
775
+ }
776
+ if (current && current.code === code.replace(/[\s-]/g, "").toUpperCase()) {
777
+ return current.view;
778
+ }
779
+ return enter((await request({ op: "room.join", code, me: joinOptions.me })));
780
+ },
781
+ list: async () => {
782
+ try {
783
+ return (await request({ op: "room.list" }));
784
+ }
785
+ finally {
786
+ disconnectIfIdle();
787
+ }
788
+ },
789
+ get current() {
790
+ return current?.view ?? null;
791
+ },
792
+ get status() {
793
+ return status;
794
+ },
795
+ on: (event, listener) => listen(statusListeners, event, listener),
796
+ },
797
+ onInvite(listener) {
798
+ inviteListeners.add(listener);
799
+ connect().catch(() => { });
800
+ return () => {
801
+ inviteListeners.delete(listener);
802
+ disconnectIfIdle();
803
+ };
804
+ },
805
+ stop() {
806
+ stopped = true;
807
+ current?.close("connection");
808
+ inviteListeners.clear();
809
+ failPending("The launch has ended.");
810
+ disconnectIfIdle();
811
+ },
812
+ };
813
+ }
814
+
815
+ // Twitch chat for games that use it (`powly.twitch`): Studio turns it on per game (Configuration →
816
+ // Twitch chat) and players connect Twitch in Powly's settings. Powly tells the game which channel
817
+ // is the player's (GET /twitch); the SDK then reads that channel's chat straight from Twitch, as
818
+ // an anonymous, read-only viewer, so no Twitch credentials ever reach the game.
819
+ //
820
+ // const { status, channel } = await powly.twitch.connect();
821
+ // if (status === "connected") {
822
+ // powly.twitch.on("message", (message) => {
823
+ // if (message.text === "!jump") player.jump();
824
+ // });
825
+ // }
826
+ //
827
+ // Chat is Twitch IRC over a WebSocket (https://dev.twitch.tv/docs/chat/irc/). The connection comes
828
+ // back by itself when it drops, and stops when the launch ends.
829
+ const TWITCH_CHAT_URL = "wss://irc-ws.chat.twitch.tv:443";
830
+ /** What a tag value's escapes stand for (anything else escaped is itself). */
831
+ const TAG_ESCAPES = { s: " ", ":": ";", r: "\r", n: "\n" };
832
+ const unescapeTag = (value) => value.replace(/\\(.)?/g, (_, char) => TAG_ESCAPES[char ?? ""] ?? char ?? "");
833
+ /** One IRC line (without its CRLF), or null when it isn't one. */
834
+ function parseIrcLine(line) {
835
+ let rest = line;
836
+ const tags = {};
837
+ if (rest.startsWith("@")) {
838
+ const end = rest.indexOf(" ");
839
+ if (end < 0)
840
+ return null;
841
+ for (const pair of rest.slice(1, end).split(";")) {
842
+ const eq = pair.indexOf("=");
843
+ const key = eq < 0 ? pair : pair.slice(0, eq);
844
+ if (key)
845
+ tags[key] = eq < 0 ? "" : unescapeTag(pair.slice(eq + 1));
846
+ }
847
+ rest = rest.slice(end + 1).trimStart();
848
+ }
849
+ let nick = null;
850
+ if (rest.startsWith(":")) {
851
+ const end = rest.indexOf(" ");
852
+ if (end < 0)
853
+ return null;
854
+ const prefix = rest.slice(1, end);
855
+ nick = prefix.includes("!") ? prefix.slice(0, prefix.indexOf("!")) : prefix;
856
+ rest = rest.slice(end + 1).trimStart();
857
+ }
858
+ const params = [];
859
+ const trailingAt = rest.indexOf(" :");
860
+ const head = trailingAt < 0 ? rest : rest.slice(0, trailingAt);
861
+ const words = head.split(" ").filter(Boolean);
862
+ const command = words.shift();
863
+ if (!command)
864
+ return null;
865
+ params.push(...words);
866
+ if (trailingAt >= 0)
867
+ params.push(rest.slice(trailingAt + 2));
868
+ return { tags, nick, command: command.toUpperCase(), params };
869
+ }
870
+ /** "/me" messages arrive wrapped in CTCP: "\u0001ACTION text\u0001". */
871
+ const CTCP = String.fromCharCode(1);
872
+ const actionText = (text) => text.startsWith(`${CTCP}ACTION `) && text.endsWith(CTCP) && text.length > 9
873
+ ? text.slice(8, -1)
874
+ : null;
875
+ /** A PRIVMSG as a chat message, or null. */
876
+ function chatMessageOf(message) {
877
+ if (message.command !== "PRIVMSG" || message.params.length < 2)
878
+ return null;
879
+ const { tags } = message;
880
+ const raw = message.params[1] ?? "";
881
+ const action = actionText(raw);
882
+ const badges = {};
883
+ for (const badge of (tags.badges ?? "").split(",")) {
884
+ const slash = badge.indexOf("/");
885
+ if (slash > 0)
886
+ badges[badge.slice(0, slash)] = badge.slice(slash + 1);
887
+ }
888
+ const login = (message.nick ?? "").toLowerCase();
889
+ const sentAt = Number(tags["tmi-sent-ts"]);
890
+ return {
891
+ id: tags.id ?? "",
892
+ userId: tags["user-id"] ?? "",
893
+ login,
894
+ displayName: tags["display-name"] || login,
895
+ color: /^#[0-9A-Fa-f]{6}$/.test(tags.color ?? "") ? tags.color : null,
896
+ text: action ?? raw,
897
+ action: action !== null,
898
+ bits: Number(tags.bits) > 0 ? Number(tags.bits) : 0,
899
+ badges,
900
+ broadcaster: "broadcaster" in badges,
901
+ moderator: tags.mod === "1" || "moderator" in badges,
902
+ subscriber: tags.subscriber === "1" || "subscriber" in badges || "founder" in badges,
903
+ vip: tags.vip === "1" || "vip" in badges,
904
+ firstMessage: tags["first-msg"] === "1",
905
+ sentAt: new Date(Number.isFinite(sentAt) && sentAt > 0 ? sentAt : Date.now()).toISOString(),
906
+ };
907
+ }
908
+ // ── Client ───────────────────────────────────────────────────────────────────
909
+ const RETRY_MS = [1_000, 2_000, 5_000, 10_000, 30_000];
910
+ /** Twitch pings every few minutes; silence for longer than this means the connection is gone. */
911
+ const SILENCE_MS = 6 * 60_000;
912
+ function createTwitch(options) {
913
+ const url = options.url ?? TWITCH_CHAT_URL;
914
+ const listeners = new Map();
915
+ let state = "idle";
916
+ let channel = null;
917
+ let socket = null;
918
+ let attempt = 0;
919
+ let retryTimer = null;
920
+ let silenceTimer = null;
921
+ /** Resolves `connect()` once the channel is joined (or the first try failed). */
922
+ let joined = null;
923
+ let stopped = false;
924
+ const emit = (event, value) => {
925
+ for (const listener of listeners.get(event) ?? []) {
926
+ try {
927
+ listener(value);
928
+ }
929
+ catch (error) {
930
+ console.error("[powly] a Twitch listener failed", error);
931
+ }
932
+ }
933
+ };
934
+ const setState = (next) => {
935
+ if (state === next)
936
+ return;
937
+ state = next;
938
+ emit("state", next);
939
+ };
940
+ const settle = () => {
941
+ joined?.();
942
+ joined = null;
943
+ };
944
+ const clearTimers = () => {
945
+ if (retryTimer)
946
+ clearTimeout(retryTimer);
947
+ if (silenceTimer)
948
+ clearTimeout(silenceTimer);
949
+ retryTimer = null;
950
+ silenceTimer = null;
951
+ };
952
+ const close = () => {
953
+ clearTimers();
954
+ const open = socket;
955
+ socket = null;
956
+ if (open) {
957
+ open.onopen = open.onmessage = open.onclose = open.onerror = null;
958
+ try {
959
+ open.close();
960
+ }
961
+ catch {
962
+ // Already closed.
963
+ }
964
+ }
965
+ };
966
+ const watchSilence = () => {
967
+ if (silenceTimer)
968
+ clearTimeout(silenceTimer);
969
+ silenceTimer = setTimeout(() => reconnect(), SILENCE_MS);
970
+ };
971
+ /** Opens chat again after a pause (longer each try in a row), or at once (`now`). */
972
+ const reconnect = (now = false) => {
973
+ close();
974
+ if (stopped || !channel || state === "closed" || state === "idle")
975
+ return;
976
+ setState("reconnecting");
977
+ settle();
978
+ const delay = now ? 0 : (RETRY_MS[Math.min(attempt, RETRY_MS.length - 1)] ?? 30_000);
979
+ attempt += 1;
980
+ retryTimer = setTimeout(open, delay);
981
+ };
982
+ const handle = (message, ws) => {
983
+ switch (message.command) {
984
+ case "PING":
985
+ ws.send(`PONG :${message.params[0] ?? "tmi.twitch.tv"}`);
986
+ return;
987
+ case "RECONNECT":
988
+ // Twitch is restarting the server: come back right away.
989
+ reconnect(true);
990
+ return;
991
+ case "ROOMSTATE":
992
+ case "366":
993
+ attempt = 0;
994
+ setState("connected");
995
+ settle();
996
+ return;
997
+ case "PRIVMSG": {
998
+ const chat = chatMessageOf(message);
999
+ if (chat)
1000
+ emit("message", chat);
1001
+ return;
1002
+ }
1003
+ case "CLEARMSG": {
1004
+ const id = message.tags["target-msg-id"];
1005
+ if (id)
1006
+ emit("delete", { id });
1007
+ return;
1008
+ }
1009
+ case "CLEARCHAT":
1010
+ emit("clear", { userId: message.tags["target-user-id"] || null });
1011
+ return;
1012
+ }
1013
+ };
1014
+ const open = () => {
1015
+ retryTimer = null;
1016
+ if (stopped || !channel)
1017
+ return;
1018
+ const login = channel.login;
1019
+ let ws;
1020
+ try {
1021
+ ws = new WebSocket(url);
1022
+ }
1023
+ catch {
1024
+ reconnect();
1025
+ return;
1026
+ }
1027
+ socket = ws;
1028
+ ws.onopen = () => {
1029
+ // Anonymous and read-only: Twitch accepts any "justinfan" nick without a password.
1030
+ ws.send("CAP REQ :twitch.tv/tags twitch.tv/commands");
1031
+ ws.send("PASS SCHMOOPIIE");
1032
+ ws.send(`NICK justinfan${Math.floor(10_000 + Math.random() * 89_999)}`);
1033
+ ws.send(`JOIN #${login}`);
1034
+ watchSilence();
1035
+ };
1036
+ ws.onmessage = (event) => {
1037
+ if (typeof event.data !== "string")
1038
+ return;
1039
+ watchSilence();
1040
+ for (const line of event.data.split("\r\n")) {
1041
+ if (!line)
1042
+ continue;
1043
+ const message = parseIrcLine(line);
1044
+ if (message)
1045
+ handle(message, ws);
1046
+ }
1047
+ };
1048
+ ws.onclose = () => {
1049
+ if (socket === ws)
1050
+ reconnect();
1051
+ };
1052
+ ws.onerror = () => {
1053
+ if (socket === ws)
1054
+ reconnect();
1055
+ };
1056
+ };
1057
+ const disconnect = () => {
1058
+ close();
1059
+ settle();
1060
+ channel = null;
1061
+ attempt = 0;
1062
+ if (state !== "idle")
1063
+ setState("closed");
1064
+ };
1065
+ return {
1066
+ info: options.getInfo,
1067
+ async connect() {
1068
+ const info = await options.getInfo();
1069
+ const next = info.status === "connected" ? info.channel : null;
1070
+ if (!next || stopped) {
1071
+ disconnect();
1072
+ return info;
1073
+ }
1074
+ // Already reading this channel.
1075
+ if (channel?.login === next.login && socket)
1076
+ return info;
1077
+ disconnect();
1078
+ channel = next;
1079
+ setState("connecting");
1080
+ const done = new Promise((resolve) => {
1081
+ joined = resolve;
1082
+ });
1083
+ open();
1084
+ await done;
1085
+ return info;
1086
+ },
1087
+ disconnect,
1088
+ get state() {
1089
+ return state;
1090
+ },
1091
+ get channel() {
1092
+ return channel;
1093
+ },
1094
+ on(event, listener) {
1095
+ const set = listeners.get(event) ?? new Set();
1096
+ set.add(listener);
1097
+ listeners.set(event, set);
1098
+ return () => set.delete(listener);
1099
+ },
1100
+ stop() {
1101
+ stopped = true;
1102
+ disconnect();
1103
+ },
1104
+ };
1105
+ }
1106
+
1107
+
1108
+
1109
+
1110
+
1111
+
1112
+
1113
+
1114
+ // The Powly SDK for games. `Powly.init()` handshakes with the Powly page that framed the game and
1115
+ // refuses to start anywhere else; afterwards the game gets the player's profile, short-lived
1116
+ // session tokens (refreshed automatically) and the platform services.
1117
+ //
1118
+ // const powly = await Powly.init();
1119
+ // powly.profile.username;
1120
+ // await powly.leaderboards.submit("high-score", 1200);
1121
+ // const token = await powly.getSessionToken(); // send it to your backend, which verifies it
1122
+ // const room = await powly.rooms.create(); // realtime parties (src/rooms.ts)
1123
+ // await powly.twitch.connect(); // the streamer's Twitch chat (src/twitch.ts)
1124
+ //
1125
+ // Nothing here is a security boundary: the game runs in the player's browser. Your backend must
1126
+ // verify session tokens (JWKS) or exchange them, and Powly checks everything server-side.
1127
+ //
1128
+ // Powly Anti-Cheat starts with the session (src/pac.ts): the SDK reports the game's clocks,
1129
+ // replaced browser functions, automation traces and input timing to the Powly page. Runs it flags
1130
+ // can't submit scores, unlock achievements or write saves.
1131
+ //
1132
+ // Saves are created in Studio (game → Saves); other keys and values of another type can't be
1133
+ // written.
1134
+ // Saves and leaderboard scores are written by your backend by default (an access token from the
1135
+ // token exchange), because players can change anything the browser sends. `saves.set`/`delete`
1136
+ // only work on saves the studio lets the browser write, and `leaderboards.submit` only on boards created with `clientSubmissions: true`; otherwise they
1137
+ // fail with a 403 PowlyError. Reading saves and leaderboards always works.
1138
+
1139
+ function expectedOrigins(options) {
1140
+ const configured = options.powlyOrigin;
1141
+ if (configured)
1142
+ return (Array.isArray(configured) ? configured : [configured]).map(originOf);
1143
+ // The launch URL can't add an origin (anyone can write one): it only narrows the built-in list.
1144
+ const fromUrl = originOf(new URL(location.href).searchParams.get("powly_origin") ?? "");
1145
+ const known = POWLY_ORIGINS.map(originOf);
1146
+ const candidates = known.includes(fromUrl) ? [fromUrl] : known;
1147
+ // Where the browser tells us who our parent really is, it must be one of them.
1148
+ const parent = location.ancestorOrigins?.[0];
1149
+ return parent ? candidates.filter((origin) => origin === parent) : candidates;
1150
+ }
1151
+ function originOf(value) {
1152
+ try {
1153
+ return new URL(value).origin;
1154
+ }
1155
+ catch {
1156
+ return "";
1157
+ }
1158
+ }
1159
+ /** Starts the SDK. Rejects when the game wasn't launched from Powly. */
1160
+ function init(options = {}) {
1161
+ if (window.parent === window) {
1162
+ return Promise.reject(new PowlyError("This game only runs when launched from Powly."));
1163
+ }
1164
+ const origins = expectedOrigins(options).filter(Boolean);
1165
+ if (origins.length === 0) {
1166
+ return Promise.reject(new PowlyError("This game only runs when launched from Powly."));
1167
+ }
1168
+ return new Promise((resolve, reject) => {
1169
+ const channels = origins.map((origin) => {
1170
+ const channel = new MessageChannel();
1171
+ const message = {
1172
+ protocol: PROTOCOL,
1173
+ version: PROTOCOL_VERSION,
1174
+ type: "init",
1175
+ sdkVersion: SDK_VERSION,
1176
+ pac: 1,
1177
+ };
1178
+ channel.port1.onmessage = (event) => {
1179
+ const data = event.data;
1180
+ if (data?.type === "ready") {
1181
+ clearTimeout(timer);
1182
+ for (const other of channels)
1183
+ if (other !== channel)
1184
+ other.port1.close();
1185
+ resolve(createSession(channel.port1, data));
1186
+ }
1187
+ else if (data?.type === "failed") {
1188
+ clearTimeout(timer);
1189
+ reject(new PowlyError(data.error));
1190
+ }
1191
+ };
1192
+ // targetOrigin: the port is only delivered if the parent really is this Powly origin.
1193
+ window.parent.postMessage(message, origin, [channel.port2]);
1194
+ return channel;
1195
+ });
1196
+ const timer = setTimeout(() => {
1197
+ for (const channel of channels)
1198
+ channel.port1.close();
1199
+ reject(new PowlyError("Powly didn’t answer. This game only runs when launched from Powly."));
1200
+ }, options.timeoutMs ?? 10_000);
1201
+ });
1202
+ }
1203
+ function createSession(port, ready) {
1204
+ let token = ready.token;
1205
+ let expiresAt = Date.now() + ready.expiresIn * 1000;
1206
+ let refreshing = null;
1207
+ let nextId = 1;
1208
+ const pending = new Map();
1209
+ const listeners = new Map();
1210
+ port.onmessage = (event) => {
1211
+ const data = event.data;
1212
+ if (data?.type === "response") {
1213
+ const waiting = pending.get(data.id);
1214
+ if (!waiting)
1215
+ return;
1216
+ pending.delete(data.id);
1217
+ if (data.ok)
1218
+ waiting.resolve(data.result);
1219
+ else
1220
+ waiting.reject(new PowlyError(data.error));
1221
+ }
1222
+ else if (data?.type === "event") {
1223
+ for (const listener of listeners.get(data.event) ?? [])
1224
+ listener();
1225
+ }
1226
+ };
1227
+ const call = (method, extra = {}) => new Promise((resolve, reject) => {
1228
+ const id = nextId++;
1229
+ pending.set(id, { resolve, reject });
1230
+ port.postMessage({ type: "request", id, method, ...extra });
1231
+ });
1232
+ const getSessionToken = async () => {
1233
+ if (expiresAt - Date.now() > 10_000)
1234
+ return token;
1235
+ refreshing ??= call("getSessionToken")
1236
+ .then((result) => {
1237
+ const fresh = result;
1238
+ token = fresh.token;
1239
+ expiresAt = Date.now() + fresh.expiresIn * 1000;
1240
+ return token;
1241
+ })
1242
+ .finally(() => {
1243
+ refreshing = null;
1244
+ });
1245
+ return refreshing;
1246
+ };
1247
+ // Keep a token ready, so calls don't wait for a refresh.
1248
+ const refreshTimer = setInterval(() => {
1249
+ if (expiresAt - Date.now() < 20_000)
1250
+ getSessionToken().catch(() => { });
1251
+ }, 5_000);
1252
+ const stopPac = startPacSensor(port, { guard: ready.pac?.guard === true });
1253
+ listeners.set("ended", new Set([() => clearInterval(refreshTimer), stopPac]));
1254
+ const api = async (path, init = {}) => {
1255
+ const response = await fetch(`${ready.apiBase}${path}`, {
1256
+ method: init.method ?? "GET",
1257
+ headers: {
1258
+ authorization: `Bearer ${await getSessionToken()}`,
1259
+ ...(init.body !== undefined && { "content-type": "application/json" }),
1260
+ },
1261
+ body: init.body === undefined ? undefined : JSON.stringify(init.body),
1262
+ credentials: "omit",
1263
+ });
1264
+ const data = response.status === 204 ? null : await response.json().catch(() => null);
1265
+ if (!response.ok) {
1266
+ const message = data && typeof data === "object" && "error" in data && typeof data.error === "string"
1267
+ ? data.error
1268
+ : `Request failed (${response.status})`;
1269
+ throw new PowlyError(message, response.status);
1270
+ }
1271
+ return data;
1272
+ };
1273
+ const path = (value) => encodeURIComponent(value);
1274
+ const sendInvite = async (playerId, params = {}) => {
1275
+ await api("/invites", { method: "POST", body: { to: playerId, params } });
1276
+ };
1277
+ const realtime = createRooms({
1278
+ playerId: ready.profile.playerId,
1279
+ getTicket: () => api("/realtime", { method: "POST" }),
1280
+ invite: sendInvite,
1281
+ });
1282
+ listeners.get("ended")?.add(() => realtime.stop());
1283
+ const twitch = createTwitch({ getInfo: () => api("/twitch") });
1284
+ listeners.get("ended")?.add(() => twitch.stop());
1285
+ const saves = {
1286
+ get: async (key) => {
1287
+ try {
1288
+ return (await api(`/saves/${path(key)}`)).value;
1289
+ }
1290
+ catch (error) {
1291
+ if (error instanceof PowlyError && error.status === 404)
1292
+ return null;
1293
+ throw error;
1294
+ }
1295
+ },
1296
+ all: async () => (await api("/saves?values=1")).values,
1297
+ set: async (key, value) => {
1298
+ await api(`/saves/${path(key)}`, { method: "PUT", body: { value } });
1299
+ },
1300
+ delete: async (key) => {
1301
+ await api(`/saves/${path(key)}`, { method: "DELETE" });
1302
+ },
1303
+ list: async () => (await api("/saves")).keys,
1304
+ };
1305
+ return {
1306
+ profile: ready.profile,
1307
+ scopes: ready.scopes,
1308
+ launchParams: ready.params,
1309
+ launchId: ready.launchId,
1310
+ apiBase: ready.apiBase,
1311
+ getSessionToken,
1312
+ api,
1313
+ friends: { list: async () => (await api("/friends")).friends },
1314
+ presence: {
1315
+ set: async (text) => {
1316
+ await api("/presence", { method: "PUT", body: { text } });
1317
+ },
1318
+ },
1319
+ invites: {
1320
+ list: async () => (await api("/invites")).invites,
1321
+ send: sendInvite,
1322
+ on: (_event, listener) => realtime.onInvite(listener),
1323
+ open: async (params) => {
1324
+ // Inviting to the room the player is in: who's already there can't be invited to it.
1325
+ const room = realtime.rooms.current;
1326
+ const members = room && params?.room === room.code ? room.members.map((member) => member.playerId) : [];
1327
+ return (await call("invites.open", { params, members }));
1328
+ },
1329
+ },
1330
+ rooms: realtime.rooms,
1331
+ twitch: {
1332
+ info: twitch.info,
1333
+ connect: twitch.connect,
1334
+ disconnect: twitch.disconnect,
1335
+ get state() {
1336
+ return twitch.state;
1337
+ },
1338
+ get channel() {
1339
+ return twitch.channel;
1340
+ },
1341
+ on: twitch.on,
1342
+ },
1343
+ leaderboards: {
1344
+ submit: (board, score) => api(`/leaderboards/${path(board)}/scores`, { method: "POST", body: { score } }),
1345
+ top: async (board, limit = 10) => (await api(`/leaderboards/${path(board)}?limit=${limit}`))
1346
+ .entries,
1347
+ },
1348
+ saves,
1349
+ achievements: {
1350
+ list: async () => (await api("/achievements")).achievements,
1351
+ unlock: (key) => api("/achievements", { method: "POST", body: { key } }),
1352
+ },
1353
+ settings: { get: saves.get, all: saves.all, set: saves.set, reset: saves.delete },
1354
+ ownership: { get: () => api("/ownership") },
1355
+ purchases: {
1356
+ open: async () => {
1357
+ await call("purchases.open");
1358
+ },
1359
+ },
1360
+ fullscreen: {
1361
+ toggle: async () => {
1362
+ await call("fullscreen.toggle");
1363
+ },
1364
+ },
1365
+ on(event, listener) {
1366
+ const set = listeners.get(event) ?? new Set();
1367
+ set.add(listener);
1368
+ listeners.set(event, set);
1369
+ return () => set.delete(listener);
1370
+ },
1371
+ };
1372
+ }
1373
+ const Powly = { init, SDK_VERSION, PowlyError };
1374
+
1375
+ window.Powly = Powly;
1376
+ })();