@playcademy/sdk 0.2.0 → 0.2.2

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/index.js CHANGED
@@ -1,3 +1,202 @@
1
+ // src/messaging.ts
2
+ var MessageEvents;
3
+ ((MessageEvents2) => {
4
+ MessageEvents2["INIT"] = "PLAYCADEMY_INIT";
5
+ MessageEvents2["TOKEN_REFRESH"] = "PLAYCADEMY_TOKEN_REFRESH";
6
+ MessageEvents2["PAUSE"] = "PLAYCADEMY_PAUSE";
7
+ MessageEvents2["RESUME"] = "PLAYCADEMY_RESUME";
8
+ MessageEvents2["FORCE_EXIT"] = "PLAYCADEMY_FORCE_EXIT";
9
+ MessageEvents2["OVERLAY"] = "PLAYCADEMY_OVERLAY";
10
+ MessageEvents2["CONNECTION_STATE"] = "PLAYCADEMY_CONNECTION_STATE";
11
+ MessageEvents2["READY"] = "PLAYCADEMY_READY";
12
+ MessageEvents2["EXIT"] = "PLAYCADEMY_EXIT";
13
+ MessageEvents2["TELEMETRY"] = "PLAYCADEMY_TELEMETRY";
14
+ MessageEvents2["KEY_EVENT"] = "PLAYCADEMY_KEY_EVENT";
15
+ MessageEvents2["DISPLAY_ALERT"] = "PLAYCADEMY_DISPLAY_ALERT";
16
+ MessageEvents2["AUTH_STATE_CHANGE"] = "PLAYCADEMY_AUTH_STATE_CHANGE";
17
+ MessageEvents2["AUTH_CALLBACK"] = "PLAYCADEMY_AUTH_CALLBACK";
18
+ })(MessageEvents ||= {});
19
+
20
+ class PlaycademyMessaging {
21
+ listeners = new Map;
22
+ send(type, payload, options) {
23
+ if (options?.target) {
24
+ this.sendViaPostMessage(type, payload, options.target, options.origin || "*");
25
+ return;
26
+ }
27
+ const context = this.getMessagingContext(type);
28
+ if (context.shouldUsePostMessage) {
29
+ this.sendViaPostMessage(type, payload, context.target, context.origin);
30
+ } else {
31
+ this.sendViaCustomEvent(type, payload);
32
+ }
33
+ }
34
+ listen(type, handler) {
35
+ const postMessageListener = (event) => {
36
+ const messageEvent = event;
37
+ if (messageEvent.data?.type === type) {
38
+ handler(messageEvent.data.payload || messageEvent.data);
39
+ }
40
+ };
41
+ const customEventListener = (event) => {
42
+ handler(event.detail);
43
+ };
44
+ if (!this.listeners.has(type)) {
45
+ this.listeners.set(type, new Map);
46
+ }
47
+ const listenerMap = this.listeners.get(type);
48
+ listenerMap.set(handler, {
49
+ postMessage: postMessageListener,
50
+ customEvent: customEventListener
51
+ });
52
+ window.addEventListener("message", postMessageListener);
53
+ window.addEventListener(type, customEventListener);
54
+ }
55
+ unlisten(type, handler) {
56
+ const typeListeners = this.listeners.get(type);
57
+ if (!typeListeners || !typeListeners.has(handler)) {
58
+ return;
59
+ }
60
+ const listeners = typeListeners.get(handler);
61
+ window.removeEventListener("message", listeners.postMessage);
62
+ window.removeEventListener(type, listeners.customEvent);
63
+ typeListeners.delete(handler);
64
+ if (typeListeners.size === 0) {
65
+ this.listeners.delete(type);
66
+ }
67
+ }
68
+ getMessagingContext(eventType) {
69
+ const isIframe = typeof window !== "undefined" && window.self !== window.top;
70
+ const iframeToParentEvents = [
71
+ "PLAYCADEMY_READY" /* READY */,
72
+ "PLAYCADEMY_EXIT" /* EXIT */,
73
+ "PLAYCADEMY_TELEMETRY" /* TELEMETRY */,
74
+ "PLAYCADEMY_KEY_EVENT" /* KEY_EVENT */,
75
+ "PLAYCADEMY_DISPLAY_ALERT" /* DISPLAY_ALERT */
76
+ ];
77
+ const shouldUsePostMessage = isIframe && iframeToParentEvents.includes(eventType);
78
+ return {
79
+ shouldUsePostMessage,
80
+ target: shouldUsePostMessage ? window.parent : undefined,
81
+ origin: "*"
82
+ };
83
+ }
84
+ sendViaPostMessage(type, payload, target = window.parent, origin = "*") {
85
+ const messageData = { type };
86
+ if (payload !== undefined) {
87
+ messageData.payload = payload;
88
+ }
89
+ target.postMessage(messageData, origin);
90
+ }
91
+ sendViaCustomEvent(type, payload) {
92
+ window.dispatchEvent(new CustomEvent(type, { detail: payload }));
93
+ }
94
+ }
95
+ var messaging = new PlaycademyMessaging;
96
+
97
+ // src/core/static/init.ts
98
+ async function getPlaycademyConfig(allowedParentOrigins) {
99
+ const preloaded = window.PLAYCADEMY;
100
+ if (preloaded?.token) {
101
+ return preloaded;
102
+ }
103
+ if (window.self !== window.top) {
104
+ return await waitForPlaycademyInit(allowedParentOrigins);
105
+ } else {
106
+ return createStandaloneConfig();
107
+ }
108
+ }
109
+ function getReferrerOrigin() {
110
+ try {
111
+ return document.referrer ? new URL(document.referrer).origin : null;
112
+ } catch {
113
+ return null;
114
+ }
115
+ }
116
+ function buildAllowedOrigins(explicit) {
117
+ if (Array.isArray(explicit) && explicit.length > 0)
118
+ return explicit;
119
+ const ref = getReferrerOrigin();
120
+ return ref ? [ref] : [];
121
+ }
122
+ function isOriginAllowed(origin, allowlist) {
123
+ if (window.location.hostname === "localhost" || window.location.hostname === "127.0.0.1") {
124
+ return true;
125
+ }
126
+ if (!allowlist || allowlist.length === 0) {
127
+ console.error("[Playcademy SDK] No allowed origins configured. Consider passing allowedParentOrigins explicitly to init().");
128
+ return false;
129
+ }
130
+ return allowlist.includes(origin);
131
+ }
132
+ async function waitForPlaycademyInit(allowedParentOrigins) {
133
+ return new Promise((resolve, reject) => {
134
+ let contextReceived = false;
135
+ const timeoutDuration = 5000;
136
+ const allowlist = buildAllowedOrigins(allowedParentOrigins);
137
+ let hasWarnedAboutUntrustedOrigin = false;
138
+ function warnAboutUntrustedOrigin(origin) {
139
+ if (hasWarnedAboutUntrustedOrigin)
140
+ return;
141
+ hasWarnedAboutUntrustedOrigin = true;
142
+ console.warn("[Playcademy SDK] Ignoring INIT from untrusted origin:", origin);
143
+ }
144
+ const handleMessage = (event) => {
145
+ if (event.data?.type !== "PLAYCADEMY_INIT" /* INIT */)
146
+ return;
147
+ if (!isOriginAllowed(event.origin, allowlist)) {
148
+ warnAboutUntrustedOrigin(event.origin);
149
+ return;
150
+ }
151
+ contextReceived = true;
152
+ window.removeEventListener("message", handleMessage);
153
+ clearTimeout(timeoutId);
154
+ window.PLAYCADEMY = event.data.payload;
155
+ resolve(event.data.payload);
156
+ };
157
+ window.addEventListener("message", handleMessage);
158
+ const timeoutId = setTimeout(() => {
159
+ if (!contextReceived) {
160
+ window.removeEventListener("message", handleMessage);
161
+ reject(new Error(`${"PLAYCADEMY_INIT" /* INIT */} not received within ${timeoutDuration}ms`));
162
+ }
163
+ }, timeoutDuration);
164
+ });
165
+ }
166
+ function createStandaloneConfig() {
167
+ console.debug("[Playcademy SDK] Standalone mode detected, creating mock context for sandbox development");
168
+ const mockConfig = {
169
+ baseUrl: "http://localhost:4321",
170
+ gameUrl: window.location.origin,
171
+ token: "mock-game-token-for-local-dev",
172
+ gameId: "mock-game-id-from-template",
173
+ realtimeUrl: undefined
174
+ };
175
+ window.PLAYCADEMY = mockConfig;
176
+ return mockConfig;
177
+ }
178
+ async function init(options) {
179
+ if (typeof window === "undefined") {
180
+ throw new Error("Playcademy SDK must run in a browser context");
181
+ }
182
+ const config = await getPlaycademyConfig(options?.allowedParentOrigins);
183
+ if (options?.baseUrl) {
184
+ config.baseUrl = options.baseUrl;
185
+ }
186
+ const client = new this({
187
+ baseUrl: config.baseUrl,
188
+ gameUrl: config.gameUrl,
189
+ token: config.token,
190
+ gameId: config.gameId,
191
+ autoStartSession: window.self !== window.top,
192
+ onDisconnect: options?.onDisconnect,
193
+ enableConnectionMonitoring: options?.enableConnectionMonitoring
194
+ });
195
+ client["initPayload"] = config;
196
+ messaging.listen("PLAYCADEMY_TOKEN_REFRESH" /* TOKEN_REFRESH */, ({ token }) => client.setToken(token));
197
+ messaging.send("PLAYCADEMY_READY" /* READY */, undefined);
198
+ return client;
199
+ }
1
200
  // ../logger/src/index.ts
