@looplay/sdk 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +86 -1
- package/dist/looplay-sdk.cjs.js +330 -32
- package/dist/looplay-sdk.cjs.js.map +1 -1
- package/dist/looplay-sdk.esm.js +330 -33
- package/dist/looplay-sdk.esm.js.map +1 -1
- package/dist/looplay-sdk.min.js +3 -1
- package/dist/looplay-sdk.min.js.map +1 -1
- package/dist/types/apps/api-client.d.ts +13 -0
- package/dist/types/apps/http.d.ts +2 -0
- package/dist/types/apps/service-client.d.ts +14 -4
- package/dist/types/auth/jwt.d.ts +2 -0
- package/dist/types/iframe/iframe-auth.d.ts +58 -0
- package/dist/types/iframe/index.d.ts +2 -0
- package/dist/types/iframe/types.d.ts +48 -0
- package/dist/types/index.d.ts +3 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,3 +1,88 @@
|
|
|
1
1
|
# Looplay
|
|
2
2
|
|
|
3
|
-
API Reference: https://docs.looplay.gg/build-on-loopplay/looplay-sdk
|
|
3
|
+
API Reference: https://docs.looplay.gg/build-on-loopplay/looplay-sdk
|
|
4
|
+
|
|
5
|
+
## Tracking a game — hosted on Looplay or published anywhere else
|
|
6
|
+
|
|
7
|
+
`LooplayIframeAuth` is the single class for tracking, in two modes that the
|
|
8
|
+
**same game code** doesn't need to branch on:
|
|
9
|
+
|
|
10
|
+
1. **Embedded in the Looplay web app's iframe.** The host page pushes
|
|
11
|
+
`{ type: 'LOOPLAY_AUTH', jwt, gameId, apiUrl }` via postMessage once the
|
|
12
|
+
game requests it. Tracking calls use the real user's bearer token — full
|
|
13
|
+
tracking, eligible for quest/reward.
|
|
14
|
+
2. **Standalone — published anywhere else** (your own domain, itch.io, a
|
|
15
|
+
non-Vite build, ...), or the iframe parent never responds. The SDK falls
|
|
16
|
+
back to a locally-generated, `localStorage`-persisted anonymous device
|
|
17
|
+
id. Tracking still works and counts toward play/analytics stats, but
|
|
18
|
+
**never** feeds the quest/reward engine — there's no real account to
|
|
19
|
+
award.
|
|
20
|
+
|
|
21
|
+
Either way you need an **`appId`**: register your game with a Looplay
|
|
22
|
+
creator account first — anonymous tracking is rejected for games without one
|
|
23
|
+
(authenticated tracking inside the iframe still works without it, for
|
|
24
|
+
backward compatibility, but you should set one regardless).
|
|
25
|
+
|
|
26
|
+
```ts
|
|
27
|
+
import { LooplayIframeAuth } from '@looplay/sdk';
|
|
28
|
+
|
|
29
|
+
export const looplayAuth = new LooplayIframeAuth({
|
|
30
|
+
appId: 'YOUR_APP_ID', // from your creator dashboard
|
|
31
|
+
apiUrl: 'https://api.looplay.gg', // required for standalone builds; optional inside the iframe
|
|
32
|
+
parentOrigin: import.meta.env.VITE_PARENT_ORIGIN, // restrict the accepted postMessage origin
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
looplayAuth.init();
|
|
36
|
+
looplayAuth.initLifecycleTracking(); // auto-emits GAME_STARTED once per session
|
|
37
|
+
|
|
38
|
+
// Anywhere in the game — identical code whether hosted on Looplay or not:
|
|
39
|
+
looplayAuth.canTrack();
|
|
40
|
+
looplayAuth.subscribe((state) => { /* re-render when auth arrives/clears */ });
|
|
41
|
+
await looplayAuth.trackPlay(playTimeSeconds);
|
|
42
|
+
await looplayAuth.trackMatch(matchId, { durationSeconds, isWin: true });
|
|
43
|
+
await looplayAuth.emitGameEvent('CUSTOM_ACTION', { value: 1 });
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
### Drop-in `<script>` tag (no build step required)
|
|
47
|
+
|
|
48
|
+
Games not built from a Looplay template — plain HTML5, Construct, GameMaker
|
|
49
|
+
exports, a Unity WebGL wrapper page, etc. — can use the browser (IIFE) bundle
|
|
50
|
+
directly, no bundler needed:
|
|
51
|
+
|
|
52
|
+
```html
|
|
53
|
+
<script src="https://unpkg.com/@looplay/sdk/browser"></script>
|
|
54
|
+
<script>
|
|
55
|
+
const looplayAuth = new LooplaySDK.LooplayIframeAuth({
|
|
56
|
+
appId: 'YOUR_APP_ID',
|
|
57
|
+
apiUrl: 'https://api.looplay.gg',
|
|
58
|
+
});
|
|
59
|
+
looplayAuth.init();
|
|
60
|
+
looplayAuth.initLifecycleTracking();
|
|
61
|
+
|
|
62
|
+
// call looplayAuth.trackPlay(...) / trackMatch(...) / emitGameEvent(...) from your game code
|
|
63
|
+
</script>
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
**Security note**: the origin check relies on `parentOrigin` (explicit option)
|
|
67
|
+
or `document.referrer`. Referrer can be stripped by `Referrer-Policy`, browser
|
|
68
|
+
privacy settings, or extensions — when that happens the check is skipped and
|
|
69
|
+
`LooplayIframeAuth` logs a `console.warn`. Always pass `parentOrigin` explicitly
|
|
70
|
+
in production embeds instead of relying on the referrer fallback.
|
|
71
|
+
|
|
72
|
+
**Lifecycle**: call `dispose()` when tearing down (route change, HMR, test
|
|
73
|
+
cleanup) to remove the `message` listener and clear subscribers:
|
|
74
|
+
|
|
75
|
+
```ts
|
|
76
|
+
looplayAuth.dispose();
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## Auth storage tradeoff
|
|
80
|
+
|
|
81
|
+
`LooplayAuth` (used by `TelegramAuthProvider` and other explicit-login
|
|
82
|
+
providers) defaults to in-memory session storage — nothing persists across a
|
|
83
|
+
reload unless you opt in. `BrowserLocalStorageAuthStorage` persists the
|
|
84
|
+
session (including the refresh token) in `localStorage`, which is convenient
|
|
85
|
+
but readable by any script on the page (XSS exposure). Prefer it only for
|
|
86
|
+
games where that tradeoff is acceptable; for higher-security needs, keep the
|
|
87
|
+
default in-memory storage or implement an `AuthStorage` backed by a more
|
|
88
|
+
restrictive mechanism.
|
package/dist/looplay-sdk.cjs.js
CHANGED
|
@@ -140,6 +140,15 @@ var LooplayAuth = class {
|
|
|
140
140
|
};
|
|
141
141
|
|
|
142
142
|
// src/apps/http.ts
|
|
143
|
+
function getFetchFn(fetchOverride) {
|
|
144
|
+
if (fetchOverride) {
|
|
145
|
+
if (typeof window !== "undefined" && fetchOverride === window.fetch) {
|
|
146
|
+
return window.fetch.bind(window);
|
|
147
|
+
}
|
|
148
|
+
return fetchOverride;
|
|
149
|
+
}
|
|
150
|
+
return globalThis.fetch.bind(globalThis);
|
|
151
|
+
}
|
|
143
152
|
var HttpError = class extends Error {
|
|
144
153
|
status;
|
|
145
154
|
bodyText;
|
|
@@ -156,7 +165,7 @@ var HttpClient = class {
|
|
|
156
165
|
defaultHeaders;
|
|
157
166
|
constructor(options) {
|
|
158
167
|
this.baseUrl = options.baseUrl.replace(/\/$/, "");
|
|
159
|
-
this.fetchFn = options.fetch
|
|
168
|
+
this.fetchFn = getFetchFn(options.fetch);
|
|
160
169
|
this.defaultHeaders = options.defaultHeaders ?? {};
|
|
161
170
|
}
|
|
162
171
|
async request(method, path, options) {
|
|
@@ -167,6 +176,8 @@ var HttpClient = class {
|
|
|
167
176
|
);
|
|
168
177
|
if (options?.bearerToken) {
|
|
169
178
|
headers.Authorization = `Bearer ${options.bearerToken}`;
|
|
179
|
+
} else if (options?.anonId) {
|
|
180
|
+
headers["x-looplay-anon-id"] = options.anonId;
|
|
170
181
|
}
|
|
171
182
|
let body;
|
|
172
183
|
if (options?.body !== void 0) {
|
|
@@ -248,24 +259,24 @@ var ServiceClient = class {
|
|
|
248
259
|
async listRecentPlayed(bearerToken, query) {
|
|
249
260
|
return this.http.request("GET", "/games/recent-play", { bearerToken, query });
|
|
250
261
|
}
|
|
251
|
-
async trackPlay(
|
|
262
|
+
async trackPlay(auth, gameId, playTimeSeconds) {
|
|
252
263
|
const body = { playTimeSeconds };
|
|
253
264
|
await this.http.request("POST", `/games/${encodeURIComponent(gameId)}/play`, {
|
|
254
|
-
|
|
265
|
+
...auth,
|
|
255
266
|
body
|
|
256
267
|
});
|
|
257
268
|
return true;
|
|
258
269
|
}
|
|
259
|
-
async trackMatchEnd(
|
|
270
|
+
async trackMatchEnd(auth, gameId, body) {
|
|
260
271
|
await this.http.request("POST", `/games/${encodeURIComponent(gameId)}/end`, {
|
|
261
|
-
|
|
272
|
+
...auth,
|
|
262
273
|
body
|
|
263
274
|
});
|
|
264
275
|
return true;
|
|
265
276
|
}
|
|
266
|
-
async emitGameEvent(
|
|
277
|
+
async emitGameEvent(auth, gameId, body) {
|
|
267
278
|
return this.http.request("POST", `/games/${encodeURIComponent(gameId)}/emit`, {
|
|
268
|
-
|
|
279
|
+
...auth,
|
|
269
280
|
body
|
|
270
281
|
});
|
|
271
282
|
}
|
|
@@ -338,6 +349,7 @@ var ServiceClient = class {
|
|
|
338
349
|
var ApiClient = class {
|
|
339
350
|
raw;
|
|
340
351
|
getAccessToken;
|
|
352
|
+
getAnonymousId;
|
|
341
353
|
constructor(options) {
|
|
342
354
|
if (!options.baseUrl) throw new MissingBaseUrlError();
|
|
343
355
|
this.raw = new ServiceClient({
|
|
@@ -346,6 +358,7 @@ var ApiClient = class {
|
|
|
346
358
|
defaultHeaders: options.defaultHeaders
|
|
347
359
|
});
|
|
348
360
|
this.getAccessToken = options.getAccessToken;
|
|
361
|
+
this.getAnonymousId = options.getAnonymousId;
|
|
349
362
|
}
|
|
350
363
|
/** Expose the underlying route-level client (requires manual bearerToken passing). */
|
|
351
364
|
unsafeRaw() {
|
|
@@ -361,8 +374,20 @@ var ApiClient = class {
|
|
|
361
374
|
if (!token) throw new NotAuthenticatedError();
|
|
362
375
|
return token;
|
|
363
376
|
}
|
|
377
|
+
/**
|
|
378
|
+
* Resolves auth for the tracking endpoints: a real bearer token if logged
|
|
379
|
+
* in, otherwise an anonymous device id. Throws only when neither is
|
|
380
|
+
* available — anonymous tracking still requires *some* identity.
|
|
381
|
+
*/
|
|
382
|
+
async resolveTrackingAuth() {
|
|
383
|
+
const bearerToken = await this.getAccessToken?.();
|
|
384
|
+
if (bearerToken) return { bearerToken };
|
|
385
|
+
const anonId = this.getAnonymousId?.();
|
|
386
|
+
if (anonId) return { anonId };
|
|
387
|
+
throw new NotAuthenticatedError();
|
|
388
|
+
}
|
|
364
389
|
async getGameDetail(gameId) {
|
|
365
|
-
const token = await this.
|
|
390
|
+
const token = await this.getAccessToken?.();
|
|
366
391
|
return this.raw.getGameDetail(gameId, token);
|
|
367
392
|
}
|
|
368
393
|
async listRecentPlayed(query) {
|
|
@@ -370,22 +395,22 @@ var ApiClient = class {
|
|
|
370
395
|
return this.raw.listRecentPlayed(token, query);
|
|
371
396
|
}
|
|
372
397
|
async trackPlay(gameId, playTimeSeconds) {
|
|
373
|
-
const
|
|
374
|
-
return this.raw.trackPlay(
|
|
398
|
+
const auth = await this.resolveTrackingAuth();
|
|
399
|
+
return this.raw.trackPlay(auth, gameId, playTimeSeconds);
|
|
375
400
|
}
|
|
376
401
|
async trackMatch(gameId, body) {
|
|
377
|
-
const
|
|
378
|
-
return this.raw.trackMatchEnd(
|
|
402
|
+
const auth = await this.resolveTrackingAuth();
|
|
403
|
+
return this.raw.trackMatchEnd(auth, gameId, body);
|
|
379
404
|
}
|
|
380
405
|
async emit(gameId, actionCode, opts) {
|
|
381
|
-
const
|
|
406
|
+
const auth = await this.resolveTrackingAuth();
|
|
382
407
|
const body = {
|
|
383
408
|
actionCode,
|
|
384
409
|
value: opts?.value,
|
|
385
410
|
refId: opts?.refId,
|
|
386
411
|
payload: opts?.payload
|
|
387
412
|
};
|
|
388
|
-
return this.raw.emitGameEvent(
|
|
413
|
+
return this.raw.emitGameEvent(auth, gameId, body);
|
|
389
414
|
}
|
|
390
415
|
async getMyProfile() {
|
|
391
416
|
const token = await this.requireToken();
|
|
@@ -452,14 +477,14 @@ var LooplaySDK = class {
|
|
|
452
477
|
gameId;
|
|
453
478
|
initialized = false;
|
|
454
479
|
constructor(options = {}) {
|
|
455
|
-
const { auth, baseUrl, fetch
|
|
480
|
+
const { auth, baseUrl, fetch, defaultHeaders } = options;
|
|
456
481
|
if (auth) {
|
|
457
482
|
this.auth = new LooplayAuth(auth);
|
|
458
483
|
}
|
|
459
484
|
if (baseUrl) {
|
|
460
485
|
this.api = new ApiClient({
|
|
461
486
|
baseUrl,
|
|
462
|
-
fetch
|
|
487
|
+
fetch,
|
|
463
488
|
defaultHeaders,
|
|
464
489
|
getAccessToken: async () => this.auth?.getAccessToken()
|
|
465
490
|
});
|
|
@@ -526,6 +551,26 @@ var LooplaySDK = class {
|
|
|
526
551
|
}
|
|
527
552
|
};
|
|
528
553
|
|
|
554
|
+
// src/auth/jwt.ts
|
|
555
|
+
function decodeJwtClaims(token) {
|
|
556
|
+
if (!token) return void 0;
|
|
557
|
+
const parts = token.split(".");
|
|
558
|
+
if (parts.length < 2) return void 0;
|
|
559
|
+
try {
|
|
560
|
+
const payload = parts[1].replace(/-/g, "+").replace(/_/g, "/");
|
|
561
|
+
const padded = payload + "===".slice((payload.length + 3) % 4);
|
|
562
|
+
if (typeof globalThis.atob !== "function") return void 0;
|
|
563
|
+
const json = globalThis.atob(padded);
|
|
564
|
+
return JSON.parse(json);
|
|
565
|
+
} catch {
|
|
566
|
+
return void 0;
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
function decodeJwtExpMs(token) {
|
|
570
|
+
const exp = decodeJwtClaims(token)?.exp;
|
|
571
|
+
return typeof exp === "number" ? exp * 1e3 : void 0;
|
|
572
|
+
}
|
|
573
|
+
|
|
529
574
|
// src/auth/providers/telegram-auth-provider.ts
|
|
530
575
|
var MissingTelegramInitDataError = class extends LooplaySDKError {
|
|
531
576
|
constructor() {
|
|
@@ -539,22 +584,6 @@ var MissingRefreshTokenError = class extends LooplaySDKError {
|
|
|
539
584
|
this.name = "MissingRefreshTokenError";
|
|
540
585
|
}
|
|
541
586
|
};
|
|
542
|
-
function decodeJwtExpMs(token) {
|
|
543
|
-
if (!token) return void 0;
|
|
544
|
-
const parts = token.split(".");
|
|
545
|
-
if (parts.length < 2) return void 0;
|
|
546
|
-
try {
|
|
547
|
-
const payload = parts[1].replace(/-/g, "+").replace(/_/g, "/");
|
|
548
|
-
const padded = payload + "===".slice((payload.length + 3) % 4);
|
|
549
|
-
if (typeof globalThis.atob !== "function") return void 0;
|
|
550
|
-
const json = globalThis.atob(padded);
|
|
551
|
-
const parsed = JSON.parse(json);
|
|
552
|
-
if (typeof parsed.exp !== "number") return void 0;
|
|
553
|
-
return parsed.exp * 1e3;
|
|
554
|
-
} catch {
|
|
555
|
-
return void 0;
|
|
556
|
-
}
|
|
557
|
-
}
|
|
558
587
|
var TelegramAuthProvider = class {
|
|
559
588
|
id = "telegram";
|
|
560
589
|
client;
|
|
@@ -620,11 +649,280 @@ var TelegramAuthProvider = class {
|
|
|
620
649
|
}
|
|
621
650
|
};
|
|
622
651
|
|
|
652
|
+
// src/iframe/iframe-auth.ts
|
|
653
|
+
var DEFAULT_ANON_ID_STORAGE_KEY = "looplay:anon-id";
|
|
654
|
+
var DEFAULT_ANONYMOUS_FALLBACK_TIMEOUT_MS = 4e3;
|
|
655
|
+
var INITIAL_STATE = {
|
|
656
|
+
jwt: null,
|
|
657
|
+
gameId: null,
|
|
658
|
+
apiUrl: null,
|
|
659
|
+
anonymousId: null,
|
|
660
|
+
origin: null,
|
|
661
|
+
receivedAt: null
|
|
662
|
+
};
|
|
663
|
+
var isRecord = (value) => typeof value === "object" && value !== null;
|
|
664
|
+
var toOptionalString = (value) => {
|
|
665
|
+
if (typeof value !== "string") return null;
|
|
666
|
+
const normalized = value.trim();
|
|
667
|
+
return normalized ? normalized : null;
|
|
668
|
+
};
|
|
669
|
+
function generateAnonymousId() {
|
|
670
|
+
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
|
671
|
+
return crypto.randomUUID();
|
|
672
|
+
}
|
|
673
|
+
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
|
|
674
|
+
const r = Math.random() * 16 | 0;
|
|
675
|
+
const v = c === "x" ? r : r & 3 | 8;
|
|
676
|
+
return v.toString(16);
|
|
677
|
+
});
|
|
678
|
+
}
|
|
679
|
+
function readStoredAnonymousId(key) {
|
|
680
|
+
if (typeof window === "undefined" || !window.localStorage) return null;
|
|
681
|
+
try {
|
|
682
|
+
return window.localStorage.getItem(key);
|
|
683
|
+
} catch {
|
|
684
|
+
return null;
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
function writeStoredAnonymousId(key, value) {
|
|
688
|
+
if (typeof window === "undefined" || !window.localStorage) return;
|
|
689
|
+
try {
|
|
690
|
+
window.localStorage.setItem(key, value);
|
|
691
|
+
} catch {
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
var LooplayIframeAuth = class {
|
|
695
|
+
state = INITIAL_STATE;
|
|
696
|
+
initialized = false;
|
|
697
|
+
lifecycleTrackingInitialized = false;
|
|
698
|
+
lastGameStartedKey = null;
|
|
699
|
+
client = null;
|
|
700
|
+
listeners = /* @__PURE__ */ new Set();
|
|
701
|
+
options;
|
|
702
|
+
messageListener = null;
|
|
703
|
+
anonymousFallbackTimer = null;
|
|
704
|
+
constructor(options = {}) {
|
|
705
|
+
this.options = options;
|
|
706
|
+
}
|
|
707
|
+
getState() {
|
|
708
|
+
return this.state;
|
|
709
|
+
}
|
|
710
|
+
getJwt() {
|
|
711
|
+
return this.state.jwt;
|
|
712
|
+
}
|
|
713
|
+
getApiUrl() {
|
|
714
|
+
return this.options.apiUrl ?? this.state.apiUrl;
|
|
715
|
+
}
|
|
716
|
+
/** Resolves to the configured `appId`, falling back to whatever the parent pushed as `gameId`. */
|
|
717
|
+
getGameId() {
|
|
718
|
+
return this.options.appId ?? this.state.gameId;
|
|
719
|
+
}
|
|
720
|
+
getAnonymousId() {
|
|
721
|
+
return this.state.anonymousId;
|
|
722
|
+
}
|
|
723
|
+
/** True once authenticated (bearer) or anonymous tracking is ready, given a configured `apiUrl`/`appId`. */
|
|
724
|
+
canTrack() {
|
|
725
|
+
if (!this.getApiUrl() || !this.getGameId()) return false;
|
|
726
|
+
return Boolean(this.state.jwt || this.state.anonymousId);
|
|
727
|
+
}
|
|
728
|
+
subscribe(listener) {
|
|
729
|
+
this.listeners.add(listener);
|
|
730
|
+
listener(this.state);
|
|
731
|
+
return () => this.listeners.delete(listener);
|
|
732
|
+
}
|
|
733
|
+
requestAuth() {
|
|
734
|
+
if (typeof window === "undefined" || window.parent === window) {
|
|
735
|
+
this.debug("standalone mode detected; auth request skipped");
|
|
736
|
+
return;
|
|
737
|
+
}
|
|
738
|
+
this.debug("requesting auth from parent", { targetOrigin: this.resolveParentOrigin() ?? "*" });
|
|
739
|
+
window.parent.postMessage({ type: "LOOPLAY_AUTH_REQUEST" }, this.resolveParentOrigin() ?? "*");
|
|
740
|
+
}
|
|
741
|
+
/** Idempotent; attaches the postMessage listener (if embedded) and arms the anonymous fallback. */
|
|
742
|
+
init() {
|
|
743
|
+
if (this.initialized || typeof window === "undefined") return;
|
|
744
|
+
this.initialized = true;
|
|
745
|
+
const isEmbedded = window.parent !== window;
|
|
746
|
+
this.debug("auth listener initialized", {
|
|
747
|
+
isIframe: isEmbedded,
|
|
748
|
+
parentOrigin: this.resolveParentOrigin()
|
|
749
|
+
});
|
|
750
|
+
if (!isEmbedded) {
|
|
751
|
+
this.activateAnonymousMode();
|
|
752
|
+
return;
|
|
753
|
+
}
|
|
754
|
+
const parentOrigin = this.resolveParentOrigin();
|
|
755
|
+
if (!parentOrigin) {
|
|
756
|
+
console.warn(
|
|
757
|
+
"[LooplaySDK] LooplayIframeAuth could not resolve a parent origin to verify postMessage against (document.referrer is empty). Auth messages from window.parent will be accepted regardless of origin. Pass `parentOrigin` explicitly to harden this."
|
|
758
|
+
);
|
|
759
|
+
}
|
|
760
|
+
this.messageListener = (event) => {
|
|
761
|
+
if (event.source !== window.parent) return;
|
|
762
|
+
if (parentOrigin && event.origin !== parentOrigin) {
|
|
763
|
+
this.debug("ignored auth message from unexpected origin", {
|
|
764
|
+
expectedOrigin: parentOrigin,
|
|
765
|
+
receivedOrigin: event.origin
|
|
766
|
+
});
|
|
767
|
+
return;
|
|
768
|
+
}
|
|
769
|
+
if (!isRecord(event.data) || event.data.type !== "LOOPLAY_AUTH") return;
|
|
770
|
+
this.handleAuthMessage(event.data, event.origin);
|
|
771
|
+
};
|
|
772
|
+
window.addEventListener("message", this.messageListener);
|
|
773
|
+
this.anonymousFallbackTimer = setTimeout(() => {
|
|
774
|
+
if (this.state.jwt) return;
|
|
775
|
+
this.activateAnonymousMode();
|
|
776
|
+
}, this.options.anonymousFallbackTimeoutMs ?? DEFAULT_ANONYMOUS_FALLBACK_TIMEOUT_MS);
|
|
777
|
+
this.requestAuth();
|
|
778
|
+
}
|
|
779
|
+
/** Removes the postMessage listener, cancels timers, and clears subscribers; safe to call multiple times. */
|
|
780
|
+
dispose() {
|
|
781
|
+
if (typeof window !== "undefined" && this.messageListener) {
|
|
782
|
+
window.removeEventListener("message", this.messageListener);
|
|
783
|
+
}
|
|
784
|
+
if (this.anonymousFallbackTimer !== null) {
|
|
785
|
+
clearTimeout(this.anonymousFallbackTimer);
|
|
786
|
+
}
|
|
787
|
+
this.messageListener = null;
|
|
788
|
+
this.anonymousFallbackTimer = null;
|
|
789
|
+
this.initialized = false;
|
|
790
|
+
this.lifecycleTrackingInitialized = false;
|
|
791
|
+
this.listeners.clear();
|
|
792
|
+
}
|
|
793
|
+
/** Lazily builds (and rebuilds on auth change) the `ApiClient` bound to the current auth mode. */
|
|
794
|
+
getClient() {
|
|
795
|
+
if (this.client) return this.client;
|
|
796
|
+
const baseUrl = this.getApiUrl();
|
|
797
|
+
this.debug("creating sdk ApiClient", { baseUrl });
|
|
798
|
+
this.client = new ApiClient({
|
|
799
|
+
baseUrl: baseUrl ?? void 0,
|
|
800
|
+
getAccessToken: async () => this.getJwt() ?? void 0,
|
|
801
|
+
getAnonymousId: () => this.getAnonymousId() ?? void 0
|
|
802
|
+
});
|
|
803
|
+
return this.client;
|
|
804
|
+
}
|
|
805
|
+
trackPlay(playTimeSeconds) {
|
|
806
|
+
const gameId = this.getGameId();
|
|
807
|
+
if (!this.canTrack() || !gameId) {
|
|
808
|
+
this.debug("trackPlay skipped; auth is not ready", { hasGameId: Boolean(gameId) });
|
|
809
|
+
return Promise.resolve(false);
|
|
810
|
+
}
|
|
811
|
+
this.debug("trackPlay dispatched", { gameId, playTimeSeconds });
|
|
812
|
+
return this.getClient().trackPlay(gameId, playTimeSeconds);
|
|
813
|
+
}
|
|
814
|
+
trackMatch(matchId, opts) {
|
|
815
|
+
const gameId = this.getGameId();
|
|
816
|
+
if (!this.canTrack() || !gameId) {
|
|
817
|
+
this.debug("trackMatch skipped; auth is not ready", { matchId });
|
|
818
|
+
return Promise.resolve(false);
|
|
819
|
+
}
|
|
820
|
+
this.debug("trackMatch dispatched", { gameId, matchId, ...opts });
|
|
821
|
+
return this.getClient().trackMatch(gameId, {
|
|
822
|
+
matchId,
|
|
823
|
+
matchDurationSeconds: opts.durationSeconds,
|
|
824
|
+
isCompleted: opts.isCompleted,
|
|
825
|
+
isWin: opts.isWin
|
|
826
|
+
});
|
|
827
|
+
}
|
|
828
|
+
emitGameEvent(actionCode, opts) {
|
|
829
|
+
const gameId = this.getGameId();
|
|
830
|
+
if (!this.canTrack() || !gameId) {
|
|
831
|
+
this.debug("emitGameEvent skipped; auth is not ready", { actionCode });
|
|
832
|
+
return Promise.resolve(null);
|
|
833
|
+
}
|
|
834
|
+
this.debug("emitGameEvent dispatched", { gameId, actionCode, ...opts });
|
|
835
|
+
return this.getClient().emit(gameId, actionCode, opts);
|
|
836
|
+
}
|
|
837
|
+
/** Idempotent; auto-emits GAME_STARTED exactly once per distinct auth session (bearer or anonymous). */
|
|
838
|
+
initLifecycleTracking() {
|
|
839
|
+
if (this.lifecycleTrackingInitialized || typeof window === "undefined") return;
|
|
840
|
+
this.lifecycleTrackingInitialized = true;
|
|
841
|
+
this.debug("lifecycle tracking initialized");
|
|
842
|
+
this.subscribe(() => {
|
|
843
|
+
const key = this.getTrackingKey();
|
|
844
|
+
if (!key || this.lastGameStartedKey === key) return;
|
|
845
|
+
this.lastGameStartedKey = key;
|
|
846
|
+
this.debug("auto GAME_STARTED dispatch");
|
|
847
|
+
void this.emitGameEvent("GAME_STARTED");
|
|
848
|
+
});
|
|
849
|
+
}
|
|
850
|
+
getTrackingKey() {
|
|
851
|
+
const gameId = this.getGameId();
|
|
852
|
+
const apiUrl = this.getApiUrl();
|
|
853
|
+
const identity = this.state.jwt ?? this.state.anonymousId;
|
|
854
|
+
if (!gameId || !apiUrl || !identity) return null;
|
|
855
|
+
return `${apiUrl}
|
|
856
|
+
${gameId}
|
|
857
|
+
${identity}`;
|
|
858
|
+
}
|
|
859
|
+
/** Generates (or restores) a persisted anonymous device id and activates anonymous tracking. */
|
|
860
|
+
activateAnonymousMode() {
|
|
861
|
+
if (this.state.anonymousId) return;
|
|
862
|
+
const key = this.options.anonymousIdStorageKey ?? DEFAULT_ANON_ID_STORAGE_KEY;
|
|
863
|
+
const anonymousId = readStoredAnonymousId(key) ?? generateAnonymousId();
|
|
864
|
+
writeStoredAnonymousId(key, anonymousId);
|
|
865
|
+
this.lastGameStartedKey = null;
|
|
866
|
+
this.setState({ ...this.state, anonymousId, receivedAt: Date.now() });
|
|
867
|
+
this.debug("anonymous tracking mode active", { anonymousId });
|
|
868
|
+
}
|
|
869
|
+
handleAuthMessage(data, origin) {
|
|
870
|
+
if (this.anonymousFallbackTimer !== null) {
|
|
871
|
+
clearTimeout(this.anonymousFallbackTimer);
|
|
872
|
+
this.anonymousFallbackTimer = null;
|
|
873
|
+
}
|
|
874
|
+
const jwt = toOptionalString(data.jwt);
|
|
875
|
+
const gameId = toOptionalString(data.gameId);
|
|
876
|
+
const apiUrl = toOptionalString(data.apiUrl);
|
|
877
|
+
if (!jwt || !gameId || !apiUrl) {
|
|
878
|
+
this.lastGameStartedKey = null;
|
|
879
|
+
this.setState({ ...this.state, jwt: null, gameId: null, apiUrl: null, origin, receivedAt: Date.now() });
|
|
880
|
+
this.debug("auth cleared by parent; falling back to anonymous tracking");
|
|
881
|
+
this.activateAnonymousMode();
|
|
882
|
+
return;
|
|
883
|
+
}
|
|
884
|
+
this.lastGameStartedKey = null;
|
|
885
|
+
this.setState({
|
|
886
|
+
jwt,
|
|
887
|
+
gameId,
|
|
888
|
+
apiUrl,
|
|
889
|
+
anonymousId: null,
|
|
890
|
+
origin,
|
|
891
|
+
receivedAt: Date.now()
|
|
892
|
+
});
|
|
893
|
+
}
|
|
894
|
+
setState(next) {
|
|
895
|
+
this.state = next;
|
|
896
|
+
this.client = null;
|
|
897
|
+
this.debug("auth state updated", {
|
|
898
|
+
gameId: this.getGameId(),
|
|
899
|
+
apiUrl: this.getApiUrl(),
|
|
900
|
+
origin: next.origin,
|
|
901
|
+
hasJwt: Boolean(next.jwt),
|
|
902
|
+
hasAnonymousId: Boolean(next.anonymousId)
|
|
903
|
+
});
|
|
904
|
+
this.listeners.forEach((listener) => listener(next));
|
|
905
|
+
}
|
|
906
|
+
resolveParentOrigin() {
|
|
907
|
+
if (this.options.parentOrigin) return this.options.parentOrigin;
|
|
908
|
+
if (typeof document === "undefined" || !document.referrer) return null;
|
|
909
|
+
try {
|
|
910
|
+
return new URL(document.referrer).origin;
|
|
911
|
+
} catch {
|
|
912
|
+
return null;
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
debug(message, details) {
|
|
916
|
+
this.options.onDebug?.(message, details);
|
|
917
|
+
}
|
|
918
|
+
};
|
|
919
|
+
|
|
623
920
|
exports.ApiClient = ApiClient;
|
|
624
921
|
exports.BrowserLocalStorageAuthStorage = BrowserLocalStorageAuthStorage;
|
|
625
922
|
exports.HttpClient = HttpClient;
|
|
626
923
|
exports.HttpError = HttpError;
|
|
627
924
|
exports.LooplayAuth = LooplayAuth;
|
|
925
|
+
exports.LooplayIframeAuth = LooplayIframeAuth;
|
|
628
926
|
exports.LooplaySDK = LooplaySDK;
|
|
629
927
|
exports.LooplaySDKError = LooplaySDKError;
|
|
630
928
|
exports.MemoryAuthStorage = MemoryAuthStorage;
|