@pixelume/sdk 0.4.0 → 0.6.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.
Files changed (35) hide show
  1. package/CHANGELOG.md +62 -1
  2. package/README.md +97 -19
  3. package/dist/core/PixelumeClient.d.ts +10 -2
  4. package/dist/core/PixelumeClient.js +20 -2
  5. package/dist/core/PixelumeClient.js.map +1 -1
  6. package/dist/core/config.d.ts +18 -1
  7. package/dist/core/config.js +99 -0
  8. package/dist/core/config.js.map +1 -1
  9. package/dist/core/errors.d.ts +5 -0
  10. package/dist/core/errors.js.map +1 -1
  11. package/dist/core/types.d.ts +78 -0
  12. package/dist/core/types.js.map +1 -1
  13. package/dist/index.d.ts +2 -1
  14. package/dist/index.js.map +1 -1
  15. package/dist/modules/analytics/AnalyticsModule.d.ts +93 -0
  16. package/dist/modules/analytics/AnalyticsModule.js +461 -0
  17. package/dist/modules/analytics/AnalyticsModule.js.map +1 -0
  18. package/dist/modules/analytics/AnalyticsProvider.d.ts +51 -0
  19. package/dist/modules/analytics/AnalyticsProvider.js +23 -0
  20. package/dist/modules/analytics/AnalyticsProvider.js.map +1 -0
  21. package/dist/modules/analytics/PostHogAnalyticsProvider.d.ts +39 -0
  22. package/dist/modules/analytics/PostHogAnalyticsProvider.js +255 -0
  23. package/dist/modules/analytics/PostHogAnalyticsProvider.js.map +1 -0
  24. package/dist/modules/analytics/events.d.ts +30 -0
  25. package/dist/modules/analytics/events.js +153 -0
  26. package/dist/modules/analytics/events.js.map +1 -0
  27. package/dist/modules/auth/AuthModule.d.ts +20 -1
  28. package/dist/modules/auth/AuthModule.js +305 -21
  29. package/dist/modules/auth/AuthModule.js.map +1 -1
  30. package/dist/ui/widget.js +15 -5
  31. package/dist/ui/widget.js.map +1 -1
  32. package/dist/version.d.ts +1 -1
  33. package/dist/version.js +1 -1
  34. package/dist/version.js.map +1 -1
  35. package/package.json +4 -1
@@ -3,6 +3,12 @@ import { PixelumeAuthError, PixelumeConfigError, isPixelumeError, } from '../../
3
3
  import { SessionManager, browserResumeSource, } from './SessionManager.js';
4
4
  const AUTHORIZATION_REQUEST_TTL_MS = 10 * 60_000;
5
5
  const CALLBACK_PARAMETERS = ['code', 'state', 'error', 'error_description'];