2
201
  var isBrowser = () => {
3
202
  const g = globalThis;
@@ -175,85 +374,120 @@ var createLogger = () => {
175
374
  };
176
375
  var log = createLogger();
177
376
 
178
- // src/core/auth/strategies.ts
179
- class ApiKeyAuth {
180
- apiKey;
181
- constructor(apiKey) {
182
- this.apiKey = apiKey;
183
- }
184
- getToken() {
185
- return this.apiKey;
186
- }
187
- getType() {
188
- return "apiKey";
189
- }
190
- getHeaders() {
191
- return { "x-api-key": this.apiKey };
377
+ // src/core/errors.ts
378
+ class PlaycademyError extends Error {
379
+ constructor(message) {
380
+ super(message);
381
+ this.name = "PlaycademyError";
192
382
  }
193
383
  }
194
384
 
195
- class SessionAuth {
196
- sessionToken;
197
- constructor(sessionToken) {
198
- this.sessionToken = sessionToken;
199
- }
200
- getToken() {
201
- return this.sessionToken;
385
+ class ApiError extends Error {
386
+ status;
387
+ details;
388
+ constructor(status, message, details) {
389
+ super(`${status} ${message}`);
390
+ this.status = status;
391
+ this.details = details;
392
+ Object.setPrototypeOf(this, ApiError.prototype);
202
393
  }
203
- getType() {
204
- return "session";
394
+ }
395
+ function extractApiErrorInfo(error) {
396
+ if (!(error instanceof ApiError)) {
397
+ return null;
205
398
  }
206
- getHeaders() {
207
- return { Authorization: `Bearer ${this.sessionToken}` };
399
+ const info = {
400
+ status: error.status,
401
+ statusText: error.message
402
+ };
403
+ if (error.details) {
404
+ info.details = error.details;
208
405
  }
406
+ return info;
209
407
  }
210
408
 
211
- class GameJwtAuth {
212
- gameToken;
213
- constructor(gameToken) {
214
- this.gameToken = gameToken;
215
- }
216
- getToken() {
217
- return this.gameToken;
218
- }
219
- getType() {
220
- return "gameJwt";
409
+ // src/core/static/login.ts
410
+ async function login(baseUrl, email, password) {
411
+ let url = baseUrl;
412
+ if (baseUrl.startsWith("/") && typeof window !== "undefined") {
413
+ url = window.location.origin + baseUrl;
221
414
  }
222
- getHeaders() {
223
- return { Authorization: `Bearer ${this.gameToken}` };
415
+ url = url + "/auth/login";
416
+ const response = await fetch(url, {
417
+ method: "POST",
418
+ headers: {
419
+ "Content-Type": "application/json"
420
+ },
421
+ body: JSON.stringify({ email, password })
422
+ });
423
+ if (!response.ok) {
424
+ try {
425
+ const errorData = await response.json();
426
+ const errorMessage = errorData && errorData.message ? String(errorData.message) : response.statusText;
427
+ throw new PlaycademyError(errorMessage);
428
+ } catch (error) {
429
+ log.error("[Playcademy SDK] Failed to parse error response JSON, using status text instead:", { error });
430
+ throw new PlaycademyError(response.statusText);
431
+ }
224
432
  }
433
+ return response.json();
434
+ }
435
+ // ../utils/src/random.ts
436
+ async function generateSecureRandomString(length) {
437
+ const charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~";
438
+ const randomValues = new Uint8Array(length);
439
+ globalThis.crypto.getRandomValues(randomValues);
440
+ return Array.from(randomValues).map((byte) => charset[byte % charset.length]).join("");
225
441
  }
226
442
 
227
- class NoAuth {
228
- getToken() {
229
- return null;
230
- }
231
- getType() {
232
- return "session";
233
- }
234
- getHeaders() {
235
- return {};
236
- }
443
+ // src/core/auth/oauth.ts
444
+ function getTimebackConfig() {
445
+ return {
446
+ authorizationEndpoint: "https://alpha-auth-production-idp.auth.us-west-2.amazoncognito.com/oauth2/authorize",
447
+ tokenEndpoint: "https://alpha-auth-production-idp.auth.us-west-2.amazoncognito.com/oauth2/token",
448
+ scope: "openid email phone"
449
+ };
237
450
  }
238
- function createAuthStrategy(token, tokenType) {
239
- if (!token) {
240
- return new NoAuth;
241
- }
242
- if (tokenType === "apiKey") {
243
- return new ApiKeyAuth(token);
244
- }
245
- if (tokenType === "session") {
246
- return new SessionAuth(token);
451
+ var OAUTH_CONFIGS = {
452
+ get TIMEBACK() {
453
+ return getTimebackConfig;
247
454
  }
248
- if (tokenType === "gameJwt") {
249
- return new GameJwtAuth(token);
455
+ };
456
+ async function generateOAuthState(data) {
457
+ const csrfToken = await generateSecureRandomString(32);
458
+ if (data && Object.keys(data).length > 0) {
459
+ const jsonStr = JSON.stringify(data);
460
+ const base64 = btoa(jsonStr).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
461
+ return `${csrfToken}.${base64}`;
250
462
  }
251
- if (token.startsWith("cademy")) {
252
- return new ApiKeyAuth(token);
463
+ return csrfToken;
464
+ }
465
+ function parseOAuthState(state) {
466
+ const lastDotIndex = state.lastIndexOf(".");
467
+ if (lastDotIndex > 0 && lastDotIndex < state.length - 1) {
468
+ try {
469
+ const csrfToken = state.substring(0, lastDotIndex);
470
+ const base64 = state.substring(lastDotIndex + 1);
471
+ const base64WithPadding = base64.replace(/-/g, "+").replace(/_/g, "/");
472
+ const paddedBase64 = base64WithPadding + "=".repeat((4 - base64WithPadding.length % 4) % 4);
473
+ const jsonStr = atob(paddedBase64);
474
+ const data = JSON.parse(jsonStr);
475
+ return { csrfToken, data };
476
+ } catch {}
253
477
  }
254
- return new GameJwtAuth(token);
478
+ return { csrfToken: state };
479
+ }
480
+ function getOAuthConfig(provider) {
481
+ const configGetter = OAUTH_CONFIGS[provider];
482
+ if (!configGetter)
483
+ throw new Error(`Unsupported auth provider: ${provider}`);
484
+ return configGetter();
255
485
  }
256
486
 
487
+ // src/core/static/identity.ts
488
+ var identity = {
489
+ parseOAuthState
490
+ };
257
491
  // src/core/auth/utils.ts
258
492
  function openPopupWindow(url, name = "auth-popup", width = 500, height = 600) {
259
493
  const left = window.screenX + (window.outerWidth - width) / 2;
@@ -283,845 +517,208 @@ function isInIframe() {
283
517
  }
284
518
  }
285
519
 
286
- // src/core/connection/monitor.ts
287
- class ConnectionMonitor {
288
- state = "online";
289
- callbacks = new Set;
290
- heartbeatInterval;
291
- consecutiveFailures = 0;
292
- isMonitoring = false;
293
- config;
294
- constructor(config) {
295
- this.config = {
296
- baseUrl: config.baseUrl,
297
- heartbeatInterval: config.heartbeatInterval ?? 1e4,
298
- heartbeatTimeout: config.heartbeatTimeout ?? 5000,
299
- failureThreshold: config.failureThreshold ?? 2,
300
- enableHeartbeat: config.enableHeartbeat ?? true,
301
- enableOfflineEvents: config.enableOfflineEvents ?? true
302
- };
303
- this._detectInitialState();
304
- }
305
- start() {
306
- if (this.isMonitoring)
307
- return;
308
- this.isMonitoring = true;
309
- if (this.config.enableOfflineEvents && typeof window !== "undefined") {
310
- window.addEventListener("online", this._handleOnline);
311
- window.addEventListener("offline", this._handleOffline);
312
- }
313
- if (this.config.enableHeartbeat) {
314
- this._startHeartbeat();
315
- }
316
- }
317
- stop() {
318
- if (!this.isMonitoring)
319
- return;
320
- this.isMonitoring = false;
321
- if (typeof window !== "undefined") {
322
- window.removeEventListener("online", this._handleOnline);
323
- window.removeEventListener("offline", this._handleOffline);
520
+ // src/core/auth/flows/popup.ts
521
+ async function initiatePopupFlow(options) {
522
+ const { provider, callbackUrl, onStateChange, oauth } = options;
523
+ try {
524
+ onStateChange?.({
525
+ status: "opening_popup",
526
+ message: "Opening authentication window..."
527
+ });
528
+ const defaults = getOAuthConfig(provider);
529
+ const config = oauth ? { ...defaults, ...oauth } : defaults;
530
+ if (!config.clientId) {
531
+ throw new Error(`clientId is required for ${provider} authentication. ` + "Please provide it in the oauth parameter.");
324
532
  }
325
- if (this.heartbeatInterval) {
326
- clearInterval(this.heartbeatInterval);
327
- this.heartbeatInterval = undefined;
533
+ const stateData = options.stateData;
534
+ const state = await generateOAuthState(stateData);
535
+ const params = new URLSearchParams({
536
+ response_type: "code",
537
+ client_id: config.clientId,
538
+ redirect_uri: callbackUrl,
539
+ state
540
+ });
541
+ if (config.scope) {
542
+ params.set("scope", config.scope);
328
543
  }
329
- }
330
- onChange(callback) {
331
- this.callbacks.add(callback);
332
- return () => this.callbacks.delete(callback);
333
- }
334
- getState() {
335
- return this.state;
336
- }
337
- async checkNow() {
338
- await this._performHeartbeat();
339
- return this.state;
340
- }
341
- reportRequestFailure(error) {
342
- const isNetworkError = error instanceof TypeError || error instanceof Error && error.message.includes("fetch");
343
- if (!isNetworkError)
344
- return;
345
- this.consecutiveFailures++;
346
- if (this.consecutiveFailures >= this.config.failureThreshold) {
347
- this._setState("degraded", "Multiple consecutive request failures");
544
+ const authUrl = `${config.authorizationEndpoint}?${params.toString()}`;
545
+ const popup = openPopupWindow(authUrl, "playcademy-auth");
546
+ if (!popup || popup.closed) {
547
+ throw new Error("Popup blocked. Please enable popups and try again.");
348
548
  }
549
+ onStateChange?.({
550
+ status: "exchanging_token",
551
+ message: "Waiting for authentication..."
552
+ });
553
+ return await waitForServerMessage(popup, onStateChange);
554
+ } catch (error) {
555
+ const errorMessage = error instanceof Error ? error.message : "Authentication failed";
556
+ onStateChange?.({
557
+ status: "error",
558
+ message: errorMessage,
559
+ error: error instanceof Error ? error : new Error(errorMessage)
560
+ });
561
+ throw error;
349
562
  }
350
- reportRequestSuccess() {
351
- if (this.consecutiveFailures > 0) {
352
- this.consecutiveFailures = 0;
353
- if (this.state === "degraded") {
354
- this._setState("online", "Requests succeeding again");
563
+ }
564
+ async function waitForServerMessage(popup, onStateChange) {
565
+ return new Promise((resolve) => {
566
+ let resolved = false;
567
+ const handleMessage = (event) => {
568
+ if (event.origin !== window.location.origin)
569
+ return;
570
+ const data = event.data;
571
+ if (data?.type === "PLAYCADEMY_AUTH_STATE_CHANGE") {
572
+ resolved = true;
573
+ window.removeEventListener("message", handleMessage);
574
+ if (data.authenticated && data.user) {
575
+ onStateChange?.({
576
+ status: "complete",
577
+ message: "Authentication successful"
578
+ });
579
+ resolve({
580
+ success: true,
581
+ user: data.user
582
+ });
583
+ } else {
584
+ const error = new Error(data.error || "Authentication failed");
585
+ onStateChange?.({
586
+ status: "error",
587
+ message: error.message,
588
+ error
589
+ });
590
+ resolve({
591
+ success: false,
592
+ error
593
+ });
594
+ }
355
595
  }
356
- }
357
- }
358
- _detectInitialState() {
359
- if (typeof navigator !== "undefined" && !navigator.onLine) {
360
- this.state = "offline";
361
- }
362
- }
363
- _handleOnline = () => {
364
- this.consecutiveFailures = 0;
365
- this._setState("online", "Browser online event");
366
- };
367
- _handleOffline = () => {
368
- this._setState("offline", "Browser offline event");
369
- };
370
- _startHeartbeat() {
371
- this._performHeartbeat();
372
- this.heartbeatInterval = setInterval(() => {
373
- this._performHeartbeat();
374
- }, this.config.heartbeatInterval);
375
- }
376
- async _performHeartbeat() {
377
- if (typeof navigator !== "undefined" && !navigator.onLine) {
378
- return;
379
- }
380
- try {
381
- const controller = new AbortController;
382
- const timeoutId = setTimeout(() => controller.abort(), this.config.heartbeatTimeout);
383
- const response = await fetch(`${this.config.baseUrl}/ping`, {
384
- method: "GET",
385
- signal: controller.signal,
386
- cache: "no-store"
387
- });
388
- clearTimeout(timeoutId);
389
- if (response.ok) {
390
- this.consecutiveFailures = 0;
391
- if (this.state !== "online") {
392
- this._setState("online", "Heartbeat successful");
393
- }
394
- } else {
395
- this._handleHeartbeatFailure("Heartbeat returned non-OK status");
596
+ };
597
+ window.addEventListener("message", handleMessage);
598
+ const checkClosed = setInterval(() => {
599
+ if (popup.closed && !resolved) {
600
+ clearInterval(checkClosed);
601
+ window.removeEventListener("message", handleMessage);
602
+ const error = new Error("Authentication cancelled");
603
+ onStateChange?.({
604
+ status: "error",
605
+ message: error.message,
606
+ error
607
+ });
608
+ resolve({
609
+ success: false,
610
+ error
611
+ });
396
612
  }
397
- } catch (error) {
398
- this._handleHeartbeatFailure(error instanceof Error ? error.message : "Heartbeat failed");
399
- }
400
- }
401
- _handleHeartbeatFailure(reason) {
402
- this.consecutiveFailures++;
403
- if (this.consecutiveFailures >= this.config.failureThreshold) {
404
- if (typeof navigator !== "undefined" && !navigator.onLine) {
405
- this._setState("offline", reason);
406
- } else {
407
- this._setState("degraded", reason);
613
+ }, 500);
614
+ setTimeout(() => {
615
+ if (!resolved) {
616
+ window.removeEventListener("message", handleMessage);
617
+ clearInterval(checkClosed);
618
+ const error = new Error("Authentication timeout");
619
+ onStateChange?.({
620
+ status: "error",
621
+ message: error.message,
622
+ error
623
+ });
624
+ resolve({
625
+ success: false,
626
+ error
627
+ });
408
628
  }
629
+ }, 5 * 60 * 1000);
630
+ });
631
+ }
632
+
633
+ // src/core/auth/flows/redirect.ts
634
+ async function initiateRedirectFlow(options) {
635
+ const { provider, callbackUrl, onStateChange, oauth } = options;
636
+ try {
637
+ onStateChange?.({
638
+ status: "opening_popup",
639
+ message: "Redirecting to authentication provider..."
640
+ });
641
+ const defaults = getOAuthConfig(provider);
642
+ const config = oauth ? { ...defaults, ...oauth } : defaults;
643
+ if (!config.clientId) {
644
+ throw new Error(`clientId is required for ${provider} authentication. ` + "Please provide it in the oauth parameter.");
409
645
  }
410
- }
411
- _setState(newState, reason) {
412
- if (this.state === newState)
413
- return;
414
- const oldState = this.state;
415
- this.state = newState;
416
- console.debug(`[ConnectionMonitor] ${oldState} → ${newState}: ${reason}`);
417
- this.callbacks.forEach((callback) => {
418
- try {
419
- callback(newState, reason);
420
- } catch (error) {
421
- console.error("[ConnectionMonitor] Error in callback:", error);
422
- }
646
+ const stateData = options.stateData;
647
+ const state = await generateOAuthState(stateData);
648
+ const params = new URLSearchParams({
649
+ response_type: "code",
650
+ client_id: config.clientId,
651
+ redirect_uri: callbackUrl,
652
+ state
653
+ });
654
+ if (config.scope) {
655
+ params.set("scope", config.scope);
656
+ }
657
+ const authUrl = `${config.authorizationEndpoint}?${params.toString()}`;
658
+ window.location.href = authUrl;
659
+ return new Promise(() => {});
660
+ } catch (error) {
661
+ const errorMessage = error instanceof Error ? error.message : "Authentication failed";
662
+ onStateChange?.({
663
+ status: "error",
664
+ message: errorMessage,
665
+ error: error instanceof Error ? error : new Error(errorMessage)
423
666
  });
667
+ throw error;
424
668
  }
425
669
  }
426
- // src/messaging.ts
427
- var MessageEvents;
428
- ((MessageEvents2) => {
429
- MessageEvents2["INIT"] = "PLAYCADEMY_INIT";
430
- MessageEvents2["TOKEN_REFRESH"] = "PLAYCADEMY_TOKEN_REFRESH";
431
- MessageEvents2["PAUSE"] = "PLAYCADEMY_PAUSE";
432
- MessageEvents2["RESUME"] = "PLAYCADEMY_RESUME";
433
- MessageEvents2["FORCE_EXIT"] = "PLAYCADEMY_FORCE_EXIT";
434
- MessageEvents2["OVERLAY"] = "PLAYCADEMY_OVERLAY";
435
- MessageEvents2["CONNECTION_STATE"] = "PLAYCADEMY_CONNECTION_STATE";
436
- MessageEvents2["READY"] = "PLAYCADEMY_READY";
437
- MessageEvents2["EXIT"] = "PLAYCADEMY_EXIT";
438
- MessageEvents2["TELEMETRY"] = "PLAYCADEMY_TELEMETRY";
439
- MessageEvents2["KEY_EVENT"] = "PLAYCADEMY_KEY_EVENT";
440
- MessageEvents2["DISPLAY_ALERT"] = "PLAYCADEMY_DISPLAY_ALERT";
441
- MessageEvents2["AUTH_STATE_CHANGE"] = "PLAYCADEMY_AUTH_STATE_CHANGE";
442
- MessageEvents2["AUTH_CALLBACK"] = "PLAYCADEMY_AUTH_CALLBACK";
443
- })(MessageEvents ||= {});
444
670
 
445
- class PlaycademyMessaging {
446
- listeners = new Map;
447
- send(type, payload, options) {
448
- if (options?.target) {
449
- this.sendViaPostMessage(type, payload, options.target, options.origin || "*");
450
- return;
451
- }
452
- const context = this.getMessagingContext(type);
453
- if (context.shouldUsePostMessage) {
454
- this.sendViaPostMessage(type, payload, context.target, context.origin);
455
- } else {
456
- this.sendViaCustomEvent(type, payload);
457
- }
671
+ // src/core/auth/flows/unified.ts
672
+ async function initiateUnifiedFlow(options) {
673
+ const { mode = "auto" } = options;
674
+ const effectiveMode = mode === "auto" ? isInIframe() ? "popup" : "redirect" : mode;
675
+ switch (effectiveMode) {
676
+ case "popup":
677
+ return initiatePopupFlow(options);
678
+ case "redirect":
679
+ return initiateRedirectFlow(options);
680
+ default:
681
+ throw new Error(`Unsupported authentication mode: ${effectiveMode}`);
458
682
  }
459
- listen(type, handler) {
460
- const postMessageListener = (event) => {
461
- const messageEvent = event;
462
- if (messageEvent.data?.type === type) {
463
- handler(messageEvent.data.payload || messageEvent.data);
683
+ }
684
+
685
+ // src/core/auth/login.ts
686
+ async function login2(client, options) {
687
+ try {
688
+ let stateData = options.stateData;
689
+ if (!stateData) {
690
+ try {
691
+ const currentUser = await client.users.me();
692
+ if (currentUser?.id) {
693
+ stateData = { playcademy_user_id: currentUser.id };
694
+ }
695
+ } catch {
696
+ log.debug("[Playcademy SDK] No current user available for state data");
464
697
  }
465
- };
466
- const customEventListener = (event) => {
467
- handler(event.detail);
468
- };
469
- if (!this.listeners.has(type)) {
470
- this.listeners.set(type, new Map);
471
698
  }
472
- const listenerMap = this.listeners.get(type);
473
- listenerMap.set(handler, {
474
- postMessage: postMessageListener,
475
- customEvent: customEventListener
699
+ log.debug("[Playcademy SDK] Starting OAuth login", {
700
+ provider: options.provider,
701
+ mode: options.mode || "auto",
702
+ callbackUrl: options.callbackUrl,
703
+ hasStateData: !!stateData
476
704
  });
477
- window.addEventListener("message", postMessageListener);
478
- window.addEventListener(type, customEventListener);
479
- }
480
- unlisten(type, handler) {
481
- const typeListeners = this.listeners.get(type);
482
- if (!typeListeners || !typeListeners.has(handler)) {
483
- return;
484
- }
485
- const listeners = typeListeners.get(handler);
486
- window.removeEventListener("message", listeners.postMessage);
487
- window.removeEventListener(type, listeners.customEvent);
488
- typeListeners.delete(handler);
489
- if (typeListeners.size === 0) {
490
- this.listeners.delete(type);
705
+ const optionsWithState = {
706
+ ...options,
707
+ stateData
708
+ };
709
+ const result = await initiateUnifiedFlow(optionsWithState);
710
+ if (result.success && result.user) {
711
+ log.debug("[Playcademy SDK] OAuth login successful", {
712
+ userId: result.user.sub
713
+ });
491
714
  }
492
- }
493
- getMessagingContext(eventType) {
494
- const isIframe = typeof window !== "undefined" && window.self !== window.top;
495
- const iframeToParentEvents = [
496
- "PLAYCADEMY_READY" /* READY */,
497
- "PLAYCADEMY_EXIT" /* EXIT */,
498
- "PLAYCADEMY_TELEMETRY" /* TELEMETRY */,
499
- "PLAYCADEMY_KEY_EVENT" /* KEY_EVENT */,
500
- "PLAYCADEMY_DISPLAY_ALERT" /* DISPLAY_ALERT */
501
- ];
502
- const shouldUsePostMessage = isIframe && iframeToParentEvents.includes(eventType);
715
+ return result;
716
+ } catch (error) {
717
+ log.error("[Playcademy SDK] OAuth login failed", { error });
718
+ const authError = error instanceof Error ? error : new Error("Authentication failed");
503
719
  return {
504
- shouldUsePostMessage,
505
- target: shouldUsePostMessage ? window.parent : undefined,
506
- origin: "*"
507
- };
508
- }
509
- sendViaPostMessage(type, payload, target = window.parent, origin = "*") {
510
- const messageData = { type };
511
- if (payload !== undefined) {
512
- messageData.payload = payload;
513
- }
514
- target.postMessage(messageData, origin);
515
- }
516
- sendViaCustomEvent(type, payload) {
517
- window.dispatchEvent(new CustomEvent(type, { detail: payload }));
518
- }
519
- }
520
- var messaging = new PlaycademyMessaging;
521
-
522
- // src/core/connection/utils.ts
523
- function createDisplayAlert(authContext) {
524
- return (message, options) => {
525
- if (authContext?.isInIframe && typeof window !== "undefined" && window.parent !== window) {
526
- window.parent.postMessage({
527
- type: "PLAYCADEMY_DISPLAY_ALERT",
528
- message,
529
- options
530
- }, "*");
531
- } else {
532
- const prefix = options?.type === "error" ? "❌" : options?.type === "warning" ? "⚠️" : "ℹ️";
533
- console.log(`${prefix} ${message}`);
534
- }
535
- };
536
- }
537
-
538
- // src/core/connection/manager.ts
539
- class ConnectionManager {
540
- monitor;
541
- authContext;
542
- disconnectHandler;
543
- connectionChangeCallback;
544
- currentState = "online";
545
- additionalDisconnectHandlers = new Set;
546
- constructor(config) {
547
- this.authContext = config.authContext;
548
- this.disconnectHandler = config.onDisconnect;
549
- this.connectionChangeCallback = config.onConnectionChange;
550
- if (config.authContext?.isInIframe) {
551
- this._setupPlatformListener();
552
- }
553
- }
554
- getState() {
555
- return this.monitor?.getState() ?? this.currentState;
556
- }
557
- async checkNow() {
558
- if (!this.monitor) {
559
- return this.currentState;
560
- }
561
- return await this.monitor.checkNow();
562
- }
563
- reportRequestSuccess() {
564
- this.monitor?.reportRequestSuccess();
565
- }
566
- reportRequestFailure(error) {
567
- this.monitor?.reportRequestFailure(error);
568
- }
569
- onDisconnect(callback) {
570
- this.additionalDisconnectHandlers.add(callback);
571
- return () => {
572
- this.additionalDisconnectHandlers.delete(callback);
573
- };
574
- }
575
- stop() {
576
- this.monitor?.stop();
577
- }
578
- _setupPlatformListener() {
579
- messaging.listen("PLAYCADEMY_CONNECTION_STATE" /* CONNECTION_STATE */, ({ state, reason }) => {
580
- this.currentState = state;
581
- this._handleConnectionChange(state, reason);
582
- });
583
- }
584
- _handleConnectionChange(state, reason) {
585
- this.connectionChangeCallback?.(state, reason);
586
- if (state === "offline" || state === "degraded") {
587
- const context = {
588
- state,
589
- reason,
590
- timestamp: Date.now(),
591
- displayAlert: createDisplayAlert(this.authContext)
592
- };
593
- if (this.disconnectHandler) {
594
- this.disconnectHandler(context);
595
- }
596
- this.additionalDisconnectHandlers.forEach((handler) => {
597
- handler(context);
598
- });
599
- }
600
- }
601
- }
602
- // src/core/errors.ts
603
- class PlaycademyError extends Error {
604
- constructor(message) {
605
- super(message);
606
- this.name = "PlaycademyError";
607
- }
608
- }
609
-
610
- class ApiError extends Error {
611
- status;
612
- details;
613
- constructor(status, message, details) {
614
- super(`${status} ${message}`);
615
- this.status = status;
616
- this.details = details;
617
- Object.setPrototypeOf(this, ApiError.prototype);
618
- }
619
- }
620
- function extractApiErrorInfo(error) {
621
- if (!(error instanceof ApiError)) {
622
- return null;
623
- }
624
- const info = {
625
- status: error.status,
626
- statusText: error.message
627
- };
628
- if (error.details) {
629
- info.details = error.details;
630
- }
631
- return info;
632
- }
633
-
634
- // src/core/request.ts
635
- function checkDevWarnings(data) {
636
- if (!data || typeof data !== "object")
637
- return;
638
- const response = data;
639
- const warningType = response.__playcademyDevWarning;
640
- if (!warningType)
641
- return;
642
- switch (warningType) {
643
- case "timeback-not-configured":
644
- console.warn("%c⚠️ TimeBack Not Configured", "background: #f59e0b; color: white; padding: 6px 12px; border-radius: 4px; font-weight: bold; font-size: 13px");
645
- console.log("%cTimeBack is configured in playcademy.config.js but the sandbox does not have TimeBack credentials.", "color: #f59e0b; font-weight: 500");
646
- console.log("To test TimeBack locally:");
647
- console.log(" Set the following environment variables:");
648
- console.log(" • %cTIMEBACK_ONEROSTER_API_URL", "color: #0ea5e9; font-weight: 600; font-family: monospace");
649
- console.log(" • %cTIMEBACK_CALIPER_API_URL", "color: #0ea5e9; font-weight: 600; font-family: monospace");
650
- console.log(" • %cTIMEBACK_API_CLIENT_ID/SECRET", "color: #0ea5e9; font-weight: 600; font-family: monospace");
651
- console.log(" Or deploy your game: %cplaycademy deploy", "color: #10b981; font-weight: 600; font-family: monospace");
652
- console.log(" Or wait for %c@superbuilders/timeback-local%c (coming soon)", "color: #8b5cf6; font-weight: 600; font-family: monospace", "color: inherit");
653
- break;
654
- default:
655
- console.warn(`[Playcademy Dev Warning] ${warningType}`);
656
- }
657
- }
658
- function prepareRequestBody(body, headers) {
659
- if (body instanceof FormData) {
660
- return body;
661
- }
662
- if (body instanceof ArrayBuffer || body instanceof Blob || ArrayBuffer.isView(body)) {
663
- if (!headers["Content-Type"]) {
664
- headers["Content-Type"] = "application/octet-stream";
665
- }
666
- return body;
667
- }
668
- if (body !== undefined && body !== null) {
669
- headers["Content-Type"] = "application/json";
670
- return JSON.stringify(body);
671
- }
672
- return;
673
- }
674
- async function request({
675
- path,
676
- baseUrl,
677
- method = "GET",
678
- body,
679
- extraHeaders = {},
680
- raw = false
681
- }) {
682
- const url = baseUrl.replace(/\/$/, "") + (path.startsWith("/") ? path : `/${path}`);
683
- const headers = { ...extraHeaders };
684
- const payload = prepareRequestBody(body, headers);
685
- const res = await fetch(url, {
686
- method,
687
- headers,
688
- body: payload,
689
- credentials: "omit"
690
- });
691
- if (raw) {
692
- return res;
693
- }
694
- if (!res.ok) {
695
- const clonedRes = res.clone();
696
- const errorBody = await clonedRes.json().catch(() => clonedRes.text().catch(() => {
697
- return;
698
- })) ?? undefined;
699
- throw new ApiError(res.status, res.statusText, errorBody);
700
- }
701
- if (res.status === 204)
702
- return;
703
- const contentType = res.headers.get("content-type") ?? "";
704
- if (contentType.includes("application/json")) {
705
- try {
706
- const parsed = await res.json();
707
- checkDevWarnings(parsed);
708
- return parsed;
709
- } catch (err) {
710
- if (err instanceof SyntaxError)
711
- return;
712
- throw err;
713
- }
714
- }
715
- const rawText = await res.text().catch(() => "");
716
- return rawText && rawText.length > 0 ? rawText : undefined;
717
- }
718
- async function fetchManifest(deploymentUrl) {
719
- const manifestUrl = `${deploymentUrl.replace(/\/$/, "")}/playcademy.manifest.json`;
720
- try {
721
- const response = await fetch(manifestUrl);
722
- if (!response.ok) {
723
- log.error(`[fetchManifest] Failed to fetch manifest from ${manifestUrl}. Status: ${response.status}`);
724
- throw new PlaycademyError(`Failed to fetch manifest: ${response.status} ${response.statusText}`);
725
- }
726
- return await response.json();
727
- } catch (error) {
728
- if (error instanceof PlaycademyError) {
729
- throw error;
730
- }
731
- log.error(`[Playcademy SDK] Error fetching or parsing manifest from ${manifestUrl}:`, {
732
- error
733
- });
734
- throw new PlaycademyError("Failed to load or parse game manifest");
735
- }
736
- }
737
-
738
- // src/core/static/init.ts
739
- async function getPlaycademyConfig(allowedParentOrigins) {
740
- const preloaded = window.PLAYCADEMY;
741
- if (preloaded?.token) {
742
- return preloaded;
743
- }
744
- if (window.self !== window.top) {
745
- return await waitForPlaycademyInit(allowedParentOrigins);
746
- } else {
747
- return createStandaloneConfig();
748
- }
749
- }
750
- function getReferrerOrigin() {
751
- try {
752
- return document.referrer ? new URL(document.referrer).origin : null;
753
- } catch {
754
- return null;
755
- }
756
- }
757
- function buildAllowedOrigins(explicit) {
758
- if (Array.isArray(explicit) && explicit.length > 0)
759
- return explicit;
760
- const ref = getReferrerOrigin();
761
- return ref ? [ref] : [];
762
- }
763
- function isOriginAllowed(origin, allowlist) {
764
- if (window.location.hostname === "localhost" || window.location.hostname === "127.0.0.1") {
765
- return true;
766
- }
767
- if (!allowlist || allowlist.length === 0) {
768
- console.error("[Playcademy SDK] No allowed origins configured. Consider passing allowedParentOrigins explicitly to init().");
769
- return false;
770
- }
771
- return allowlist.includes(origin);
772
- }
773
- async function waitForPlaycademyInit(allowedParentOrigins) {
774
- return new Promise((resolve, reject) => {
775
- let contextReceived = false;
776
- const timeoutDuration = 5000;
777
- const allowlist = buildAllowedOrigins(allowedParentOrigins);
778
- let hasWarnedAboutUntrustedOrigin = false;
779
- function warnAboutUntrustedOrigin(origin) {
780
- if (hasWarnedAboutUntrustedOrigin)
781
- return;
782
- hasWarnedAboutUntrustedOrigin = true;
783
- console.warn("[Playcademy SDK] Ignoring INIT from untrusted origin:", origin);
784
- }
785
- const handleMessage = (event) => {
786
- if (event.data?.type !== "PLAYCADEMY_INIT" /* INIT */)
787
- return;
788
- if (!isOriginAllowed(event.origin, allowlist)) {
789
- warnAboutUntrustedOrigin(event.origin);
790
- return;
791
- }
792
- contextReceived = true;
793
- window.removeEventListener("message", handleMessage);
794
- clearTimeout(timeoutId);
795
- window.PLAYCADEMY = event.data.payload;
796
- resolve(event.data.payload);
797
- };
798
- window.addEventListener("message", handleMessage);
799
- const timeoutId = setTimeout(() => {
800
- if (!contextReceived) {
801
- window.removeEventListener("message", handleMessage);
802
- reject(new Error(`${"PLAYCADEMY_INIT" /* INIT */} not received within ${timeoutDuration}ms`));
803
- }
804
- }, timeoutDuration);
805
- });
806
- }
807
- function createStandaloneConfig() {
808
- console.debug("[Playcademy SDK] Standalone mode detected, creating mock context for sandbox development");
809
- const mockConfig = {
810
- baseUrl: "http://localhost:4321",
811
- gameUrl: window.location.origin,
812
- token: "mock-game-token-for-local-dev",
813
- gameId: "mock-game-id-from-template",
814
- realtimeUrl: undefined
815
- };
816
- window.PLAYCADEMY = mockConfig;
817
- return mockConfig;
818
- }
819
- async function init(options) {
820
- if (typeof window === "undefined") {
821
- throw new Error("Playcademy SDK must run in a browser context");
822
- }
823
- const config = await getPlaycademyConfig(options?.allowedParentOrigins);
824
- if (options?.baseUrl) {
825
- config.baseUrl = options.baseUrl;
826
- }
827
- const client = new this({
828
- baseUrl: config.baseUrl,
829
- gameUrl: config.gameUrl,
830
- token: config.token,
831
- gameId: config.gameId,
832
- autoStartSession: window.self !== window.top,
833
- onDisconnect: options?.onDisconnect,
834
- enableConnectionMonitoring: options?.enableConnectionMonitoring
835
- });
836
- client["initPayload"] = config;
837
- messaging.listen("PLAYCADEMY_TOKEN_REFRESH" /* TOKEN_REFRESH */, ({ token }) => client.setToken(token));
838
- messaging.send("PLAYCADEMY_READY" /* READY */, undefined);
839
- return client;
840
- }
841
- // src/core/static/login.ts
842
- async function login(baseUrl, email, password) {
843
- let url = baseUrl;
844
- if (baseUrl.startsWith("/") && typeof window !== "undefined") {
845
- url = window.location.origin + baseUrl;
846
- }
847
- url = url + "/auth/login";
848
- const response = await fetch(url, {
849
- method: "POST",
850
- headers: {
851
- "Content-Type": "application/json"
852
- },
853
- body: JSON.stringify({ email, password })
854
- });
855
- if (!response.ok) {
856
- try {
857
- const errorData = await response.json();
858
- const errorMessage = errorData && errorData.message ? String(errorData.message) : response.statusText;
859
- throw new PlaycademyError(errorMessage);
860
- } catch (error) {
861
- log.error("[Playcademy SDK] Failed to parse error response JSON, using status text instead:", { error });
862
- throw new PlaycademyError(response.statusText);
863
- }
864
- }
865
- return response.json();
866
- }
867
- // ../utils/src/random.ts
868
- async function generateSecureRandomString(length) {
869
- const charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~";
870
- const randomValues = new Uint8Array(length);
871
- globalThis.crypto.getRandomValues(randomValues);
872
- return Array.from(randomValues).map((byte) => charset[byte % charset.length]).join("");
873
- }
874
-
875
- // src/core/auth/oauth.ts
876
- function getTimebackConfig() {
877
- return {
878
- authorizationEndpoint: "https://alpha-auth-production-idp.auth.us-west-2.amazoncognito.com/oauth2/authorize",
879
- tokenEndpoint: "https://alpha-auth-production-idp.auth.us-west-2.amazoncognito.com/oauth2/token",
880
- scope: "openid email phone"
881
- };
882
- }
883
- var OAUTH_CONFIGS = {
884
- get TIMEBACK() {
885
- return getTimebackConfig;
886
- }
887
- };
888
- async function generateOAuthState(data) {
889
- const csrfToken = await generateSecureRandomString(32);
890
- if (data && Object.keys(data).length > 0) {
891
- const jsonStr = JSON.stringify(data);
892
- const base64 = btoa(jsonStr).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
893
- return `${csrfToken}.${base64}`;
894
- }
895
- return csrfToken;
896
- }
897
- function parseOAuthState(state) {
898
- const lastDotIndex = state.lastIndexOf(".");
899
- if (lastDotIndex > 0 && lastDotIndex < state.length - 1) {
900
- try {
901
- const csrfToken = state.substring(0, lastDotIndex);
902
- const base64 = state.substring(lastDotIndex + 1);
903
- const base64WithPadding = base64.replace(/-/g, "+").replace(/_/g, "/");
904
- const paddedBase64 = base64WithPadding + "=".repeat((4 - base64WithPadding.length % 4) % 4);
905
- const jsonStr = atob(paddedBase64);
906
- const data = JSON.parse(jsonStr);
907
- return { csrfToken, data };
908
- } catch {}
909
- }
910
- return { csrfToken: state };
911
- }
912
- function getOAuthConfig(provider) {
913
- const configGetter = OAUTH_CONFIGS[provider];
914
- if (!configGetter)
915
- throw new Error(`Unsupported auth provider: ${provider}`);
916
- return configGetter();
917
- }
918
-
919
- // src/core/static/identity.ts
920
- var identity = {
921
- parseOAuthState
922
- };
923
- // src/core/auth/flows/popup.ts
924
- async function initiatePopupFlow(options) {
925
- const { provider, callbackUrl, onStateChange, oauth } = options;
926
- try {
927
- onStateChange?.({
928
- status: "opening_popup",
929
- message: "Opening authentication window..."
930
- });
931
- const defaults = getOAuthConfig(provider);
932
- const config = oauth ? { ...defaults, ...oauth } : defaults;
933
- if (!config.clientId) {
934
- throw new Error(`clientId is required for ${provider} authentication. ` + "Please provide it in the oauth parameter.");
935
- }
936
- const stateData = options.stateData;
937
- const state = await generateOAuthState(stateData);
938
- const params = new URLSearchParams({
939
- response_type: "code",
940
- client_id: config.clientId,
941
- redirect_uri: callbackUrl,
942
- state
943
- });
944
- if (config.scope) {
945
- params.set("scope", config.scope);
946
- }
947
- const authUrl = `${config.authorizationEndpoint}?${params.toString()}`;
948
- const popup = openPopupWindow(authUrl, "playcademy-auth");
949
- if (!popup || popup.closed) {
950
- throw new Error("Popup blocked. Please enable popups and try again.");
951
- }
952
- onStateChange?.({
953
- status: "exchanging_token",
954
- message: "Waiting for authentication..."
955
- });
956
- return await waitForServerMessage(popup, onStateChange);
957
- } catch (error) {
958
- const errorMessage = error instanceof Error ? error.message : "Authentication failed";
959
- onStateChange?.({
960
- status: "error",
961
- message: errorMessage,
962
- error: error instanceof Error ? error : new Error(errorMessage)
963
- });
964
- throw error;
965
- }
966
- }
967
- async function waitForServerMessage(popup, onStateChange) {
968
- return new Promise((resolve) => {
969
- let resolved = false;
970
- const handleMessage = (event) => {
971
- if (event.origin !== window.location.origin)
972
- return;
973
- const data = event.data;
974
- if (data?.type === "PLAYCADEMY_AUTH_STATE_CHANGE") {
975
- resolved = true;
976
- window.removeEventListener("message", handleMessage);
977
- if (data.authenticated && data.user) {
978
- onStateChange?.({
979
- status: "complete",
980
- message: "Authentication successful"
981
- });
982
- resolve({
983
- success: true,
984
- user: data.user
985
- });
986
- } else {
987
- const error = new Error(data.error || "Authentication failed");
988
- onStateChange?.({
989
- status: "error",
990
- message: error.message,
991
- error
992
- });
993
- resolve({
994
- success: false,
995
- error
996
- });
997
- }
998
- }
999
- };
1000
- window.addEventListener("message", handleMessage);
1001
- const checkClosed = setInterval(() => {
1002
- if (popup.closed && !resolved) {
1003
- clearInterval(checkClosed);
1004
- window.removeEventListener("message", handleMessage);
1005
- const error = new Error("Authentication cancelled");
1006
- onStateChange?.({
1007
- status: "error",
1008
- message: error.message,
1009
- error
1010
- });
1011
- resolve({
1012
- success: false,
1013
- error
1014
- });
1015
- }
1016
- }, 500);
1017
- setTimeout(() => {
1018
- if (!resolved) {
1019
- window.removeEventListener("message", handleMessage);
1020
- clearInterval(checkClosed);
1021
- const error = new Error("Authentication timeout");
1022
- onStateChange?.({
1023
- status: "error",
1024
- message: error.message,
1025
- error
1026
- });
1027
- resolve({
1028
- success: false,
1029
- error
1030
- });
1031
- }
1032
- }, 5 * 60 * 1000);
1033
- });
1034
- }
1035
-
1036
- // src/core/auth/flows/redirect.ts
1037
- async function initiateRedirectFlow(options) {
1038
- const { provider, callbackUrl, onStateChange, oauth } = options;
1039
- try {
1040
- onStateChange?.({
1041
- status: "opening_popup",
1042
- message: "Redirecting to authentication provider..."
1043
- });
1044
- const defaults = getOAuthConfig(provider);
1045
- const config = oauth ? { ...defaults, ...oauth } : defaults;
1046
- if (!config.clientId) {
1047
- throw new Error(`clientId is required for ${provider} authentication. ` + "Please provide it in the oauth parameter.");
1048
- }
1049
- const stateData = options.stateData;
1050
- const state = await generateOAuthState(stateData);
1051
- const params = new URLSearchParams({
1052
- response_type: "code",
1053
- client_id: config.clientId,
1054
- redirect_uri: callbackUrl,
1055
- state
1056
- });
1057
- if (config.scope) {
1058
- params.set("scope", config.scope);
1059
- }
1060
- const authUrl = `${config.authorizationEndpoint}?${params.toString()}`;
1061
- window.location.href = authUrl;
1062
- return new Promise(() => {});
1063
- } catch (error) {
1064
- const errorMessage = error instanceof Error ? error.message : "Authentication failed";
1065
- onStateChange?.({
1066
- status: "error",
1067
- message: errorMessage,
1068
- error: error instanceof Error ? error : new Error(errorMessage)
1069
- });
1070
- throw error;
1071
- }
1072
- }
1073
-
1074
- // src/core/auth/flows/unified.ts
1075
- async function initiateUnifiedFlow(options) {
1076
- const { mode = "auto" } = options;
1077
- const effectiveMode = mode === "auto" ? isInIframe() ? "popup" : "redirect" : mode;
1078
- switch (effectiveMode) {
1079
- case "popup":
1080
- return initiatePopupFlow(options);
1081
- case "redirect":
1082
- return initiateRedirectFlow(options);
1083
- default:
1084
- throw new Error(`Unsupported authentication mode: ${effectiveMode}`);
1085
- }
1086
- }
1087
-
1088
- // src/core/auth/login.ts
1089
- async function login2(client, options) {
1090
- try {
1091
- let stateData = options.stateData;
1092
- if (!stateData) {
1093
- try {
1094
- const currentUser = await client.users.me();
1095
- if (currentUser?.id) {
1096
- stateData = { playcademy_user_id: currentUser.id };
1097
- }
1098
- } catch {
1099
- log.debug("[Playcademy SDK] No current user available for state data");
1100
- }
1101
- }
1102
- log.debug("[Playcademy SDK] Starting OAuth login", {
1103
- provider: options.provider,
1104
- mode: options.mode || "auto",
1105
- callbackUrl: options.callbackUrl,
1106
- hasStateData: !!stateData
1107
- });
1108
- const optionsWithState = {
1109
- ...options,
1110
- stateData
1111
- };
1112
- const result = await initiateUnifiedFlow(optionsWithState);
1113
- if (result.success && result.user) {
1114
- log.debug("[Playcademy SDK] OAuth login successful", {
1115
- userId: result.user.sub
1116
- });
1117
- }
1118
- return result;
1119
- } catch (error) {
1120
- log.error("[Playcademy SDK] OAuth login failed", { error });
1121
- const authError = error instanceof Error ? error : new Error("Authentication failed");
1122
- return {
1123
- success: false,
1124
- error: authError
720
+ success: false,
721
+ error: authError
1125
722
  };
1126
723
  }
1127
724
  }
@@ -1243,49 +840,53 @@ function createRuntimeNamespace(client) {
1243
840
  }
1244
841
  return counts;
1245
842
  },
1246
- assets: {
1247
- url(pathOrStrings, ...values) {
1248
- const gameUrl = client["initPayload"]?.gameUrl;
1249
- let path;
1250
- if (Array.isArray(pathOrStrings) && "raw" in pathOrStrings) {
1251
- const strings = pathOrStrings;
1252
- path = strings.reduce((acc, str, i) => {
1253
- return acc + str + (values[i] != null ? String(values[i]) : "");
1254
- }, "");
1255
- } else {
1256
- path = pathOrStrings;
1257
- }
1258
- if (!gameUrl) {
1259
- return path.startsWith("./") ? path : "./" + path;
1260
- }
1261
- const cleanPath = path.startsWith("./") ? path.slice(2) : path;
1262
- return gameUrl + cleanPath;
1263
- },
1264
- fetch: async (path, options) => {
1265
- const gameUrl = client["initPayload"]?.gameUrl;
1266
- if (!gameUrl) {
1267
- const relativePath = path.startsWith("./") ? path : "./" + path;
1268
- return fetch(relativePath, options);
1269
- }
1270
- const cleanPath = path.startsWith("./") ? path.slice(2) : path;
1271
- return fetch(gameUrl + cleanPath, options);
1272
- },
1273
- json: async (path) => {
1274
- const response = await client.runtime.assets.fetch(path);
1275
- return response.json();
1276
- },
1277
- blob: async (path) => {
1278
- const response = await client.runtime.assets.fetch(path);
1279
- return response.blob();
1280
- },
1281
- text: async (path) => {
1282
- const response = await client.runtime.assets.fetch(path);
1283
- return response.text();
1284
- },
1285
- arrayBuffer: async (path) => {
1286
- const response = await client.runtime.assets.fetch(path);
1287
- return response.arrayBuffer();
843
+ assets: createAssetsNamespace(client)
844
+ };
845
+ }
846
+ function createAssetsNamespace(client) {
847
+ const fetchAsset = async (path, options) => {
848
+ const gameUrl = client["initPayload"]?.gameUrl;
849
+ if (!gameUrl) {
850
+ const relativePath = path.startsWith("./") ? path : "./" + path;
851
+ return fetch(relativePath, options);
852
+ }
853
+ const cleanPath = path.startsWith("./") ? path.slice(2) : path;
854
+ return fetch(gameUrl + cleanPath, options);
855
+ };
856
+ return {
857
+ url(pathOrStrings, ...values) {
858
+ const gameUrl = client["initPayload"]?.gameUrl;
859
+ let path;
860
+ if (Array.isArray(pathOrStrings) && "raw" in pathOrStrings) {
861
+ const strings = pathOrStrings;
862
+ path = strings.reduce((acc, str, i) => {
863
+ return acc + str + (values[i] != null ? String(values[i]) : "");
864
+ }, "");
865
+ } else {
866
+ path = pathOrStrings;
867
+ }
868
+ if (!gameUrl) {
869
+ return path.startsWith("./") ? path : "./" + path;
1288
870
  }
871
+ const cleanPath = path.startsWith("./") ? path.slice(2) : path;
872
+ return gameUrl + cleanPath;
873
+ },
874
+ fetch: fetchAsset,
875
+ json: async (path) => {
876
+ const response = await fetchAsset(path);
877
+ return response.json();
878
+ },
879
+ blob: async (path) => {
880
+ const response = await fetchAsset(path);
881
+ return response.blob();
882
+ },
883
+ text: async (path) => {
884
+ const response = await fetchAsset(path);
885
+ return response.text();
886
+ },
887
+ arrayBuffer: async (path) => {
888
+ const response = await fetchAsset(path);
889
+ return response.arrayBuffer();
1289
890
  }
1290
891
  };
1291
892
  }
@@ -1334,579 +935,814 @@ function createPermanentCache(keyPrefix) {
1334
935
  const cache = new Map;
1335
936
  async function get(key, loader) {
1336
937
  const fullKey = keyPrefix ? `${keyPrefix}:${key}` : key;
1337
- const existing = cache.get(fullKey);
1338
- if (existing)
1339
- return existing;
938
+ const existing = cache.get(fullKey);
939
+ if (existing)
940
+ return existing;
941
+ const promise = loader().catch((error) => {
942
+ cache.delete(fullKey);
943
+ throw error;
944
+ });
945
+ cache.set(fullKey, promise);
946
+ return promise;
947
+ }
948
+ function clear(key) {
949
+ if (key === undefined) {
950
+ cache.clear();
951
+ } else {
952
+ const fullKey = keyPrefix ? `${keyPrefix}:${key}` : key;
953
+ cache.delete(fullKey);
954
+ }
955
+ }
956
+ function has(key) {
957
+ const fullKey = keyPrefix ? `${keyPrefix}:${key}` : key;
958
+ return cache.has(fullKey);
959
+ }
960
+ function size() {
961
+ return cache.size;
962
+ }
963
+ function keys() {
964
+ const result = [];
965
+ const prefixLen = keyPrefix ? keyPrefix.length + 1 : 0;
966
+ for (const fullKey of cache.keys()) {
967
+ result.push(fullKey.substring(prefixLen));
968
+ }
969
+ return result;
970
+ }
971
+ return { get, clear, has, size, keys };
972
+ }
973
+
974
+ // src/namespaces/game/users.ts
975
+ function createUsersNamespace(client) {
976
+ const itemIdCache = createPermanentCache("items");
977
+ const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
978
+ const resolveItemId = async (identifier) => {
979
+ if (UUID_REGEX.test(identifier))
980
+ return identifier;
981
+ const gameId = client["gameId"];
982
+ const cacheKey = gameId ? `${identifier}:${gameId}` : identifier;
983
+ return itemIdCache.get(cacheKey, async () => {
984
+ const queryParams = new URLSearchParams({ slug: identifier });
985
+ if (gameId)
986
+ queryParams.append("gameId", gameId);
987
+ const item = await client["request"](`/items/resolve?${queryParams.toString()}`, "GET");
988
+ return item.id;
989
+ });
990
+ };
991
+ return {
992
+ me: async () => {
993
+ return client["request"]("/users/me", "GET");
994
+ },
995
+ inventory: {
996
+ get: async () => client["request"](`/inventory`, "GET"),
997
+ add: async (identifier, qty) => {
998
+ const itemId = await resolveItemId(identifier);
999
+ const res = await client["request"](`/inventory/add`, "POST", { body: { itemId, qty } });
1000
+ client["emit"]("inventoryChange", {
1001
+ itemId,
1002
+ delta: qty,
1003
+ newTotal: res.newTotal
1004
+ });
1005
+ return res;
1006
+ },
1007
+ remove: async (identifier, qty) => {
1008
+ const itemId = await resolveItemId(identifier);
1009
+ const res = await client["request"](`/inventory/remove`, "POST", { body: { itemId, qty } });
1010
+ client["emit"]("inventoryChange", {
1011
+ itemId,
1012
+ delta: -qty,
1013
+ newTotal: res.newTotal
1014
+ });
1015
+ return res;
1016
+ },
1017
+ quantity: async (identifier) => {
1018
+ const itemId = await resolveItemId(identifier);
1019
+ const inventory = await client["request"](`/inventory`, "GET");
1020
+ const item = inventory.find((inv) => inv.item?.id === itemId);
1021
+ return item?.quantity ?? 0;
1022
+ },
1023
+ has: async (identifier, minQuantity = 1) => {
1024
+ const itemId = await resolveItemId(identifier);
1025
+ const inventory = await client["request"](`/inventory`, "GET");
1026
+ const item = inventory.find((inv) => inv.item?.id === itemId);
1027
+ const qty = item?.quantity ?? 0;
1028
+ return qty >= minQuantity;
1029
+ }
1030
+ }
1031
+ };
1032
+ }
1033
+ // ../constants/src/overworld.ts
1034
+ var ITEM_SLUGS = {
1035
+ PLAYCADEMY_CREDITS: "PLAYCADEMY_CREDITS",
1036
+ PLAYCADEMY_XP: "PLAYCADEMY_XP",
1037
+ FOUNDING_MEMBER_BADGE: "FOUNDING_MEMBER_BADGE",
1038
+ EARLY_ADOPTER_BADGE: "EARLY_ADOPTER_BADGE",
1039
+ FIRST_GAME_BADGE: "FIRST_GAME_BADGE",
1040
+ COMMON_SWORD: "COMMON_SWORD",
1041
+ SMALL_HEALTH_POTION: "SMALL_HEALTH_POTION",
1042
+ SMALL_BACKPACK: "SMALL_BACKPACK",
1043
+ LAVA_LAMP: "LAVA_LAMP",
1044
+ BOOMBOX: "BOOMBOX",
1045
+ CABIN_BED: "CABIN_BED"
1046
+ };
1047
+ var CURRENCIES = {
1048
+ PRIMARY: ITEM_SLUGS.PLAYCADEMY_CREDITS,
1049
+ XP: ITEM_SLUGS.PLAYCADEMY_XP
1050
+ };
1051
+ var BADGES = {
1052
+ FOUNDING_MEMBER: ITEM_SLUGS.FOUNDING_MEMBER_BADGE,
1053
+ EARLY_ADOPTER: ITEM_SLUGS.EARLY_ADOPTER_BADGE,
1054
+ FIRST_GAME: ITEM_SLUGS.FIRST_GAME_BADGE
1055
+ };
1056
+ // ../constants/src/timeback.ts
1057
+ var TIMEBACK_ROUTES = {
1058
+ END_ACTIVITY: "/integrations/timeback/end-activity"
1059
+ };
1060
+ // src/core/cache/singleton-cache.ts
1061
+ function createSingletonCache() {
1062
+ let cachedValue;
1063
+ let hasValue = false;
1064
+ async function get(loader) {
1065
+ if (hasValue) {
1066
+ return cachedValue;
1067
+ }
1068
+ const value = await loader();
1069
+ cachedValue = value;
1070
+ hasValue = true;
1071
+ return value;
1072
+ }
1073
+ function clear() {
1074
+ cachedValue = undefined;
1075
+ hasValue = false;
1076
+ }
1077
+ function has() {
1078
+ return hasValue;
1079
+ }
1080
+ return { get, clear, has };
1081
+ }
1082
+
1083
+ // src/namespaces/game/credits.ts
1084
+ function createCreditsNamespace(client) {
1085
+ const creditsIdCache = createSingletonCache();
1086
+ const getCreditsItemId = async () => {
1087
+ return creditsIdCache.get(async () => {
1088
+ const queryParams = new URLSearchParams({ slug: CURRENCIES.PRIMARY });
1089
+ const creditsItem = await client["request"](`/items/resolve?${queryParams.toString()}`, "GET");
1090
+ if (!creditsItem || !creditsItem.id) {
1091
+ throw new Error("Playcademy Credits item not found in catalog");
1092
+ }
1093
+ return creditsItem.id;
1094
+ });
1095
+ };
1096
+ return {
1097
+ balance: async () => {
1098
+ const inventory = await client["request"]("/inventory", "GET");
1099
+ const primaryCurrencyInventoryItem = inventory.find((item) => item.item?.slug === CURRENCIES.PRIMARY);
1100
+ return primaryCurrencyInventoryItem?.quantity ?? 0;
1101
+ },
1102
+ add: async (amount) => {
1103
+ if (amount <= 0) {
1104
+ throw new Error("Amount must be positive");
1105
+ }
1106
+ const creditsItemId = await getCreditsItemId();
1107
+ const result = await client["request"]("/inventory/add", "POST", {
1108
+ body: {
1109
+ itemId: creditsItemId,
1110
+ qty: amount
1111
+ }
1112
+ });
1113
+ client["emit"]("inventoryChange", {
1114
+ itemId: creditsItemId,
1115
+ delta: amount,
1116
+ newTotal: result.newTotal
1117
+ });
1118
+ return result.newTotal;
1119
+ },
1120
+ spend: async (amount) => {
1121
+ if (amount <= 0) {
1122
+ throw new Error("Amount must be positive");
1123
+ }
1124
+ const creditsItemId = await getCreditsItemId();
1125
+ const result = await client["request"]("/inventory/remove", "POST", {
1126
+ body: {
1127
+ itemId: creditsItemId,
1128
+ qty: amount
1129
+ }
1130
+ });
1131
+ client["emit"]("inventoryChange", {
1132
+ itemId: creditsItemId,
1133
+ delta: -amount,
1134
+ newTotal: result.newTotal
1135
+ });
1136
+ return result.newTotal;
1137
+ }
1138
+ };
1139
+ }
1140
+ // src/namespaces/game/scores.ts
1141
+ function createScoresNamespace(client) {
1142
+ return {
1143
+ submit: async (gameId, score, metadata) => {
1144
+ return client["request"](`/games/${gameId}/scores`, "POST", {
1145
+ body: {
1146
+ score,
1147
+ metadata
1148
+ }
1149
+ });
1150
+ }
1151
+ };
1152
+ }
1153
+ // src/namespaces/game/realtime.ts
1154
+ function createRealtimeNamespace(client) {
1155
+ return {
1156
+ token: {
1157
+ get: async () => {
1158
+ const endpoint = client["gameId"] ? `/games/${client["gameId"]}/realtime/token` : "/realtime/token";
1159
+ return client["request"](endpoint, "POST");
1160
+ }
1161
+ }
1162
+ };
1163
+ }
1164
+ // src/core/cache/ttl-cache.ts
1165
+ function createTTLCache(options) {
1166
+ const cache = new Map;
1167
+ const { ttl: defaultTTL, keyPrefix = "", onClear } = options;
1168
+ async function get(key, loader, config) {
1169
+ const fullKey = keyPrefix ? `${keyPrefix}:${key}` : key;
1170
+ const now = Date.now();
1171
+ const effectiveTTL = config?.ttl !== undefined ? config.ttl : defaultTTL;
1172
+ const force = config?.force || false;
1173
+ const skipCache = config?.skipCache || false;
1174
+ if (effectiveTTL === 0 || skipCache) {
1175
+ return loader();
1176
+ }
1177
+ if (!force) {
1178
+ const cached = cache.get(fullKey);
1179
+ if (cached && cached.expiresAt > now) {
1180
+ return cached.value;
1181
+ }
1182
+ }
1340
1183
  const promise = loader().catch((error) => {
1341
1184
  cache.delete(fullKey);
1342
1185
  throw error;
1343
1186
  });
1344
- cache.set(fullKey, promise);
1187
+ cache.set(fullKey, {
1188
+ value: promise,
1189
+ expiresAt: now + effectiveTTL
1190
+ });
1345
1191
  return promise;
1346
1192
  }
1347
1193
  function clear(key) {
1348
1194
  if (key === undefined) {
1349
1195
  cache.clear();
1196
+ onClear?.();
1350
1197
  } else {
1351
1198
  const fullKey = keyPrefix ? `${keyPrefix}:${key}` : key;
1352
1199
  cache.delete(fullKey);
1353
1200
  }
1354
1201
  }
1355
- function has(key) {
1356
- const fullKey = keyPrefix ? `${keyPrefix}:${key}` : key;
1357
- return cache.has(fullKey);
1358
- }
1359
1202
  function size() {
1360
1203
  return cache.size;
1361
1204
  }
1362
- function keys() {
1363
- const result = [];
1205
+ function prune() {
1206
+ const now = Date.now();
1207
+ for (const [key, entry] of cache.entries()) {
1208
+ if (entry.expiresAt <= now) {
1209
+ cache.delete(key);
1210
+ }
1211
+ }
1212
+ }
1213
+ function getKeys() {
1214
+ const keys = [];
1364
1215
  const prefixLen = keyPrefix ? keyPrefix.length + 1 : 0;
1365
1216
  for (const fullKey of cache.keys()) {
1366
- result.push(fullKey.substring(prefixLen));
1217
+ keys.push(fullKey.substring(prefixLen));
1367
1218
  }
1368
- return result;
1219
+ return keys;
1369
1220
  }
1370
- return { get, clear, has, size, keys };
1221
+ function has(key) {
1222
+ const fullKey = keyPrefix ? `${keyPrefix}:${key}` : key;
1223
+ const cached = cache.get(fullKey);
1224
+ if (!cached)
1225
+ return false;
1226
+ const now = Date.now();
1227
+ if (cached.expiresAt <= now) {
1228
+ cache.delete(fullKey);
1229
+ return false;
1230
+ }
1231
+ return true;
1232
+ }
1233
+ return { get, clear, size, prune, getKeys, has };
1371
1234
  }
1372
1235
 
1373
- // src/namespaces/game/users.ts
1374
- function createUsersNamespace(client) {
1375
- const itemIdCache = createPermanentCache("items");
1376
- const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
1377
- const resolveItemId = async (identifier) => {
1378
- if (UUID_REGEX.test(identifier))
1379
- return identifier;
1380
- const gameId = client["gameId"];
1381
- const cacheKey = gameId ? `${identifier}:${gameId}` : identifier;
1382
- return itemIdCache.get(cacheKey, async () => {
1383
- const queryParams = new URLSearchParams({ slug: identifier });
1384
- if (gameId)
1385
- queryParams.append("gameId", gameId);
1386
- const item = await client["request"](`/items/resolve?${queryParams.toString()}`, "GET");
1387
- return item.id;
1388
- });
1389
- };
1236
+ // src/namespaces/game/timeback.ts
1237
+ function createTimebackNamespace(client) {
1238
+ let currentActivity = null;
1239
+ const userCache = createTTLCache({
1240
+ ttl: 5 * 60 * 1000,
1241
+ keyPrefix: "game.timeback.user"
1242
+ });
1243
+ const getTimeback = () => client["initPayload"]?.timeback;
1390
1244
  return {
1391
- me: async () => {
1392
- return client["request"]("/users/me", "GET");
1245
+ get user() {
1246
+ return {
1247
+ get id() {
1248
+ return getTimeback()?.id;
1249
+ },
1250
+ get role() {
1251
+ return getTimeback()?.role;
1252
+ },
1253
+ get enrollments() {
1254
+ return getTimeback()?.enrollments ?? [];
1255
+ },
1256
+ get organizations() {
1257
+ return getTimeback()?.organizations ?? [];
1258
+ },
1259
+ fetch: async (options) => {
1260
+ return userCache.get("current", async () => {
1261
+ const response = await client["request"]("/timeback/user", "GET");
1262
+ const initPayload = client["initPayload"];
1263
+ if (initPayload) {
1264
+ initPayload.timeback = response;
1265
+ }
1266
+ return {
1267
+ id: response.id,
1268
+ role: response.role,
1269
+ enrollments: response.enrollments,
1270
+ organizations: response.organizations
1271
+ };
1272
+ }, options);
1273
+ }
1274
+ };
1393
1275
  },
1394
- inventory: {
1395
- get: async () => client["request"](`/inventory`, "GET"),
1396
- add: async (identifier, qty) => {
1397
- const itemId = await resolveItemId(identifier);
1398
- const res = await client["request"](`/inventory/add`, "POST", { body: { itemId, qty } });
1399
- client["emit"]("inventoryChange", {
1400
- itemId,
1401
- delta: qty,
1402
- newTotal: res.newTotal
1403
- });
1404
- return res;
1405
- },
1406
- remove: async (identifier, qty) => {
1407
- const itemId = await resolveItemId(identifier);
1408
- const res = await client["request"](`/inventory/remove`, "POST", { body: { itemId, qty } });
1409
- client["emit"]("inventoryChange", {
1410
- itemId,
1411
- delta: -qty,
1412
- newTotal: res.newTotal
1413
- });
1414
- return res;
1415
- },
1416
- quantity: async (identifier) => {
1417
- const itemId = await resolveItemId(identifier);
1418
- const inventory = await client["request"](`/inventory`, "GET");
1419
- const item = inventory.find((inv) => inv.item?.id === itemId);
1420
- return item?.quantity ?? 0;
1421
- },
1422
- has: async (identifier, minQuantity = 1) => {
1423
- const itemId = await resolveItemId(identifier);
1424
- const inventory = await client["request"](`/inventory`, "GET");
1425
- const item = inventory.find((inv) => inv.item?.id === itemId);
1426
- const qty = item?.quantity ?? 0;
1427
- return qty >= minQuantity;
1276
+ startActivity: (metadata) => {
1277
+ currentActivity = {
1278
+ startTime: Date.now(),
1279
+ metadata,
1280
+ pausedTime: 0,
1281
+ pauseStartTime: null
1282
+ };
1283
+ },
1284
+ pauseActivity: () => {
1285
+ if (!currentActivity) {
1286
+ throw new Error("No activity in progress. Call startActivity() before pauseActivity().");
1287
+ }
1288
+ if (currentActivity.pauseStartTime !== null) {
1289
+ throw new Error("Activity is already paused.");
1290
+ }
1291
+ currentActivity.pauseStartTime = Date.now();
1292
+ },
1293
+ resumeActivity: () => {
1294
+ if (!currentActivity) {
1295
+ throw new Error("No activity in progress. Call startActivity() before resumeActivity().");
1296
+ }
1297
+ if (currentActivity.pauseStartTime === null) {
1298
+ throw new Error("Activity is not paused.");
1299
+ }
1300
+ const pauseDuration = Date.now() - currentActivity.pauseStartTime;
1301
+ currentActivity.pausedTime += pauseDuration;
1302
+ currentActivity.pauseStartTime = null;
1303
+ },
1304
+ endActivity: async (data) => {
1305
+ if (!currentActivity) {
1306
+ throw new Error("No activity in progress. Call startActivity() before endActivity().");
1307
+ }
1308
+ if (currentActivity.pauseStartTime !== null) {
1309
+ const pauseDuration = Date.now() - currentActivity.pauseStartTime;
1310
+ currentActivity.pausedTime += pauseDuration;
1311
+ currentActivity.pauseStartTime = null;
1312
+ }
1313
+ const endTime = Date.now();
1314
+ const totalElapsed = endTime - currentActivity.startTime;
1315
+ const activeTime = totalElapsed - currentActivity.pausedTime;
1316
+ const durationSeconds = Math.floor(activeTime / 1000);
1317
+ const { correctQuestions, totalQuestions } = data;
1318
+ const request = {
1319
+ activityData: currentActivity.metadata,
1320
+ scoreData: {
1321
+ correctQuestions,
1322
+ totalQuestions
1323
+ },
1324
+ timingData: {
1325
+ durationSeconds
1326
+ },
1327
+ xpEarned: data.xpAwarded,
1328
+ masteredUnits: data.masteredUnits
1329
+ };
1330
+ try {
1331
+ const response = await client["requestGameBackend"](TIMEBACK_ROUTES.END_ACTIVITY, "POST", request);
1332
+ currentActivity = null;
1333
+ return response;
1334
+ } catch (error) {
1335
+ currentActivity = null;
1336
+ throw error;
1428
1337
  }
1429
1338
  }
1430
1339
  };
1431
1340
  }
1432
- // ../constants/src/overworld.ts
1433
- var ITEM_SLUGS = {
1434
- PLAYCADEMY_CREDITS: "PLAYCADEMY_CREDITS",
1435
- PLAYCADEMY_XP: "PLAYCADEMY_XP",
1436
- FOUNDING_MEMBER_BADGE: "FOUNDING_MEMBER_BADGE",
1437
- EARLY_ADOPTER_BADGE: "EARLY_ADOPTER_BADGE",
1438
- FIRST_GAME_BADGE: "FIRST_GAME_BADGE",
1439
- COMMON_SWORD: "COMMON_SWORD",
1440
- SMALL_HEALTH_POTION: "SMALL_HEALTH_POTION",
1441
- SMALL_BACKPACK: "SMALL_BACKPACK",
1442
- LAVA_LAMP: "LAVA_LAMP",
1443
- BOOMBOX: "BOOMBOX",
1444
- CABIN_BED: "CABIN_BED"
1445
- };
1446
- var CURRENCIES = {
1447
- PRIMARY: ITEM_SLUGS.PLAYCADEMY_CREDITS,
1448
- XP: ITEM_SLUGS.PLAYCADEMY_XP
1449
- };
1450
- var BADGES = {
1451
- FOUNDING_MEMBER: ITEM_SLUGS.FOUNDING_MEMBER_BADGE,
1452
- EARLY_ADOPTER: ITEM_SLUGS.EARLY_ADOPTER_BADGE,
1453
- FIRST_GAME: ITEM_SLUGS.FIRST_GAME_BADGE
1454
- };
1455
- // ../constants/src/timeback.ts
1456
- var TIMEBACK_ROUTES = {
1457
- END_ACTIVITY: "/integrations/timeback/end-activity"
1458
- };
1459
- // src/core/cache/singleton-cache.ts
1460
- function createSingletonCache() {
1461
- let cachedValue;
1462
- let hasValue = false;
1463
- async function get(loader) {
1464
- if (hasValue) {
1465
- return cachedValue;
1466
- }
1467
- const value = await loader();
1468
- cachedValue = value;
1469
- hasValue = true;
1470
- return value;
1341
+ // src/core/auth/strategies.ts
1342
+ class ApiKeyAuth {
1343
+ apiKey;
1344
+ constructor(apiKey) {
1345
+ this.apiKey = apiKey;
1346
+ }
1347
+ getToken() {
1348
+ return this.apiKey;
1349
+ }
1350
+ getType() {
1351
+ return "apiKey";
1352
+ }
1353
+ getHeaders() {
1354
+ return { "x-api-key": this.apiKey };
1355
+ }
1356
+ }
1357
+
1358
+ class SessionAuth {
1359
+ sessionToken;
1360
+ constructor(sessionToken) {
1361
+ this.sessionToken = sessionToken;
1362
+ }
1363
+ getToken() {
1364
+ return this.sessionToken;
1365
+ }
1366
+ getType() {
1367
+ return "session";
1368
+ }
1369
+ getHeaders() {
1370
+ return { Authorization: `Bearer ${this.sessionToken}` };
1371
+ }
1372
+ }
1373
+
1374
+ class GameJwtAuth {
1375
+ gameToken;
1376
+ constructor(gameToken) {
1377
+ this.gameToken = gameToken;
1378
+ }
1379
+ getToken() {
1380
+ return this.gameToken;
1381
+ }
1382
+ getType() {
1383
+ return "gameJwt";
1384
+ }
1385
+ getHeaders() {
1386
+ return { Authorization: `Bearer ${this.gameToken}` };
1387
+ }
1388
+ }
1389
+
1390
+ class NoAuth {
1391
+ getToken() {
1392
+ return null;
1393
+ }
1394
+ getType() {
1395
+ return "session";
1396
+ }
1397
+ getHeaders() {
1398
+ return {};
1399
+ }
1400
+ }
1401
+ function createAuthStrategy(token, tokenType) {
1402
+ if (!token) {
1403
+ return new NoAuth;
1404
+ }
1405
+ if (tokenType === "apiKey") {
1406
+ return new ApiKeyAuth(token);
1407
+ }
1408
+ if (tokenType === "session") {
1409
+ return new SessionAuth(token);
1471
1410
  }
1472
- function clear() {
1473
- cachedValue = undefined;
1474
- hasValue = false;
1411
+ if (tokenType === "gameJwt") {
1412
+ return new GameJwtAuth(token);
1475
1413
  }
1476
- function has() {
1477
- return hasValue;
1414
+ if (token.startsWith("cademy")) {
1415
+ return new ApiKeyAuth(token);
1478
1416
  }
1479
- return { get, clear, has };
1417
+ return new GameJwtAuth(token);
1480
1418
  }
1481
1419
 
1482
- // src/namespaces/game/credits.ts
1483
- function createCreditsNamespace(client) {
1484
- const creditsIdCache = createSingletonCache();
1485
- const getCreditsItemId = async () => {
1486
- return creditsIdCache.get(async () => {
1487
- const queryParams = new URLSearchParams({ slug: CURRENCIES.PRIMARY });
1488
- const creditsItem = await client["request"](`/items/resolve?${queryParams.toString()}`, "GET");
1489
- if (!creditsItem || !creditsItem.id) {
1490
- throw new Error("Playcademy Credits item not found in catalog");
1420
+ // src/core/connection/monitor.ts
1421
+ class ConnectionMonitor {
1422
+ state = "online";
1423
+ callbacks = new Set;
1424
+ heartbeatInterval;
1425
+ consecutiveFailures = 0;
1426
+ isMonitoring = false;
1427
+ config;
1428
+ constructor(config) {
1429
+ this.config = {
1430
+ baseUrl: config.baseUrl,
1431
+ heartbeatInterval: config.heartbeatInterval ?? 1e4,
1432
+ heartbeatTimeout: config.heartbeatTimeout ?? 5000,
1433
+ failureThreshold: config.failureThreshold ?? 2,
1434
+ enableHeartbeat: config.enableHeartbeat ?? true,
1435
+ enableOfflineEvents: config.enableOfflineEvents ?? true
1436
+ };
1437
+ this._detectInitialState();
1438
+ }
1439
+ start() {
1440
+ if (this.isMonitoring)
1441
+ return;
1442
+ this.isMonitoring = true;
1443
+ if (this.config.enableOfflineEvents && typeof window !== "undefined") {
1444
+ window.addEventListener("online", this._handleOnline);
1445
+ window.addEventListener("offline", this._handleOffline);
1446
+ }
1447
+ if (this.config.enableHeartbeat) {
1448
+ this._startHeartbeat();
1449
+ }
1450
+ }
1451
+ stop() {
1452
+ if (!this.isMonitoring)
1453
+ return;
1454
+ this.isMonitoring = false;
1455
+ if (typeof window !== "undefined") {
1456
+ window.removeEventListener("online", this._handleOnline);
1457
+ window.removeEventListener("offline", this._handleOffline);
1458
+ }
1459
+ if (this.heartbeatInterval) {
1460
+ clearInterval(this.heartbeatInterval);
1461
+ this.heartbeatInterval = undefined;
1462
+ }
1463
+ }
1464
+ onChange(callback) {
1465
+ this.callbacks.add(callback);
1466
+ return () => this.callbacks.delete(callback);
1467
+ }
1468
+ getState() {
1469
+ return this.state;
1470
+ }
1471
+ async checkNow() {
1472
+ await this._performHeartbeat();
1473
+ return this.state;
1474
+ }
1475
+ reportRequestFailure(error) {
1476
+ const isNetworkError = error instanceof TypeError || error instanceof Error && error.message.includes("fetch");
1477
+ if (!isNetworkError)
1478
+ return;
1479
+ this.consecutiveFailures++;
1480
+ if (this.consecutiveFailures >= this.config.failureThreshold) {
1481
+ this._setState("degraded", "Multiple consecutive request failures");
1482
+ }
1483
+ }
1484
+ reportRequestSuccess() {
1485
+ if (this.consecutiveFailures > 0) {
1486
+ this.consecutiveFailures = 0;
1487
+ if (this.state === "degraded") {
1488
+ this._setState("online", "Requests succeeding again");
1491
1489
  }
1492
- return creditsItem.id;
1493
- });
1490
+ }
1491
+ }
1492
+ _detectInitialState() {
1493
+ if (typeof navigator !== "undefined" && !navigator.onLine) {
1494
+ this.state = "offline";
1495
+ }
1496
+ }
1497
+ _handleOnline = () => {
1498
+ this.consecutiveFailures = 0;
1499
+ this._setState("online", "Browser online event");
1494
1500
  };
1495
- return {
1496
- balance: async () => {
1497
- const inventory = await client["request"]("/inventory", "GET");
1498
- const primaryCurrencyInventoryItem = inventory.find((item) => item.item?.slug === CURRENCIES.PRIMARY);
1499
- return primaryCurrencyInventoryItem?.quantity ?? 0;
1500
- },
1501
- add: async (amount) => {
1502
- if (amount <= 0) {
1503
- throw new Error("Amount must be positive");
1504
- }
1505
- const creditsItemId = await getCreditsItemId();
1506
- const result = await client["request"]("/inventory/add", "POST", {
1507
- body: {
1508
- itemId: creditsItemId,
1509
- qty: amount
1510
- }
1511
- });
1512
- client["emit"]("inventoryChange", {
1513
- itemId: creditsItemId,
1514
- delta: amount,
1515
- newTotal: result.newTotal
1501
+ _handleOffline = () => {
1502
+ this._setState("offline", "Browser offline event");
1503
+ };
1504
+ _startHeartbeat() {
1505
+ this._performHeartbeat();
1506
+ this.heartbeatInterval = setInterval(() => {
1507
+ this._performHeartbeat();
1508
+ }, this.config.heartbeatInterval);
1509
+ }
1510
+ async _performHeartbeat() {
1511
+ if (typeof navigator !== "undefined" && !navigator.onLine) {
1512
+ return;
1513
+ }
1514
+ try {
1515
+ const controller = new AbortController;
1516
+ const timeoutId = setTimeout(() => controller.abort(), this.config.heartbeatTimeout);
1517
+ const response = await fetch(`${this.config.baseUrl}/ping`, {
1518
+ method: "GET",
1519
+ signal: controller.signal,
1520
+ cache: "no-store"
1516
1521
  });
1517
- return result.newTotal;
1518
- },
1519
- spend: async (amount) => {
1520
- if (amount <= 0) {
1521
- throw new Error("Amount must be positive");
1522
- }
1523
- const creditsItemId = await getCreditsItemId();
1524
- const result = await client["request"]("/inventory/remove", "POST", {
1525
- body: {
1526
- itemId: creditsItemId,
1527
- qty: amount
1522
+ clearTimeout(timeoutId);
1523
+ if (response.ok) {
1524
+ this.consecutiveFailures = 0;
1525
+ if (this.state !== "online") {
1526
+ this._setState("online", "Heartbeat successful");
1528
1527
  }
1529
- });
1530
- client["emit"]("inventoryChange", {
1531
- itemId: creditsItemId,
1532
- delta: -amount,
1533
- newTotal: result.newTotal
1534
- });
1535
- return result.newTotal;
1528
+ } else {
1529
+ this._handleHeartbeatFailure("Heartbeat returned non-OK status");
1530
+ }
1531
+ } catch (error) {
1532
+ this._handleHeartbeatFailure(error instanceof Error ? error.message : "Heartbeat failed");
1536
1533
  }
1537
- };
1534
+ }
1535
+ _handleHeartbeatFailure(reason) {
1536
+ this.consecutiveFailures++;
1537
+ if (this.consecutiveFailures >= this.config.failureThreshold) {
1538
+ if (typeof navigator !== "undefined" && !navigator.onLine) {
1539
+ this._setState("offline", reason);
1540
+ } else {
1541
+ this._setState("degraded", reason);
1542
+ }
1543
+ }
1544
+ }
1545
+ _setState(newState, reason) {
1546
+ if (this.state === newState)
1547
+ return;
1548
+ const oldState = this.state;
1549
+ this.state = newState;
1550
+ console.debug(`[ConnectionMonitor] ${oldState} → ${newState}: ${reason}`);
1551
+ this.callbacks.forEach((callback) => {
1552
+ try {
1553
+ callback(newState, reason);
1554
+ } catch (error) {
1555
+ console.error("[ConnectionMonitor] Error in callback:", error);
1556
+ }
1557
+ });
1558
+ }
1538
1559
  }
1539
- // src/namespaces/game/scores.ts
1540
- function createScoresNamespace(client) {
1541
- return {
1542
- submit: async (gameId, score, metadata) => {
1543
- return client["request"](`/games/${gameId}/scores`, "POST", {
1544
- body: {
1545
- score,
1546
- metadata
1547
- }
1548
- });
1560
+ // src/core/connection/utils.ts
1561
+ function createDisplayAlert(authContext) {
1562
+ return (message, options) => {
1563
+ if (authContext?.isInIframe && typeof window !== "undefined" && window.parent !== window) {
1564
+ window.parent.postMessage({
1565
+ type: "PLAYCADEMY_DISPLAY_ALERT",
1566
+ message,
1567
+ options
1568
+ }, "*");
1569
+ } else {
1570
+ const prefix = options?.type === "error" ? "❌" : options?.type === "warning" ? "⚠️" : "ℹ️";
1571
+ console.log(`${prefix} ${message}`);
1549
1572
  }
1550
1573
  };
1551
1574
  }
1552
- // src/namespaces/game/realtime.client.ts
1553
- var CLOSE_CODES = {
1554
- NORMAL_CLOSURE: 1000,
1555
- TOKEN_REFRESH: 4000
1556
- };
1557
1575
 
1558
- class RealtimeChannelClient {
1559
- gameId;
1560
- _channelName;
1561
- getToken;
1562
- baseUrl;
1563
- ws;
1564
- listeners = new Set;
1565
- isClosing = false;
1566
- tokenRefreshUnsubscribe;
1567
- constructor(gameId, _channelName, getToken, baseUrl) {
1568
- this.gameId = gameId;
1569
- this._channelName = _channelName;
1570
- this.getToken = getToken;
1571
- this.baseUrl = baseUrl;
1572
- }
1573
- async connect() {
1574
- try {
1575
- const token = await this.getToken();
1576
- let wsBase;
1577
- if (/^ws(s)?:\/\//.test(this.baseUrl)) {
1578
- wsBase = this.baseUrl;
1579
- } else if (/^http(s)?:\/\//.test(this.baseUrl)) {
1580
- wsBase = this.baseUrl.replace(/^http/, "ws");
1581
- } else {
1582
- const isBrowser2 = typeof window !== "undefined";
1583
- if (isBrowser2) {
1584
- const proto = window.location.protocol === "https:" ? "wss" : "ws";
1585
- wsBase = `${proto}://${window.location.host}${this.baseUrl}`;
1586
- } else {
1587
- wsBase = `ws://${this.baseUrl.replace(/^\//, "")}`;
1588
- }
1589
- }
1590
- const url = new URL(wsBase);
1591
- url.searchParams.set("token", token);
1592
- url.searchParams.set("c", this._channelName);
1593
- this.ws = new WebSocket(url);
1594
- this.setupEventHandlers();
1595
- this.setupTokenRefreshListener();
1596
- await new Promise((resolve, reject) => {
1597
- if (!this.ws) {
1598
- reject(new Error("WebSocket creation failed"));
1599
- return;
1600
- }
1601
- const onOpen = () => {
1602
- this.ws?.removeEventListener("open", onOpen);
1603
- this.ws?.removeEventListener("error", onError);
1604
- resolve();
1605
- };
1606
- const onError = (event) => {
1607
- this.ws?.removeEventListener("open", onOpen);
1608
- this.ws?.removeEventListener("error", onError);
1609
- reject(new Error(`WebSocket connection failed: ${event}`));
1610
- };
1611
- this.ws.addEventListener("open", onOpen);
1612
- this.ws.addEventListener("error", onError);
1613
- });
1614
- log.debug("[RealtimeChannelClient] Connected to channel", {
1615
- gameId: this.gameId,
1616
- channel: this._channelName
1617
- });
1618
- return this;
1619
- } catch (error) {
1620
- log.error("[RealtimeChannelClient] Connection failed", {
1621
- gameId: this.gameId,
1622
- channel: this._channelName,
1623
- error
1624
- });
1625
- throw error;
1576
+ // src/core/connection/manager.ts
1577
+ class ConnectionManager {
1578
+ monitor;
1579
+ authContext;
1580
+ disconnectHandler;
1581
+ connectionChangeCallback;
1582
+ currentState = "online";
1583
+ additionalDisconnectHandlers = new Set;
1584
+ constructor(config) {
1585
+ this.authContext = config.authContext;
1586
+ this.disconnectHandler = config.onDisconnect;
1587
+ this.connectionChangeCallback = config.onConnectionChange;
1588
+ if (config.authContext?.isInIframe) {
1589
+ this._setupPlatformListener();
1590
+ }
1591
+ }
1592
+ getState() {
1593
+ return this.monitor?.getState() ?? this.currentState;
1594
+ }
1595
+ async checkNow() {
1596
+ if (!this.monitor) {
1597
+ return this.currentState;
1626
1598
  }
1599
+ return await this.monitor.checkNow();
1627
1600
  }
1628
- setupEventHandlers() {
1629
- if (!this.ws)
1630
- return;
1631
- this.ws.onmessage = (event) => {
1632
- try {
1633
- const data = JSON.parse(event.data);
1634
- this.listeners.forEach((callback) => {
1635
- try {
1636
- callback(data);
1637
- } catch (error) {
1638
- log.warn("[RealtimeChannelClient] Message listener error", {
1639
- channel: this._channelName,
1640
- error
1641
- });
1642
- }
1643
- });
1644
- } catch (error) {
1645
- log.warn("[RealtimeChannelClient] Failed to parse message", {
1646
- channel: this._channelName,
1647
- message: event.data,
1648
- error
1649
- });
1650
- }
1651
- };
1652
- this.ws.onclose = (event) => {
1653
- log.debug("[RealtimeChannelClient] Connection closed", {
1654
- channel: this._channelName,
1655
- code: event.code,
1656
- reason: event.reason,
1657
- wasClean: event.wasClean
1658
- });
1659
- if (!this.isClosing && event.code !== CLOSE_CODES.TOKEN_REFRESH) {
1660
- log.warn("[RealtimeChannelClient] Unexpected disconnection", {
1661
- channel: this._channelName,
1662
- code: event.code,
1663
- reason: event.reason
1664
- });
1665
- }
1666
- };
1667
- this.ws.onerror = (event) => {
1668
- log.error("[RealtimeChannelClient] WebSocket error", {
1669
- channel: this._channelName,
1670
- event
1671
- });
1672
- };
1601
+ reportRequestSuccess() {
1602
+ this.monitor?.reportRequestSuccess();
1673
1603
  }
1674
- setupTokenRefreshListener() {
1675
- const tokenRefreshHandler = async ({ token }) => {
1676
- log.debug("[RealtimeChannelClient] Token refresh received, reconnecting", {
1677
- channel: this._channelName
1678
- });
1679
- try {
1680
- await this.reconnectWithNewToken(token);
1681
- } catch (error) {
1682
- log.error("[RealtimeChannelClient] Token refresh reconnection failed", {
1683
- channel: this._channelName,
1684
- error
1685
- });
1686
- }
1687
- };
1688
- messaging.listen("PLAYCADEMY_TOKEN_REFRESH" /* TOKEN_REFRESH */, tokenRefreshHandler);
1689
- this.tokenRefreshUnsubscribe = () => {
1690
- messaging.unlisten("PLAYCADEMY_TOKEN_REFRESH" /* TOKEN_REFRESH */, tokenRefreshHandler);
1604
+ reportRequestFailure(error) {
1605
+ this.monitor?.reportRequestFailure(error);
1606
+ }
1607
+ onDisconnect(callback) {
1608
+ this.additionalDisconnectHandlers.add(callback);
1609
+ return () => {
1610
+ this.additionalDisconnectHandlers.delete(callback);
1691
1611
  };
1692
1612
  }
1693
- async reconnectWithNewToken(_token) {
1694
- if (this.ws && this.ws.readyState === WebSocket.OPEN) {
1695
- this.ws.close(CLOSE_CODES.TOKEN_REFRESH, "token_refresh");
1696
- await new Promise((resolve) => {
1697
- const checkClosed = () => {
1698
- if (!this.ws || this.ws.readyState === WebSocket.CLOSED) {
1699
- resolve();
1700
- } else {
1701
- setTimeout(checkClosed, 10);
1702
- }
1703
- };
1704
- checkClosed();
1705
- });
1706
- }
1707
- await this.connect();
1613
+ stop() {
1614
+ this.monitor?.stop();
1708
1615
  }
1709
- send(data) {
1710
- if (this.ws?.readyState === WebSocket.OPEN) {
1711
- try {
1712
- const message = JSON.stringify(data);
1713
- this.ws.send(message);
1714
- } catch (error) {
1715
- log.error("[RealtimeChannelClient] Failed to send message", {
1716
- channel: this._channelName,
1717
- error,
1718
- data
1719
- });
1616
+ _setupPlatformListener() {
1617
+ messaging.listen("PLAYCADEMY_CONNECTION_STATE" /* CONNECTION_STATE */, ({ state, reason }) => {
1618
+ this.currentState = state;
1619
+ this._handleConnectionChange(state, reason);
1620
+ });
1621
+ }
1622
+ _handleConnectionChange(state, reason) {
1623
+ this.connectionChangeCallback?.(state, reason);
1624
+ if (state === "offline" || state === "degraded") {
1625
+ const context = {
1626
+ state,
1627
+ reason,
1628
+ timestamp: Date.now(),
1629
+ displayAlert: createDisplayAlert(this.authContext)
1630
+ };
1631
+ if (this.disconnectHandler) {
1632
+ this.disconnectHandler(context);
1720
1633
  }
1721
- } else {
1722
- log.warn("[RealtimeChannelClient] Cannot send message - connection not open", {
1723
- channel: this._channelName,
1724
- readyState: this.ws?.readyState
1634
+ this.additionalDisconnectHandlers.forEach((handler) => {
1635
+ handler(context);
1725
1636
  });
1726
1637
  }
1727
1638
  }
1728
- onMessage(callback) {
1729
- this.listeners.add(callback);
1730
- return () => this.listeners.delete(callback);
1639
+ }
1640
+ // src/core/request.ts
1641
+ function checkDevWarnings(data) {
1642
+ if (!data || typeof data !== "object")
1643
+ return;
1644
+ const response = data;
1645
+ const warningType = response.__playcademyDevWarning;
1646
+ if (!warningType)
1647
+ return;
1648
+ switch (warningType) {
1649
+ case "timeback-not-configured":
1650
+ console.warn("%c⚠️ TimeBack Not Configured", "background: #f59e0b; color: white; padding: 6px 12px; border-radius: 4px; font-weight: bold; font-size: 13px");
1651
+ console.log("%cTimeBack is configured in playcademy.config.js but the sandbox does not have TimeBack credentials.", "color: #f59e0b; font-weight: 500");
1652
+ console.log("To test TimeBack locally:");
1653
+ console.log(" Set the following environment variables:");
1654
+ console.log(" • %cTIMEBACK_ONEROSTER_API_URL", "color: #0ea5e9; font-weight: 600; font-family: monospace");
1655
+ console.log(" • %cTIMEBACK_CALIPER_API_URL", "color: #0ea5e9; font-weight: 600; font-family: monospace");
1656
+ console.log(" • %cTIMEBACK_API_CLIENT_ID/SECRET", "color: #0ea5e9; font-weight: 600; font-family: monospace");
1657
+ console.log(" Or deploy your game: %cplaycademy deploy", "color: #10b981; font-weight: 600; font-family: monospace");
1658
+ console.log(" Or wait for %c@superbuilders/timeback-local%c (coming soon)", "color: #8b5cf6; font-weight: 600; font-family: monospace", "color: inherit");
1659
+ break;
1660
+ default:
1661
+ console.warn(`[Playcademy Dev Warning] ${warningType}`);
1662
+ }
1663
+ }
1664
+ function prepareRequestBody(body, headers) {
1665
+ if (body instanceof FormData) {
1666
+ return body;
1731
1667
  }
1732
- close() {
1733
- this.isClosing = true;
1734
- if (this.tokenRefreshUnsubscribe) {
1735
- this.tokenRefreshUnsubscribe();
1736
- this.tokenRefreshUnsubscribe = undefined;
1737
- }
1738
- if (this.ws) {
1739
- this.ws.close(CLOSE_CODES.NORMAL_CLOSURE, "client_close");
1740
- this.ws = undefined;
1668
+ if (body instanceof ArrayBuffer || body instanceof Blob || ArrayBuffer.isView(body)) {
1669
+ if (!headers["Content-Type"]) {
1670
+ headers["Content-Type"] = "application/octet-stream";
1741
1671
  }
1742
- this.listeners.clear();
1743
- log.debug("[RealtimeChannelClient] Channel closed", {
1744
- channel: this._channelName
1745
- });
1746
- }
1747
- get channelName() {
1748
- return this._channelName;
1672
+ return body;
1749
1673
  }
1750
- get isConnected() {
1751
- return this.ws?.readyState === WebSocket.OPEN;
1674
+ if (body !== undefined && body !== null) {
1675
+ headers["Content-Type"] = "application/json";
1676
+ return JSON.stringify(body);
1752
1677
  }
1678
+ return;
1753
1679
  }
1754
-
1755
- // src/namespaces/game/realtime.ts
1756
- function createRealtimeNamespace(client) {
1757
- return {
1758
- token: {
1759
- get: async () => {
1760
- const endpoint = client["gameId"] ? `/games/${client["gameId"]}/realtime/token` : "/realtime/token";
1761
- return client["request"](endpoint, "POST");
1762
- }
1763
- },
1764
- async open(channel = "default", url) {
1765
- if (!client["gameId"]) {
1766
- throw new Error("gameId is required for realtime channels");
1767
- }
1768
- let wsBaseUrl = url;
1769
- if (!wsBaseUrl && typeof window !== "undefined") {
1770
- const ctx = window.PLAYCADEMY;
1771
- if (ctx?.realtimeUrl) {
1772
- wsBaseUrl = ctx.realtimeUrl;
1773
- }
1774
- }
1775
- const realtimeClient = new RealtimeChannelClient(client["gameId"], channel, () => client.realtime.token.get().then((r) => r.token), wsBaseUrl ?? client.getBaseUrl());
1776
- return realtimeClient.connect();
1680
+ async function request({
1681
+ path,
1682
+ baseUrl,
1683
+ method = "GET",
1684
+ body,
1685
+ extraHeaders = {},
1686
+ raw = false
1687
+ }) {
1688
+ const url = baseUrl.replace(/\/$/, "") + (path.startsWith("/") ? path : `/${path}`);
1689
+ const headers = { ...extraHeaders };
1690
+ const payload = prepareRequestBody(body, headers);
1691
+ const res = await fetch(url, {
1692
+ method,
1693
+ headers,
1694
+ body: payload,
1695
+ credentials: "omit"
1696
+ });
1697
+ if (raw) {
1698
+ return res;
1699
+ }
1700
+ if (!res.ok) {
1701
+ const clonedRes = res.clone();
1702
+ const errorBody = await clonedRes.json().catch(() => clonedRes.text().catch(() => {
1703
+ return;
1704
+ })) ?? undefined;
1705
+ throw new ApiError(res.status, res.statusText, errorBody);
1706
+ }
1707
+ if (res.status === 204)
1708
+ return;
1709
+ const contentType = res.headers.get("content-type") ?? "";
1710
+ if (contentType.includes("application/json")) {
1711
+ try {
1712
+ const parsed = await res.json();
1713
+ checkDevWarnings(parsed);
1714
+ return parsed;
1715
+ } catch (err) {
1716
+ if (err instanceof SyntaxError)
1717
+ return;
1718
+ throw err;
1777
1719
  }
1778
- };
1720
+ }
1721
+ const rawText = await res.text().catch(() => "");
1722
+ return rawText && rawText.length > 0 ? rawText : undefined;
1779
1723
  }
1780
- // src/namespaces/game/timeback.ts
1781
- function createTimebackNamespace(client) {
1782
- let currentActivity = null;
1783
- return {
1784
- startActivity: (metadata) => {
1785
- currentActivity = {
1786
- startTime: Date.now(),
1787
- metadata,
1788
- pausedTime: 0,
1789
- pauseStartTime: null
1790
- };
1791
- },
1792
- pauseActivity: () => {
1793
- if (!currentActivity) {
1794
- throw new Error("No activity in progress. Call startActivity() before pauseActivity().");
1795
- }
1796
- if (currentActivity.pauseStartTime !== null) {
1797
- throw new Error("Activity is already paused.");
1798
- }
1799
- currentActivity.pauseStartTime = Date.now();
1800
- },
1801
- resumeActivity: () => {
1802
- if (!currentActivity) {
1803
- throw new Error("No activity in progress. Call startActivity() before resumeActivity().");
1804
- }
1805
- if (currentActivity.pauseStartTime === null) {
1806
- throw new Error("Activity is not paused.");
1807
- }
1808
- const pauseDuration = Date.now() - currentActivity.pauseStartTime;
1809
- currentActivity.pausedTime += pauseDuration;
1810
- currentActivity.pauseStartTime = null;
1811
- },
1812
- endActivity: async (data) => {
1813
- if (!currentActivity) {
1814
- throw new Error("No activity in progress. Call startActivity() before endActivity().");
1815
- }
1816
- if (currentActivity.pauseStartTime !== null) {
1817
- const pauseDuration = Date.now() - currentActivity.pauseStartTime;
1818
- currentActivity.pausedTime += pauseDuration;
1819
- currentActivity.pauseStartTime = null;
1820
- }
1821
- const endTime = Date.now();
1822
- const totalElapsed = endTime - currentActivity.startTime;
1823
- const activeTime = totalElapsed - currentActivity.pausedTime;
1824
- const durationSeconds = Math.floor(activeTime / 1000);
1825
- const { correctQuestions, totalQuestions } = data;
1826
- const request2 = {
1827
- activityData: currentActivity.metadata,
1828
- scoreData: {
1829
- correctQuestions,
1830
- totalQuestions
1831
- },
1832
- timingData: {
1833
- durationSeconds
1834
- },
1835
- xpEarned: data.xpAwarded,
1836
- masteredUnits: data.masteredUnits
1837
- };
1838
- try {
1839
- const response = await client["requestGameBackend"](TIMEBACK_ROUTES.END_ACTIVITY, "POST", request2);
1840
- currentActivity = null;
1841
- return response;
1842
- } catch (error) {
1843
- currentActivity = null;
1844
- throw error;
1845
- }
1846
- },
1847
- management: {
1848
- setup: (request2) => {
1849
- return client["request"]("/timeback/setup", "POST", {
1850
- body: request2
1851
- });
1852
- },
1853
- verify: (gameId) => {
1854
- return client["request"](`/timeback/verify/${gameId}`, "GET");
1855
- },
1856
- cleanup: (gameId) => {
1857
- return client["request"](`/timeback/integrations/${gameId}`, "DELETE");
1858
- },
1859
- get: (gameId) => {
1860
- return client["request"](`/timeback/integrations/${gameId}`, "GET");
1861
- },
1862
- getConfig: (gameId) => {
1863
- return client["request"](`/timeback/config/${gameId}`, "GET");
1864
- }
1865
- },
1866
- xp: {
1867
- today: async (options) => {
1868
- const params = new URLSearchParams;
1869
- if (options?.date)
1870
- params.set("date", options.date);
1871
- if (options?.timezone)
1872
- params.set("tz", options.timezone);
1873
- const query = params.toString();
1874
- const endpoint = query ? `/timeback/xp/today?${query}` : "/timeback/xp/today";
1875
- return client["request"](endpoint, "GET");
1876
- },
1877
- total: async () => {
1878
- return client["request"]("/timeback/xp/total", "GET");
1879
- },
1880
- history: async (options) => {
1881
- const params = new URLSearchParams;
1882
- if (options?.startDate)
1883
- params.set("startDate", options.startDate);
1884
- if (options?.endDate)
1885
- params.set("endDate", options.endDate);
1886
- const query = params.toString();
1887
- const endpoint = query ? `/timeback/xp/history?${query}` : "/timeback/xp/history";
1888
- return client["request"](endpoint, "GET");
1889
- },
1890
- summary: async (options) => {
1891
- const [today, total] = await Promise.all([
1892
- client["request"]((() => {
1893
- const params = new URLSearchParams;
1894
- if (options?.date)
1895
- params.set("date", options.date);
1896
- if (options?.timezone)
1897
- params.set("tz", options.timezone);
1898
- const query = params.toString();
1899
- return query ? `/timeback/xp/today?${query}` : "/timeback/xp/today";
1900
- })(), "GET"),
1901
- client["request"]("/timeback/xp/total", "GET")
1902
- ]);
1903
- return { today, total };
1904
- }
1724
+ async function fetchManifest(deploymentUrl) {
1725
+ const manifestUrl = `${deploymentUrl.replace(/\/$/, "")}/playcademy.manifest.json`;
1726
+ try {
1727
+ const response = await fetch(manifestUrl);
1728
+ if (!response.ok) {
1729
+ log.error(`[fetchManifest] Failed to fetch manifest from ${manifestUrl}. Status: ${response.status}`);
1730
+ throw new PlaycademyError(`Failed to fetch manifest: ${response.status} ${response.statusText}`);
1905
1731
  }
1906
- };
1732
+ return await response.json();
1733
+ } catch (error) {
1734
+ if (error instanceof PlaycademyError) {
1735
+ throw error;
1736
+ }
1737
+ log.error(`[Playcademy SDK] Error fetching or parsing manifest from ${manifestUrl}:`, {
1738
+ error
1739
+ });
1740
+ throw new PlaycademyError("Failed to load or parse game manifest");
1741
+ }
1907
1742
  }
1908
- // src/clients/public.ts
1909
- class PlaycademyClient {
1743
+
1744
+ // src/clients/base.ts
1745
+ class PlaycademyBaseClient {
1910
1746
  baseUrl;
1911
1747
  gameUrl;
1912
1748
  authStrategy;
@@ -2084,9 +1920,13 @@ class PlaycademyClient {
2084
1920
  });
2085
1921
  }
2086
1922
  }
1923
+ users = createUsersNamespace(this);
1924
+ }
1925
+
1926
+ // src/clients/public.ts
1927
+ class PlaycademyClient extends PlaycademyBaseClient {
2087
1928
  identity = createIdentityNamespace(this);
2088
1929
  runtime = createRuntimeNamespace(this);
2089
- users = createUsersNamespace(this);
2090
1930
  timeback = createTimebackNamespace(this);
2091
1931
  credits = createCreditsNamespace(this);
2092
1932
  scores = createScoresNamespace(this);