@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/README.md +20 -17
- package/dist/index.d.ts +245 -561
- package/dist/index.js +1275 -1435
- package/dist/internal.d.ts +2558 -2318
- package/dist/internal.js +2454 -2565
- package/dist/server.d.ts +6 -5
- package/dist/server.js +14 -1
- package/dist/types.d.ts +448 -549
- package/package.json +3 -3
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/
|
|
179
|
-
class
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
this.
|
|
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
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|
-
|
|
204
|
-
|
|
394
|
+
}
|
|
395
|
+
function extractApiErrorInfo(error) {
|
|
396
|
+
if (!(error instanceof ApiError)) {
|
|
397
|
+
return null;
|
|
205
398
|
}
|
|
206
|
-
|
|
207
|
-
|
|
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
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
-
|
|
223
|
-
|
|
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
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
-
|
|
239
|
-
|
|
240
|
-
return
|
|
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
|
-
|
|
249
|
-
|
|
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
|
-
|
|
252
|
-
|
|
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
|
|
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/
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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
|
-
|
|
326
|
-
|
|
327
|
-
|
|
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
|
-
|
|
331
|
-
|
|
332
|
-
|
|
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
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
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
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
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
|
-
}
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
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
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
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
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
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
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
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
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
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
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
const
|
|
482
|
-
if (
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
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
|
-
|
|
494
|
-
|
|
495
|
-
const
|
|
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
|
-
|
|
505
|
-
|
|
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
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
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,
|
|
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
|
|
1363
|
-
const
|
|
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
|
-
|
|
1217
|
+
keys.push(fullKey.substring(prefixLen));
|
|
1367
1218
|
}
|
|
1368
|
-
return
|
|
1219
|
+
return keys;
|
|
1369
1220
|
}
|
|
1370
|
-
|
|
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/
|
|
1374
|
-
function
|
|
1375
|
-
|
|
1376
|
-
const
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
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
|
-
|
|
1392
|
-
return
|
|
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
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
}
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
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
|
-
//
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
};
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
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
|
-
|
|
1473
|
-
|
|
1474
|
-
hasValue = false;
|
|
1411
|
+
if (tokenType === "gameJwt") {
|
|
1412
|
+
return new GameJwtAuth(token);
|
|
1475
1413
|
}
|
|
1476
|
-
|
|
1477
|
-
return
|
|
1414
|
+
if (token.startsWith("cademy")) {
|
|
1415
|
+
return new ApiKeyAuth(token);
|
|
1478
1416
|
}
|
|
1479
|
-
return
|
|
1417
|
+
return new GameJwtAuth(token);
|
|
1480
1418
|
}
|
|
1481
1419
|
|
|
1482
|
-
// src/
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
}
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
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
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
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
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
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/
|
|
1540
|
-
function
|
|
1541
|
-
return {
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
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
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
this.
|
|
1569
|
-
this.
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
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
|
-
|
|
1629
|
-
|
|
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
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
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
|
-
|
|
1694
|
-
|
|
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
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
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
|
-
|
|
1722
|
-
|
|
1723
|
-
channel: this._channelName,
|
|
1724
|
-
readyState: this.ws?.readyState
|
|
1634
|
+
this.additionalDisconnectHandlers.forEach((handler) => {
|
|
1635
|
+
handler(context);
|
|
1725
1636
|
});
|
|
1726
1637
|
}
|
|
1727
1638
|
}
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
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
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1751
|
-
|
|
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
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
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
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
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
|
-
|
|
1909
|
-
|
|
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);
|