6
+ /** `prompt=none` answers meaning "the central session cannot authorize without a screen". */
7
+ const SESSION_ENDED_ERRORS = new Set([
8
+ 'login_required',
9
+ 'consent_required',
10
+ 'interaction_required',
11
+ ]);
6
12
  export class AuthModule {
7
13
  #http;
8
14
  #config;
@@ -13,16 +19,22 @@ export class AuthModule {
13
19
  #loginPromise = null;
14
20
  #playerPromise = null;
15
21
  #logoutPromise = null;
22
+ /** True while a callback of a silent attempt is being completed. */
23
+ #completingRestore = false;
24
+ #resume;
25
+ #stopRestoreWatch = null;
16
26
  constructor(http, config) {
17
27
  this.#http = http;
18
28
  this.#config = config;
19
29
  const browser = config.browser;
30
+ this.#resume =
31
+ browser !== undefined && typeof browser.subscribeResume === 'function'
32
+ ? (onResume) => browser.subscribeResume?.(onResume) ?? (() => undefined)
33
+ : browserResumeSource();
20
34
  this.#session = new SessionManager({
21
35
  http,
22
36
  clientId: () => this.#requireClientId(),
23
- resume: browser !== undefined && typeof browser.subscribeResume === 'function'
24
- ? (onResume) => browser.subscribeResume?.(onResume) ?? (() => undefined)
25
- : browserResumeSource(),
37
+ resume: this.#resume,
26
38
  onEnded: (error) => {
27
39
  this.#setState(anonymous('expired', error));
28
40
  },
@@ -46,6 +58,8 @@ export class AuthModule {
46
58
  }
47
59
  /** @internal Releases timers/listeners and drops credentials from memory (no revocation). */
48
60
  dispose() {
61
+ this.#stopRestoreWatch?.();
62
+ this.#stopRestoreWatch = null;
49
63
  this.#session.dispose();
50
64
  this.#listeners.clear();
51
65
  }
@@ -62,7 +76,7 @@ export class AuthModule {
62
76
  if (scope !== 'profile' && scope !== 'profile saves') {
63
77
  return Promise.reject(new PixelumeConfigError('Pixelume Auth scope must be "profile" or "profile saves"'));
64
78
  }
65
- this.#loginPromise ??= this.#doLogin(scope).finally(() => {
79
+ this.#loginPromise ??= this.#doLogin(scope, false).finally(() => {
66
80
  this.#loginPromise = null;
67
81
  });
68
82
  return this.#loginPromise;
@@ -88,40 +102,92 @@ export class AuthModule {
88
102
  if (callback !== null) {
89
103
  await this.#handleCallback(browser, callback);
90
104
  }
91
- else if (this.#config.restoreSession) {
92
- const scope = this.#readSessionHint(browser.sessionStorage);
93
- if (scope === null) {
94
- this.#setState(anonymous('visitor'));
105
+ else {
106
+ const persistent = this.#persistentStorage(browser);
107
+ if (this.#config.crossSessionRestore !== null && persistent !== null) {
108
+ await this.#restoreAcrossSessions(browser, persistent, this.#config.crossSessionRestore);
95
109
  }
96
110
  else {
97
- this.#removeSessionHint(browser.sessionStorage);
98
- await this.login({ scope });
111
+ // The feature was turned off: do not leave a stale hint behind.
112
+ if (persistent !== null)
113
+ this.#removePersistentHint(persistent);
114
+ await this.#restoreSameTab(browser);
99
115
  }
100
116
  }
101
- else {
102
- this.#setState(anonymous('visitor'));
103
- }
104
117
  }
105
118
  catch (error) {
106
119
  const failure = isPixelumeError(error)
107
120
  ? error
108
121
  : new PixelumeAuthError('browser_unavailable', { cause: error });
109
- this.#setState(anonymous('error', failure));
122
+ const offline = this.#completingRestore && (failure.kind === 'network' || failure.kind === 'timeout');
123
+ this.#setState(anonymous(offline ? 'offline' : 'error', failure));
124
+ }
125
+ finally {
126
+ this.#completingRestore = false;
110
127
  }
111
128
  return this.#state;
112
129
  }
113
- async #doLogin(scope) {
130
+ /** Behaviour without `restoreAcrossSessions` (unchanged since 0.2): same-tab reload only. */
131
+ async #restoreSameTab(browser) {
132
+ const scope = this.#config.restoreSession
133
+ ? this.#readSessionHint(browser.sessionStorage)
134
+ : null;
135
+ if (scope === null) {
136
+ this.#setState(anonymous('visitor'));
137
+ return;
138
+ }
139
+ this.#removeSessionHint(browser.sessionStorage);
140
+ await this.login({ scope });
141
+ }
142
+ /**
143
+ * At most one silent (`prompt=none`) attempt per start, driven by the tab hint (reload) or the
144
+ * persistent hint (new tab, reopened browser). A per-tab marker stops a second attempt while a
145
+ * previous one did not come back (navigation error, back button), so it can never loop.
146
+ */
147
+ async #restoreAcrossSessions(browser, persistent, options) {
148
+ const tabScope = this.#config.restoreSession
149
+ ? this.#readSessionHint(browser.sessionStorage)
150
+ : null;
151
+ const hint = this.#readPersistentHint(persistent, options.maxAgeMs);
152
+ const remembered = tabScope ?? hint?.scope ?? null;
153
+ if (remembered === null) {
154
+ this.#setState(anonymous('visitor'));
155
+ return;
156
+ }
157
+ if (this.#recentRestoreAttempt(browser.sessionStorage)) {
158
+ this.#setState(anonymous('visitor'));
159
+ return;
160
+ }
161
+ if (!isOnline(browser)) {
162
+ this.#setState(anonymous('offline'));
163
+ return;
164
+ }
165
+ // Never ask silently for more than both the game and the previous grant allow.
166
+ const scope = remembered === 'profile saves' && this.#config.scope === 'profile saves'
167
+ ? 'profile saves'
168
+ : 'profile';
169
+ this.#removeSessionHint(browser.sessionStorage);
170
+ await this.#doLogin(scope, true);
171
+ }
172
+ async #doLogin(scope, silent) {
114
173
  const clientId = this.#requireClientId();
115
174
  const redirectUri = this.#requireRedirectUri();
116
175
  const browser = this.#browser();
117
176
  let pending = this.#readPending(browser.sessionStorage);
118
- if (pending === null || pending.redirectUri !== redirectUri || this.#isExpired(pending)) {
177
+ if (silent ||
178
+ pending === null ||
179
+ pending.redirectUri !== redirectUri ||
180
+ pending.silent === true ||
181
+ this.#isExpired(pending)) {
182
+ const returnTo = silent ? sameOriginPath(browser.currentUrl(), redirectUri) : null;
119
183
  pending = {
120
184
  version: 1,
121
185
  state: randomBase64Url(browser.crypto),
122
186
  verifier: randomBase64Url(browser.crypto),
123
187
  redirectUri,
124
188
  createdAt: Date.now(),
189
+ ...(silent ? { silent: true, scope } : {}),
190
+ ...(returnTo === null ? {} : { returnTo }),
125
191
  };
126
192
  this.#writePending(browser.sessionStorage, pending);
127
193
  }
@@ -135,8 +201,13 @@ export class AuthModule {
135
201
  code_challenge: challenge,
136
202
  code_challenge_method: 'S256',
137
203
  scope,
204
+ ...(silent ? { prompt: 'none' } : {}),
138
205
  }).toString();
139
- this.#setState(Object.freeze({ status: 'redirecting', player: null }));
206
+ if (silent)
207
+ this.#writeRestoreAttempt(browser.sessionStorage);
208
+ this.#setState(Object.freeze(silent
209
+ ? { status: 'redirecting', player: null, restoring: true }
210
+ : { status: 'redirecting', player: null }));
140
211
  try {
141
212
  browser.navigate(authorization.toString());
142
213
  }
