@looplay/sdk 0.1.1 → 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 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.
@@ -176,6 +176,8 @@ var HttpClient = class {
176
176
  );
177
177
  if (options?.bearerToken) {
178
178
  headers.Authorization = `Bearer ${options.bearerToken}`;
179
+ } else if (options?.anonId) {
180
+ headers["x-looplay-anon-id"] = options.anonId;
179
181
  }
180
182
  let body;
181
183
  if (options?.body !== void 0) {
@@ -257,24 +259,24 @@ var ServiceClient = class {
257
259
  async listRecentPlayed(bearerToken, query) {
258
260
  return this.http.request("GET", "/games/recent-play", { bearerToken, query });
259
261
  }
260
- async trackPlay(bearerToken, gameId, playTimeSeconds) {
262
+ async trackPlay(auth, gameId, playTimeSeconds) {
261
263
  const body = { playTimeSeconds };
262
264
  await this.http.request("POST", `/games/${encodeURIComponent(gameId)}/play`, {
263
- bearerToken,
265
+ ...auth,
264
266
  body
265
267
  });
266
268
  return true;
267
269
  }
268
- async trackMatchEnd(bearerToken, gameId, body) {
270
+ async trackMatchEnd(auth, gameId, body) {
269
271
  await this.http.request("POST", `/games/${encodeURIComponent(gameId)}/end`, {
270
- bearerToken,
272
+ ...auth,
271
273
  body
272
274
  });
273
275
  return true;
274
276
  }
275
- async emitGameEvent(bearerToken, gameId, body) {
277
+ async emitGameEvent(auth, gameId, body) {
276
278
  return this.http.request("POST", `/games/${encodeURIComponent(gameId)}/emit`, {
277
- bearerToken,
279
+ ...auth,
278
280
  body
279
281
  });
280
282
  }
@@ -347,6 +349,7 @@ var ServiceClient = class {
347
349
  var ApiClient = class {
348
350
  raw;
349
351
  getAccessToken;
352
+ getAnonymousId;
350
353
  constructor(options) {
351
354
  if (!options.baseUrl) throw new MissingBaseUrlError();
352
355
  this.raw = new ServiceClient({
@@ -355,6 +358,7 @@ var ApiClient = class {
355
358
  defaultHeaders: options.defaultHeaders
356
359
  });
357
360
  this.getAccessToken = options.getAccessToken;
361
+ this.getAnonymousId = options.getAnonymousId;
358
362
  }
359
363
  /** Expose the underlying route-level client (requires manual bearerToken passing). */
360
364
  unsafeRaw() {
@@ -370,8 +374,20 @@ var ApiClient = class {
370
374
  if (!token) throw new NotAuthenticatedError();
371
375
  return token;
372
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
+ }
373
389
  async getGameDetail(gameId) {
374
- const token = await this.requireToken();
390
+ const token = await this.getAccessToken?.();
375
391
  return this.raw.getGameDetail(gameId, token);
376
392
  }
377
393
  async listRecentPlayed(query) {
@@ -379,22 +395,22 @@ var ApiClient = class {
379
395
  return this.raw.listRecentPlayed(token, query);
380
396
  }
381
397
  async trackPlay(gameId, playTimeSeconds) {
382
- const token = await this.requireToken();
383
- return this.raw.trackPlay(token, gameId, playTimeSeconds);
398
+ const auth = await this.resolveTrackingAuth();
399
+ return this.raw.trackPlay(auth, gameId, playTimeSeconds);
384
400
  }
385
401
  async trackMatch(gameId, body) {
386
- const token = await this.requireToken();
387
- return this.raw.trackMatchEnd(token, gameId, body);
402
+ const auth = await this.resolveTrackingAuth();
403
+ return this.raw.trackMatchEnd(auth, gameId, body);
388
404
  }
389
405
  async emit(gameId, actionCode, opts) {
390
- const token = await this.requireToken();
406
+ const auth = await this.resolveTrackingAuth();
391
407
  const body = {
392
408
  actionCode,
393
409
  value: opts?.value,
394
410
  refId: opts?.refId,
395
411
  payload: opts?.payload
396
412
  };
397
- return this.raw.emitGameEvent(token, gameId, body);
413
+ return this.raw.emitGameEvent(auth, gameId, body);
398
414
  }
399
415
  async getMyProfile() {
400
416
  const token = await this.requireToken();
@@ -535,6 +551,26 @@ var LooplaySDK = class {
535
551
  }
536
552
  };
537
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
+
538
574
  // src/auth/providers/telegram-auth-provider.ts
539
575
  var MissingTelegramInitDataError = class extends LooplaySDKError {
540
576
  constructor() {
@@ -548,22 +584,6 @@ var MissingRefreshTokenError = class extends LooplaySDKError {
548
584
  this.name = "MissingRefreshTokenError";
549
585
  }
550
586
  };
551
- function decodeJwtExpMs(token) {
552
- if (!token) return void 0;
553
- const parts = token.split(".");
554
- if (parts.length < 2) return void 0;
555
- try {
556
- const payload = parts[1].replace(/-/g, "+").replace(/_/g, "/");
557
- const padded = payload + "===".slice((payload.length + 3) % 4);
558
- if (typeof globalThis.atob !== "function") return void 0;
559
- const json = globalThis.atob(padded);
560
- const parsed = JSON.parse(json);
561
- if (typeof parsed.exp !== "number") return void 0;
562
- return parsed.exp * 1e3;
563
- } catch {
564
- return void 0;
565
- }
566
- }
567
587
  var TelegramAuthProvider = class {
568
588
  id = "telegram";
569
589
  client;
@@ -629,11 +649,280 @@ var TelegramAuthProvider = class {
629
649
  }
630
650
  };
631
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
+
632
920
  exports.ApiClient = ApiClient;
633
921
  exports.BrowserLocalStorageAuthStorage = BrowserLocalStorageAuthStorage;
634
922
  exports.HttpClient = HttpClient;
635
923
  exports.HttpError = HttpError;
636
924
  exports.LooplayAuth = LooplayAuth;
925
+ exports.LooplayIframeAuth = LooplayIframeAuth;
637
926
  exports.LooplaySDK = LooplaySDK;
638
927
  exports.LooplaySDKError = LooplaySDKError;
639
928
  exports.MemoryAuthStorage = MemoryAuthStorage;