@@ -144,11 +215,36 @@ export class AuthModule {
144
215
  this.#removePending(browser.sessionStorage);
145
216
  throw new PixelumeAuthError('browser_unavailable', { cause });
146
217
  }
218
+ if (silent)
219
+ this.#watchInterruptedRestore();
220
+ }
221
+ /**
222
+ * If the page comes back without leaving (back/forward cache, navigation error then "back"),
223
+ * the silent attempt is over: become a visitor instead of staying in `redirecting`. The per-tab
224
+ * marker stays, so the next start does not try again right away.
225
+ */
226
+ #watchInterruptedRestore() {
227
+ this.#stopRestoreWatch?.();
228
+ if (this.#resume === null)
229
+ return;
230
+ const stop = this.#resume(() => {
231
+ const current = this.#state;
232
+ if (current.status === 'redirecting' && current.restoring === true) {
233
+ this.#setState(anonymous('visitor'));
234
+ }
235
+ stop();
236
+ if (this.#stopRestoreWatch === stop)
237
+ this.#stopRestoreWatch = null;
238
+ });
239
+ this.#stopRestoreWatch = stop;
147
240
  }
148
241
  async #handleCallback(browser, callback) {
149
242
  const pending = this.#readPending(browser.sessionStorage);
150
243
  this.#removePending(browser.sessionStorage);
151
- this.#cleanCallback(browser, callback.url);
244
+ this.#removeRestoreAttempt(browser.sessionStorage);
245
+ const silent = pending?.silent === true && !this.#isExpired(pending);
246
+ this.#cleanCallback(browser, callback.url, silent ? (pending.returnTo ?? null) : null);
247
+ this.#completingRestore = silent;
152
248
  if (callback.invalid)
153
249
  throw new PixelumeAuthError('invalid_callback');
154
250
  if (pending === null || this.#isExpired(pending)) {
@@ -166,6 +262,27 @@ export class AuthModule {
166
262
  }
167
263
  if (callback.error !== null) {
168
264
  this.#removeSessionHint(browser.sessionStorage);
265
+ const persistent = this.#persistentStorage(browser);
266
+ if (silent) {
267
+ this.#completingRestore = false;
268
+ if (SESSION_ENDED_ERRORS.has(callback.error)) {
269
+ if (persistent !== null)
270
+ this.#removePersistentHint(persistent);
271
+ this.#setState(anonymous('session_ended'));
272
+ return;
273
+ }
274
+ if (callback.error === 'invalid_scope' && pending.scope === 'profile saves') {
275
+ // The game no longer allows `saves`: the next start tries `profile` only.
276
+ if (persistent !== null)
277
+ this.#downgradePersistentHint(persistent);
278
+ }
279
+ else if (persistent !== null) {
280
+ this.#removePersistentHint(persistent);
281
+ }
282
+ throw new PixelumeAuthError('restore_rejected');
283
+ }
284
+ if (persistent !== null)
285
+ this.#removePersistentHint(persistent);
169
286
  if (callback.error === 'access_denied') {
170
287
  this.#setState(anonymous('cancelled'));
171
288
  return;
@@ -174,7 +291,9 @@ export class AuthModule {
174
291
  }
175
292
  if (callback.code === null)
176
293
  throw new PixelumeAuthError('invalid_callback');
177
- this.#setState(Object.freeze({ status: 'exchanging', player: null }));
294
+ this.#setState(Object.freeze(silent
295
+ ? { status: 'exchanging', player: null, restoring: true }
296
+ : { status: 'exchanging', player: null }));
178
297
  const response = await this.#http.request({
179
298
  method: 'POST',
180
299
  path: '/v1/oauth/token',
@@ -193,6 +312,10 @@ export class AuthModule {
193
312
  if (player !== null) {
194
313
  const scope = response.data.scopes.includes('saves') ? 'profile saves' : 'profile';
195
314
  this.#writeSessionHint(browser.sessionStorage, scope);
315
+ const persistent = this.#persistentStorage(browser);
316
+ if (this.#config.crossSessionRestore !== null && persistent !== null) {
317
+ this.#writePersistentHint(persistent, scope);
318
+ }
196
319
  }
197
320
  }
198
321
  async #fetchPlayer(options) {
@@ -226,6 +349,9 @@ export class AuthModule {
226
349
  if (browser) {
227
350
  this.#removePending(browser.sessionStorage);
228
351
  this.#removeSessionHint(browser.sessionStorage);
352
+ const persistent = this.#persistentStorage(browser);
353
+ if (persistent !== null)
354
+ this.#removePersistentHint(persistent);
229
355
  }
230
356
  this.#setState(anonymous('logged_out'));
231
357
  if (token === null)
@@ -265,7 +391,12 @@ export class AuthModule {
265
391
  invalid: !sameCallbackBase(current, expected) || (code === null) === (error === null),
266
392
  };
267
393
  }
268
- #cleanCallback(browser, url) {
394
+ /** Removes the callback fields; a silent attempt returns to the page that started it. */
395
+ #cleanCallback(browser, url, returnTo) {
396
+ if (returnTo !== null && isSafeReturnPath(returnTo)) {
397
+ browser.replaceUrl(returnTo);
398
+ return;
399
+ }
269
400
  const clean = new URL(url.toString());
270
401
  for (const key of CALLBACK_PARAMETERS)
271
402
  clean.searchParams.delete(key);
@@ -300,6 +431,15 @@ export class AuthModule {
300
431
  globalThis.history.replaceState(globalThis.history.state, '', url);
301
432
  },
302
433
  sessionStorage: globalThis.sessionStorage,
434
+ get persistentStorage() {
435
+ try {
436
+ return typeof globalThis.localStorage === 'undefined' ? null : globalThis.localStorage;
437
+ }
438
+ catch {
439
+ // Blocked storage (privacy settings, sandboxed frames).
440
+ return null;
441
+ }
442
+ },
303
443
  crypto: globalThis.crypto,
304
444
  };
305
445
  }
@@ -325,6 +465,99 @@ export class AuthModule {
325
465
  #hintKey() {
326
466
  return `pixelume.oauth.session.${this.#config.clientId ?? 'unconfigured'}`;
327
467
  }
468
+ #persistentHintKey() {
469
+ return `pixelume.oauth.restore.${this.#config.clientId ?? 'unconfigured'}`;
470
+ }
471
+ #restoreAttemptKey() {
472
+ return `pixelume.oauth.restore-attempt.${this.#config.clientId ?? 'unconfigured'}`;
473
+ }
474
+ #persistentStorage(browser) {
475
+ try {
476
+ return browser.persistentStorage ?? null;
477
+ }
478
+ catch {
479
+ return null;
480
+ }
481
+ }
482
+ /**
483
+ * Persistent-hint helpers never throw: an unavailable `localStorage` only disables
484
+ * cross-session restoration, it must not break the login itself.
485
+ */
486
+ #readPersistentHint(storage, maxAgeMs) {
487
+ try {
488
+ const raw = storage.getItem(this.#persistentHintKey());
489
+ if (raw === null)
490
+ return null;
491
+ const value = JSON.parse(raw);
492
+ const now = Date.now();
493
+ if (!isPersistentHint(value) || value.at > now + 60_000 || value.at + maxAgeMs < now) {
494
+ storage.removeItem(this.#persistentHintKey());
495
+ return null;
496
+ }
497
+ return value;
498
+ }
499
+ catch {
500
+ this.#removePersistentHint(storage);
501
+ return null;
502
+ }
503
+ }
504
+ #writePersistentHint(storage, scope) {
505
+ const hint = { version: 1, scope, at: Date.now() };
506
+ try {
507
+ storage.setItem(this.#persistentHintKey(), JSON.stringify(hint));
508
+ }
509
+ catch {
510
+ // Quota or blocked storage: the next new tab simply starts as a visitor.
511
+ }
512
+ }
513
+ #downgradePersistentHint(storage) {
514
+ try {
515
+ const raw = storage.getItem(this.#persistentHintKey());
516
+ const value = raw === null ? null : JSON.parse(raw);
517
+ if (!isPersistentHint(value))
518
+ return;
519
+ storage.setItem(this.#persistentHintKey(), JSON.stringify({ ...value, scope: 'profile' }));
520
+ }
521
+ catch {
522
+ this.#removePersistentHint(storage);
523
+ }
524
+ }
525
+ #removePersistentHint(storage) {
526
+ try {
527
+ storage.removeItem(this.#persistentHintKey());
528
+ }
529
+ catch {
530
+ // Nothing else to do.
531
+ }
532
+ }
533
+ #recentRestoreAttempt(storage) {
534
+ const raw = storage.getItem(this.#restoreAttemptKey());
535
+ if (raw === null)
536
+ return false;
537
+ const at = Number(raw);
538
+ const now = Date.now();
539
+ if (!Number.isFinite(at) || at > now + 60_000 || at + AUTHORIZATION_REQUEST_TTL_MS < now) {
540
+ storage.removeItem(this.#restoreAttemptKey());
541
+ return false;
542
+ }
543
+ return true;
544
+ }
545
+ #writeRestoreAttempt(storage) {
546
+ try {
547
+ storage.setItem(this.#restoreAttemptKey(), String(Date.now()));
548
+ }
549
+ catch (cause) {
550
+ throw new PixelumeAuthError('storage_unavailable', { cause });
551
+ }
552
+ }
553
+ #removeRestoreAttempt(storage) {
554
+ try {
555
+ storage.removeItem(this.#restoreAttemptKey());
556
+ }
557
+ catch (cause) {
558
+ throw new PixelumeAuthError('storage_unavailable', { cause });
559
+ }
560
+ }
328
561
  #readPending(storage) {
329
562
  try {
330
563
  const raw = storage.getItem(this.#pendingKey());
@@ -408,11 +641,58 @@ function samePlayer(left, right) {
408
641
  left.displayName === right.displayName &&
409
642
  left.avatarUrl === right.avatarUrl);
410
643
  }
644
+ function isPersistentHint(value) {
645
+ if (typeof value !== 'object' || value === null || Array.isArray(value))
646
+ return false;
647
+ const hint = value;
648
+ return (hint.version === 1 &&
649
+ (hint.scope === 'profile' || hint.scope === 'profile saves') &&
650
+ typeof hint.at === 'number' &&
651
+ Number.isFinite(hint.at));
652
+ }
653
+ function isOnline(browser) {
654
+ try {
655
+ if (typeof browser.isOnline === 'function')
656
+ return browser.isOnline();
657
+ const navigator = Reflect.get(globalThis, 'navigator');
658
+ return navigator?.onLine !== false;
659
+ }
660
+ catch {
661
+ return true;
662
+ }
663
+ }
664
+ /** Path, query and hash of `current` when it is on the redirect URI's origin; null otherwise. */
665
+ function sameOriginPath(current, redirectUri) {
666
+ try {
667
+ const url = new URL(current);
668
+ if (url.origin !== new URL(redirectUri).origin)
669
+ return null;
670
+ for (const key of CALLBACK_PARAMETERS)
671
+ url.searchParams.delete(key);
672
+ const path = `${url.pathname}${url.search}${url.hash}`;
673
+ return isSafeReturnPath(path) ? path : null;
674
+ }
675
+ catch {
676
+ return null;
677
+ }
678
+ }
679
+ function isSafeReturnPath(value) {
680
+ return (value.startsWith('/') &&
681
+ !value.startsWith('//') &&
682
+ !value.includes('\\') &&
683
+ value.length <= 2048);
684
+ }
411
685
  function isPendingAuthorization(value) {
412
686
  if (typeof value !== 'object' || value === null || Array.isArray(value))
413
687
  return false;
414
688
  const pending = value;
415
- return (pending.version === 1 &&
689
+ return ((pending.silent === undefined || pending.silent === true) &&
690
+ (pending.scope === undefined ||
691
+ pending.scope === 'profile' ||
692
+ pending.scope === 'profile saves') &&
693
+ (pending.returnTo === undefined ||
694
+ (typeof pending.returnTo === 'string' && isSafeReturnPath(pending.returnTo))) &&
695
+ pending.version === 1 &&
416
696
  typeof pending.state === 'string' &&
417
697
  /^[A-Za-z0-9_-]{43}$/u.test(pending.state) &&
418
698
  typeof pending.verifier === 'string' &&
@@ -461,6 +741,10 @@ function sameState(left, right) {
461
741
  if (left.status === 'anonymous' && right.status === 'anonymous') {
462
742
  return left.reason === right.reason && left.error === right.error;
463
743
  }
744
+ if ((left.status === 'redirecting' && right.status === 'redirecting') ||
745
+ (left.status === 'exchanging' && right.status === 'exchanging')) {
746
+ return left.restoring === right.restoring;
747
+ }
464
748
  return true;
465
749
  }
466
750
  //# sourceMappingURL=AuthModule.js.map