@playcademy/sdk 0.12.1-beta.1 → 0.12.1-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import * as _playcademy_types from '@playcademy/types';
2
- import { TimebackGrade, TimebackSubject } from '@playcademy/types/timeback';
2
+ import { TimebackGrade, TimebackSubject, HeartbeatRequest } from '@playcademy/types/timeback';
3
3
  import { TimebackUserRole, UserEnrollment, UserOrganization, UserInfo } from '@playcademy/types/user';
4
4
  import { AUTH_PROVIDER_IDS } from '@playcademy/constants';
5
5
 
@@ -511,6 +511,12 @@ declare enum MessageEvents {
511
511
  * - `metadata?`: `Record<string, unknown>`
512
512
  */
513
513
  DEMO_END = "PLAYCADEMY_DEMO_END",
514
+ /**
515
+ * Game shares its latest TimeBack heartbeat window with the parent shell.
516
+ * The shell can relay this payload during top-level page teardown, which is
517
+ * more reliable than relying only on cross-origin iframe unload events.
518
+ */
519
+ TIMEBACK_HEARTBEAT_RELAY = "PLAYCADEMY_TIMEBACK_HEARTBEAT_RELAY",
514
520
  /**
515
521
  * Notifies about authentication state changes.
516
522
  * Can be sent in both directions depending on auth flow.
@@ -578,6 +584,8 @@ interface MessageEventMap {
578
584
  [MessageEvents.KEY_EVENT]: KeyEventPayload;
579
585
  /** Demo end signal from game */
580
586
  [MessageEvents.DEMO_END]: DemoEndPayload;
587
+ /** Latest TimeBack heartbeat window for parent-shell unload relay */
588
+ [MessageEvents.TIMEBACK_HEARTBEAT_RELAY]: TimebackHeartbeatRelayRequest;
581
589
  /** Authentication state change notification */
582
590
  [MessageEvents.AUTH_STATE_CHANGE]: AuthStateChangePayload;
583
591
  /** OAuth callback data from popup/new-tab windows */
@@ -1442,6 +1450,8 @@ interface InitPayload {
1442
1450
  mode?: PlaycademyMode;
1443
1451
  /** Launch session correlation ID (UUID, set by platform on game launch) */
1444
1452
  launchId?: string;
1453
+ /** When `true`, the parent shell provides a heartbeat relay via postMessage, so the SDK can skip its own `fetch({ keepalive })` beacon on pagehide. Defaults to `false`. */
1454
+ hasHeartbeatRelay?: boolean;
1445
1455
  }
1446
1456
  interface GameContextPayload {
1447
1457
  token: string;
@@ -1547,6 +1557,9 @@ interface DemoEndOptions {
1547
1557
  interface DemoEndPayload extends DemoEndOptions {
1548
1558
  score: number;
1549
1559
  }
1560
+ type TimebackHeartbeatRelayRequest = Omit<HeartbeatRequest, 'gameId' | 'studentId' | 'windowStartedAtMs' | 'windowSequence'> & {
1561
+ windowStartedAtMs: number;
1562
+ };
1550
1563
 
1551
1564
  /**
1552
1565
  * SDK-specific API response types
package/dist/index.js CHANGED
@@ -25,6 +25,7 @@ var MessageEvents;
25
25
  MessageEvents2["TELEMETRY"] = "PLAYCADEMY_TELEMETRY";
26
26
  MessageEvents2["KEY_EVENT"] = "PLAYCADEMY_KEY_EVENT";
27
27
  MessageEvents2["DEMO_END"] = "PLAYCADEMY_DEMO_END";
28
+ MessageEvents2["TIMEBACK_HEARTBEAT_RELAY"] = "PLAYCADEMY_TIMEBACK_HEARTBEAT_RELAY";
28
29
  MessageEvents2["AUTH_STATE_CHANGE"] = "PLAYCADEMY_AUTH_STATE_CHANGE";
29
30
  MessageEvents2["AUTH_CALLBACK"] = "PLAYCADEMY_AUTH_CALLBACK";
30
31
  })(MessageEvents ||= {});
@@ -85,7 +86,8 @@ class PlaycademyMessaging {
85
86
  "PLAYCADEMY_EXIT" /* EXIT */,
86
87
  "PLAYCADEMY_TELEMETRY" /* TELEMETRY */,
87
88
  "PLAYCADEMY_KEY_EVENT" /* KEY_EVENT */,
88
- "PLAYCADEMY_DEMO_END" /* DEMO_END */
89
+ "PLAYCADEMY_DEMO_END" /* DEMO_END */,
90
+ "PLAYCADEMY_TIMEBACK_HEARTBEAT_RELAY" /* TIMEBACK_HEARTBEAT_RELAY */
89
91
  ];
90
92
  const shouldUsePostMessage = isIframe && iframeToParentEvents.includes(eventType);
91
93
  return {
@@ -1242,6 +1244,7 @@ function isValidUUID(value) {
1242
1244
  var DEFAULT_PAUSED_HEARTBEAT_TIMEOUT_MS = 10 * 60 * 1000;
1243
1245
  var DEFAULT_HEARTBEAT_INTERVAL_MS = 15000;
1244
1246
  var DEFAULT_INACTIVITY_TIMEOUT_MS = 10 * 60 * 1000;
1247
+ var RELAY_INTERVAL_MS = 1000;
1245
1248
  var HEARTBEAT_RETRY_POLICY = { retryableMethods: ["POST"] };
1246
1249
  var USER_ACTIVITY_EVENTS = ["keydown", "pointerdown", "pointermove", "wheel"];
1247
1250
  var USER_ACTIVITY_LISTENER_OPTIONS = { capture: true };
@@ -1275,6 +1278,14 @@ function computeWindowAndReset(activity) {
1275
1278
  activity.windowPausedAtStart = totalPaused;
1276
1279
  return { activeMs, pausedMs: windowPaused, windowStartedAtMs };
1277
1280
  }
1281
+ function computeWindowSnapshot(activity) {
1282
+ const now = Date.now();
1283
+ const totalPaused = getCurrentPausedTotal(activity, now);
1284
+ const windowPaused = totalPaused - activity.windowPausedAtStart;
1285
+ const windowElapsed = now - activity.windowStartTime;
1286
+ const activeMs = Math.max(0, windowElapsed - windowPaused);
1287
+ return { activeMs, pausedMs: windowPaused, windowStartedAtMs: activity.windowStartTime };
1288
+ }
1278
1289
  function markPersistedTiming(activity, timing) {
1279
1290
  activity.totalPersistedActiveMs += timing.activeMs;
1280
1291
  activity.totalPersistedPausedMs += timing.pausedMs;
@@ -1340,6 +1351,10 @@ function createTimebackActivityTracker(client) {
1340
1351
  let boundShellPauseHandler = null;
1341
1352
  let boundShellResumeHandler = null;
1342
1353
  let boundPageHideHandler = null;
1354
+ let relayIntervalId = null;
1355
+ let lastRelayedActiveMs = -1;
1356
+ let lastRelayedPausedMs = -1;
1357
+ let lastRelayedWindowStart = -1;
1343
1358
  function startHeartbeatInterval(activity) {
1344
1359
  if (activity.heartbeatIntervalMs === Infinity || activity.heartbeatIntervalId !== null) {
1345
1360
  return;
@@ -1355,10 +1370,12 @@ function createTimebackActivityTracker(client) {
1355
1370
  activity.windowStartTime = now;
1356
1371
  activity.windowPausedAtStart = pausedAtReset;
1357
1372
  startHeartbeatInterval(activity);
1373
+ startRelayInterval();
1358
1374
  }
1359
1375
  function markPausedHeartbeatTimedOut(activity) {
1360
1376
  activity.pausedHeartbeatTimeoutId = null;
1361
1377
  activity.pausedHeartbeatTimedOut = true;
1378
+ stopRelayInterval();
1362
1379
  stopHeartbeatInterval(activity);
1363
1380
  }
1364
1381
  function armPausedHeartbeatTimeout(activity, startedAt = Date.now()) {
@@ -1506,14 +1523,47 @@ function createTimebackActivityTracker(client) {
1506
1523
  }
1507
1524
  syncInactivityTracking();
1508
1525
  }
1526
+ function startRelayInterval() {
1527
+ if (relayIntervalId !== null) {
1528
+ return;
1529
+ }
1530
+ relayIntervalId = setInterval(() => {
1531
+ const activity = currentActivity;
1532
+ if (!activity) {
1533
+ return;
1534
+ }
1535
+ const timing = computeWindowSnapshot(activity);
1536
+ if (timing.activeMs === lastRelayedActiveMs && timing.pausedMs === lastRelayedPausedMs && timing.windowStartedAtMs === lastRelayedWindowStart) {
1537
+ return;
1538
+ }
1539
+ lastRelayedActiveMs = timing.activeMs;
1540
+ lastRelayedPausedMs = timing.pausedMs;
1541
+ lastRelayedWindowStart = timing.windowStartedAtMs;
1542
+ const body = buildHeartbeatBody(activity, timing);
1543
+ try {
1544
+ messaging.send("PLAYCADEMY_TIMEBACK_HEARTBEAT_RELAY" /* TIMEBACK_HEARTBEAT_RELAY */, body);
1545
+ } catch {}
1546
+ }, RELAY_INTERVAL_MS);
1547
+ }
1548
+ function stopRelayInterval() {
1549
+ if (relayIntervalId !== null) {
1550
+ clearInterval(relayIntervalId);
1551
+ relayIntervalId = null;
1552
+ lastRelayedActiveMs = -1;
1553
+ lastRelayedPausedMs = -1;
1554
+ lastRelayedWindowStart = -1;
1555
+ }
1556
+ }
1509
1557
  function handleVisibilityChange() {
1510
1558
  if (!currentActivity) {
1511
1559
  return;
1512
1560
  }
1513
1561
  if (document.visibilityState === "hidden") {
1514
1562
  addPauseReason("hidden");
1563
+ flushFinalHeartbeatBeacon();
1515
1564
  } else {
1516
1565
  removePauseReason("hidden");
1566
+ startRelayInterval();
1517
1567
  }
1518
1568
  }
1519
1569
  function handleShellPause() {
@@ -1553,7 +1603,7 @@ function createTimebackActivityTracker(client) {
1553
1603
  }
1554
1604
  });
1555
1605
  }
1556
- function handlePageHide() {
1606
+ function flushFinalHeartbeatBeacon() {
1557
1607
  applyOverdueInactivity();
1558
1608
  const activity = currentActivity;
1559
1609
  if (!activity) {
@@ -1564,26 +1614,34 @@ function createTimebackActivityTracker(client) {
1564
1614
  return;
1565
1615
  }
1566
1616
  const body = buildHeartbeatBody(activity, timing, true);
1567
- queueHeartbeatFlush(activity, timing, async () => {
1568
- try {
1569
- const baseUrl = client["getGameBackendUrl"]();
1570
- const url = `${baseUrl}${TIMEBACK_ROUTES.HEARTBEAT}`;
1571
- const headers = {
1572
- "Content-Type": "application/json",
1573
- ...client["authStrategy"].getHeaders()
1574
- };
1575
- const response = await fetch(url, {
1576
- method: "POST",
1577
- headers,
1578
- body: JSON.stringify(body),
1579
- keepalive: true
1580
- });
1581
- if (response.ok) {
1582
- return true;
1617
+ stopRelayInterval();
1618
+ try {
1619
+ messaging.send("PLAYCADEMY_TIMEBACK_HEARTBEAT_RELAY" /* TIMEBACK_HEARTBEAT_RELAY */, body);
1620
+ } catch {}
1621
+ if (!client["initPayload"]?.hasHeartbeatRelay) {
1622
+ queueHeartbeatFlush(activity, timing, async () => {
1623
+ try {
1624
+ const baseUrl = client["getGameBackendUrl"]();
1625
+ const url = `${baseUrl}${TIMEBACK_ROUTES.HEARTBEAT}`;
1626
+ const headers = {
1627
+ "Content-Type": "application/json",
1628
+ ...client["authStrategy"].getHeaders()
1629
+ };
1630
+ const response = await fetch(url, {
1631
+ method: "POST",
1632
+ headers,
1633
+ body: JSON.stringify(body),
1634
+ keepalive: true
1635
+ });
1636
+ return response.ok;
1637
+ } catch {
1638
+ return false;
1583
1639
  }
1584
- } catch {}
1585
- return false;
1586
- });
1640
+ });
1641
+ }
1642
+ }
1643
+ function handlePageHide() {
1644
+ flushFinalHeartbeatBeacon();
1587
1645
  }
1588
1646
  function cleanupListeners() {
1589
1647
  if (boundVisibilityHandler && typeof document !== "undefined") {
@@ -1615,6 +1673,7 @@ function createTimebackActivityTracker(client) {
1615
1673
  if (currentActivity?.heartbeatIntervalId != null) {
1616
1674
  stopHeartbeatInterval(currentActivity);
1617
1675
  }
1676
+ stopRelayInterval();
1618
1677
  }
1619
1678
  return {
1620
1679
  currentRunId() {
@@ -1665,6 +1724,7 @@ function createTimebackActivityTracker(client) {
1665
1724
  }
1666
1725
  }
1667
1726
  startHeartbeatInterval(currentActivity);
1727
+ startRelayInterval();
1668
1728
  if (typeof globalThis.window !== "undefined") {
1669
1729
  boundPageHideHandler = handlePageHide;
1670
1730
  globalThis.window.addEventListener("pagehide", boundPageHideHandler);
@@ -2318,7 +2378,7 @@ async function request({
2318
2378
  return rawText && rawText.length > 0 ? rawText : undefined;
2319
2379
  }
2320
2380
  // src/version.ts
2321
- var SDK_VERSION = "0.12.1-beta.1";
2381
+ var SDK_VERSION = "0.12.1-beta.2";
2322
2382
 
2323
2383
  // src/clients/base.ts
2324
2384
  class PlaycademyBaseClient {
@@ -1,5 +1,5 @@
1
1
  import { SchemaInfo } from '@playcademy/cloudflare';
2
- import { TimebackGrade, TimebackSubject, TimebackCourseConfig, CourseConfig, OrganizationConfig, ComponentConfig, ResourceConfig, ComponentResourceConfig } from '@playcademy/types/timeback';
2
+ import { TimebackGrade, TimebackSubject, HeartbeatRequest, TimebackCourseConfig, CourseConfig, OrganizationConfig, ComponentConfig, ResourceConfig, ComponentResourceConfig } from '@playcademy/types/timeback';
3
3
  export { QtiTestQuestionRef, QtiTestQuestionsResponse } from '@playcademy/types/timeback';
4
4
  import * as _playcademy_types from '@playcademy/types';
5
5
  import { GameManifest } from '@playcademy/types';
@@ -291,6 +291,12 @@ declare enum MessageEvents {
291
291
  * - `metadata?`: `Record<string, unknown>`
292
292
  */
293
293
  DEMO_END = "PLAYCADEMY_DEMO_END",
294
+ /**
295
+ * Game shares its latest TimeBack heartbeat window with the parent shell.
296
+ * The shell can relay this payload during top-level page teardown, which is
297
+ * more reliable than relying only on cross-origin iframe unload events.
298
+ */
299
+ TIMEBACK_HEARTBEAT_RELAY = "PLAYCADEMY_TIMEBACK_HEARTBEAT_RELAY",
294
300
  /**
295
301
  * Notifies about authentication state changes.
296
302
  * Can be sent in both directions depending on auth flow.
@@ -358,6 +364,8 @@ interface MessageEventMap {
358
364
  [MessageEvents.KEY_EVENT]: KeyEventPayload;
359
365
  /** Demo end signal from game */
360
366
  [MessageEvents.DEMO_END]: DemoEndPayload;
367
+ /** Latest TimeBack heartbeat window for parent-shell unload relay */
368
+ [MessageEvents.TIMEBACK_HEARTBEAT_RELAY]: TimebackHeartbeatRelayRequest;
361
369
  /** Authentication state change notification */
362
370
  [MessageEvents.AUTH_STATE_CHANGE]: AuthStateChangePayload;
363
371
  /** OAuth callback data from popup/new-tab windows */
@@ -1293,6 +1301,8 @@ interface InitPayload {
1293
1301
  mode?: PlaycademyMode;
1294
1302
  /** Launch session correlation ID (UUID, set by platform on game launch) */
1295
1303
  launchId?: string;
1304
+ /** When `true`, the parent shell provides a heartbeat relay via postMessage, so the SDK can skip its own `fetch({ keepalive })` beacon on pagehide. Defaults to `false`. */
1305
+ hasHeartbeatRelay?: boolean;
1296
1306
  }
1297
1307
  /**
1298
1308
  * Simplified user data passed to games via InitPayload
@@ -1437,6 +1447,9 @@ interface DemoEndOptions {
1437
1447
  interface DemoEndPayload extends DemoEndOptions {
1438
1448
  score: number;
1439
1449
  }
1450
+ type TimebackHeartbeatRelayRequest = Omit<HeartbeatRequest, 'gameId' | 'studentId' | 'windowStartedAtMs' | 'windowSequence'> & {
1451
+ windowStartedAtMs: number;
1452
+ };
1440
1453
 
1441
1454
  /**
1442
1455
  * SDK-specific API response types
@@ -3152,4 +3165,4 @@ declare class PlaycademyInternalClient extends PlaycademyBaseClient {
3152
3165
  }
3153
3166
 
3154
3167
  export { ApiError, MessageEvents, PlaycademyInternalClient as PlaycademyClient, PlaycademyError, PlaycademyInternalClient, extractApiErrorInfo, messaging };
3155
- export type { ApiErrorCode, ApiErrorInfo, AssessmentBankStatus, AssessmentRow, AssessmentSummary, AuthCallbackPayload, AuthOptions, AuthProviderType, AuthResult, AuthServerMessage, AuthStateChangePayload, AuthStateUpdate, BetterAuthApiKey, BetterAuthApiKeyResponse, BetterAuthSignInResponse, BucketFile, ClientConfig, ClientEvents, CourseMastery, CourseXp, DemoEndOptions, DemoEndPayload, DevUploadEvent, DevUploadHooks, ErrorResponseBody, EventListeners, ExternalGame, FetchedGame, Game, GameContextPayload, GameCustomHostname, GameInitUser, GameRow as GameRecord, GameTokenResponse, GetHighestGradeMasteredOptions, GetMasteryOptions, GetXpOptions, HighestGradeMasteredResponse, HostedGame, InitErrorPayload, InitPayload, KVKeyEntry, KVKeyMetadata, KVSeedEntry, KVStatsResponse, KeyEventPayload, LoginResponse, MasteryResponse, MessageEventMap, PlatformTimebackUser, PlatformTimebackUserContext, PlaycademyMode, PlaycademyServerClientConfig, PlaycademyServerClientState, ScoreSubmission, StartActivityOptions, StartActivityResult, TelemetryPayload, TimebackEnrollment, TimebackInitContext, TimebackOrganization, TimebackUser, TimebackUserContext, TimebackUserHighestGradeMastered, TimebackUserMastery, TimebackUserRefreshField, TimebackUserRefreshOptions, TimebackUserXp, TokenRefreshPayload, TokenType, UpsertGameMetadataInput, UserRow as User, XpResponse };
3168
+ export type { ApiErrorCode, ApiErrorInfo, AssessmentBankStatus, AssessmentRow, AssessmentSummary, AuthCallbackPayload, AuthOptions, AuthProviderType, AuthResult, AuthServerMessage, AuthStateChangePayload, AuthStateUpdate, BetterAuthApiKey, BetterAuthApiKeyResponse, BetterAuthSignInResponse, BucketFile, ClientConfig, ClientEvents, CourseMastery, CourseXp, DemoEndOptions, DemoEndPayload, DevUploadEvent, DevUploadHooks, ErrorResponseBody, EventListeners, ExternalGame, FetchedGame, Game, GameContextPayload, GameCustomHostname, GameInitUser, GameRow as GameRecord, GameTokenResponse, GetHighestGradeMasteredOptions, GetMasteryOptions, GetXpOptions, HighestGradeMasteredResponse, HostedGame, InitErrorPayload, InitPayload, KVKeyEntry, KVKeyMetadata, KVSeedEntry, KVStatsResponse, KeyEventPayload, LoginResponse, MasteryResponse, MessageEventMap, PlatformTimebackUser, PlatformTimebackUserContext, PlaycademyMode, PlaycademyServerClientConfig, PlaycademyServerClientState, ScoreSubmission, StartActivityOptions, StartActivityResult, TelemetryPayload, TimebackEnrollment, TimebackHeartbeatRelayRequest, TimebackInitContext, TimebackOrganization, TimebackUser, TimebackUserContext, TimebackUserHighestGradeMastered, TimebackUserMastery, TimebackUserRefreshField, TimebackUserRefreshOptions, TimebackUserXp, TokenRefreshPayload, TokenType, UpsertGameMetadataInput, UserRow as User, XpResponse };
package/dist/internal.js CHANGED
@@ -25,6 +25,7 @@ var MessageEvents;
25
25
  MessageEvents2["TELEMETRY"] = "PLAYCADEMY_TELEMETRY";
26
26
  MessageEvents2["KEY_EVENT"] = "PLAYCADEMY_KEY_EVENT";
27
27
  MessageEvents2["DEMO_END"] = "PLAYCADEMY_DEMO_END";
28
+ MessageEvents2["TIMEBACK_HEARTBEAT_RELAY"] = "PLAYCADEMY_TIMEBACK_HEARTBEAT_RELAY";
28
29
  MessageEvents2["AUTH_STATE_CHANGE"] = "PLAYCADEMY_AUTH_STATE_CHANGE";
29
30
  MessageEvents2["AUTH_CALLBACK"] = "PLAYCADEMY_AUTH_CALLBACK";
30
31
  })(MessageEvents ||= {});
@@ -85,7 +86,8 @@ class PlaycademyMessaging {
85
86
  "PLAYCADEMY_EXIT" /* EXIT */,
86
87
  "PLAYCADEMY_TELEMETRY" /* TELEMETRY */,
87
88
  "PLAYCADEMY_KEY_EVENT" /* KEY_EVENT */,
88
- "PLAYCADEMY_DEMO_END" /* DEMO_END */
89
+ "PLAYCADEMY_DEMO_END" /* DEMO_END */,
90
+ "PLAYCADEMY_TIMEBACK_HEARTBEAT_RELAY" /* TIMEBACK_HEARTBEAT_RELAY */
89
91
  ];
90
92
  const shouldUsePostMessage = isIframe && iframeToParentEvents.includes(eventType);
91
93
  return {
@@ -1242,6 +1244,7 @@ function isValidUUID(value) {
1242
1244
  var DEFAULT_PAUSED_HEARTBEAT_TIMEOUT_MS = 10 * 60 * 1000;
1243
1245
  var DEFAULT_HEARTBEAT_INTERVAL_MS = 15000;
1244
1246
  var DEFAULT_INACTIVITY_TIMEOUT_MS = 10 * 60 * 1000;
1247
+ var RELAY_INTERVAL_MS = 1000;
1245
1248
  var HEARTBEAT_RETRY_POLICY = { retryableMethods: ["POST"] };
1246
1249
  var USER_ACTIVITY_EVENTS = ["keydown", "pointerdown", "pointermove", "wheel"];
1247
1250
  var USER_ACTIVITY_LISTENER_OPTIONS = { capture: true };
@@ -1275,6 +1278,14 @@ function computeWindowAndReset(activity) {
1275
1278
  activity.windowPausedAtStart = totalPaused;
1276
1279
  return { activeMs, pausedMs: windowPaused, windowStartedAtMs };
1277
1280
  }
1281
+ function computeWindowSnapshot(activity) {
1282
+ const now = Date.now();
1283
+ const totalPaused = getCurrentPausedTotal(activity, now);
1284
+ const windowPaused = totalPaused - activity.windowPausedAtStart;
1285
+ const windowElapsed = now - activity.windowStartTime;
1286
+ const activeMs = Math.max(0, windowElapsed - windowPaused);
1287
+ return { activeMs, pausedMs: windowPaused, windowStartedAtMs: activity.windowStartTime };
1288
+ }
1278
1289
  function markPersistedTiming(activity, timing) {
1279
1290
  activity.totalPersistedActiveMs += timing.activeMs;
1280
1291
  activity.totalPersistedPausedMs += timing.pausedMs;
@@ -1340,6 +1351,10 @@ function createTimebackActivityTracker(client) {
1340
1351
  let boundShellPauseHandler = null;
1341
1352
  let boundShellResumeHandler = null;
1342
1353
  let boundPageHideHandler = null;
1354
+ let relayIntervalId = null;
1355
+ let lastRelayedActiveMs = -1;
1356
+ let lastRelayedPausedMs = -1;
1357
+ let lastRelayedWindowStart = -1;
1343
1358
  function startHeartbeatInterval(activity) {
1344
1359
  if (activity.heartbeatIntervalMs === Infinity || activity.heartbeatIntervalId !== null) {
1345
1360
  return;
@@ -1355,10 +1370,12 @@ function createTimebackActivityTracker(client) {
1355
1370
  activity.windowStartTime = now;
1356
1371
  activity.windowPausedAtStart = pausedAtReset;
1357
1372
  startHeartbeatInterval(activity);
1373
+ startRelayInterval();
1358
1374
  }
1359
1375
  function markPausedHeartbeatTimedOut(activity) {
1360
1376
  activity.pausedHeartbeatTimeoutId = null;
1361
1377
  activity.pausedHeartbeatTimedOut = true;
1378
+ stopRelayInterval();
1362
1379
  stopHeartbeatInterval(activity);
1363
1380
  }
1364
1381
  function armPausedHeartbeatTimeout(activity, startedAt = Date.now()) {
@@ -1506,14 +1523,47 @@ function createTimebackActivityTracker(client) {
1506
1523
  }
1507
1524
  syncInactivityTracking();
1508
1525
  }
1526
+ function startRelayInterval() {
1527
+ if (relayIntervalId !== null) {
1528
+ return;
1529
+ }
1530
+ relayIntervalId = setInterval(() => {
1531
+ const activity = currentActivity;
1532
+ if (!activity) {
1533
+ return;
1534
+ }
1535
+ const timing = computeWindowSnapshot(activity);
1536
+ if (timing.activeMs === lastRelayedActiveMs && timing.pausedMs === lastRelayedPausedMs && timing.windowStartedAtMs === lastRelayedWindowStart) {
1537
+ return;
1538
+ }
1539
+ lastRelayedActiveMs = timing.activeMs;
1540
+ lastRelayedPausedMs = timing.pausedMs;
1541
+ lastRelayedWindowStart = timing.windowStartedAtMs;
1542
+ const body = buildHeartbeatBody(activity, timing);
1543
+ try {
1544
+ messaging.send("PLAYCADEMY_TIMEBACK_HEARTBEAT_RELAY" /* TIMEBACK_HEARTBEAT_RELAY */, body);
1545
+ } catch {}
1546
+ }, RELAY_INTERVAL_MS);
1547
+ }
1548
+ function stopRelayInterval() {
1549
+ if (relayIntervalId !== null) {
1550
+ clearInterval(relayIntervalId);
1551
+ relayIntervalId = null;
1552
+ lastRelayedActiveMs = -1;
1553
+ lastRelayedPausedMs = -1;
1554
+ lastRelayedWindowStart = -1;
1555
+ }
1556
+ }
1509
1557
  function handleVisibilityChange() {
1510
1558
  if (!currentActivity) {
1511
1559
  return;
1512
1560
  }
1513
1561
  if (document.visibilityState === "hidden") {
1514
1562
  addPauseReason("hidden");
1563
+ flushFinalHeartbeatBeacon();
1515
1564
  } else {
1516
1565
  removePauseReason("hidden");
1566
+ startRelayInterval();
1517
1567
  }
1518
1568
  }
1519
1569
  function handleShellPause() {
@@ -1553,7 +1603,7 @@ function createTimebackActivityTracker(client) {
1553
1603
  }
1554
1604
  });
1555
1605
  }
1556
- function handlePageHide() {
1606
+ function flushFinalHeartbeatBeacon() {
1557
1607
  applyOverdueInactivity();
1558
1608
  const activity = currentActivity;
1559
1609
  if (!activity) {
@@ -1564,26 +1614,34 @@ function createTimebackActivityTracker(client) {
1564
1614
  return;
1565
1615
  }
1566
1616
  const body = buildHeartbeatBody(activity, timing, true);
1567
- queueHeartbeatFlush(activity, timing, async () => {
1568
- try {
1569
- const baseUrl = client["getGameBackendUrl"]();
1570
- const url = `${baseUrl}${TIMEBACK_ROUTES.HEARTBEAT}`;
1571
- const headers = {
1572
- "Content-Type": "application/json",
1573
- ...client["authStrategy"].getHeaders()
1574
- };
1575
- const response = await fetch(url, {
1576
- method: "POST",
1577
- headers,
1578
- body: JSON.stringify(body),
1579
- keepalive: true
1580
- });
1581
- if (response.ok) {
1582
- return true;
1617
+ stopRelayInterval();
1618
+ try {
1619
+ messaging.send("PLAYCADEMY_TIMEBACK_HEARTBEAT_RELAY" /* TIMEBACK_HEARTBEAT_RELAY */, body);
1620
+ } catch {}
1621
+ if (!client["initPayload"]?.hasHeartbeatRelay) {
1622
+ queueHeartbeatFlush(activity, timing, async () => {
1623
+ try {
1624
+ const baseUrl = client["getGameBackendUrl"]();
1625
+ const url = `${baseUrl}${TIMEBACK_ROUTES.HEARTBEAT}`;
1626
+ const headers = {
1627
+ "Content-Type": "application/json",
1628
+ ...client["authStrategy"].getHeaders()
1629
+ };
1630
+ const response = await fetch(url, {
1631
+ method: "POST",
1632
+ headers,
1633
+ body: JSON.stringify(body),
1634
+ keepalive: true
1635
+ });
1636
+ return response.ok;
1637
+ } catch {
1638
+ return false;
1583
1639
  }
1584
- } catch {}
1585
- return false;
1586
- });
1640
+ });
1641
+ }
1642
+ }
1643
+ function handlePageHide() {
1644
+ flushFinalHeartbeatBeacon();
1587
1645
  }
1588
1646
  function cleanupListeners() {
1589
1647
  if (boundVisibilityHandler && typeof document !== "undefined") {
@@ -1615,6 +1673,7 @@ function createTimebackActivityTracker(client) {
1615
1673
  if (currentActivity?.heartbeatIntervalId != null) {
1616
1674
  stopHeartbeatInterval(currentActivity);
1617
1675
  }
1676
+ stopRelayInterval();
1618
1677
  }
1619
1678
  return {
1620
1679
  currentRunId() {
@@ -1665,6 +1724,7 @@ function createTimebackActivityTracker(client) {
1665
1724
  }
1666
1725
  }
1667
1726
  startHeartbeatInterval(currentActivity);
1727
+ startRelayInterval();
1668
1728
  if (typeof globalThis.window !== "undefined") {
1669
1729
  boundPageHideHandler = handlePageHide;
1670
1730
  globalThis.window.addEventListener("pagehide", boundPageHideHandler);
@@ -3009,7 +3069,7 @@ async function request({
3009
3069
  return rawText && rawText.length > 0 ? rawText : undefined;
3010
3070
  }
3011
3071
  // src/version.ts
3012
- var SDK_VERSION = "0.12.1-beta.1";
3072
+ var SDK_VERSION = "0.12.1-beta.2";
3013
3073
 
3014
3074
  // src/clients/base.ts
3015
3075
  class PlaycademyBaseClient {
@@ -233,7 +233,7 @@ function extractApiErrorInfo(error) {
233
233
  }
234
234
 
235
235
  // src/version.ts
236
- var SDK_VERSION = "0.12.1-beta.1";
236
+ var SDK_VERSION = "0.12.1-beta.2";
237
237
 
238
238
  // src/server/request.ts
239
239
  async function makeApiRequest(opts) {
package/dist/server.js CHANGED
@@ -422,7 +422,7 @@ function extractApiErrorInfo(error) {
422
422
  }
423
423
 
424
424
  // src/version.ts
425
- var SDK_VERSION = "0.12.1-beta.1";
425
+ var SDK_VERSION = "0.12.1-beta.2";
426
426
 
427
427
  // src/server/request.ts
428
428
  async function makeApiRequest(opts) {
package/dist/types.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import * as _playcademy_types from '@playcademy/types';
2
2
  import { GameManifest } from '@playcademy/types';
3
3
  export { AuthenticatedUser, DeveloperStatusEnumType, DeveloperStatusResponse, DeveloperStatusValue, GameCourseMetrics, GameLeaderboardEntry, GameManifest, GameMetricComparisonKind, GameMetricComparisonMetric, GameMetricComparisonRow, GameMetricComparisonRowStatus, GameMetricsProxyResponse, GameMetricsResponse, GameMetricsUnsupportedReason, GamePlatform, GameRunMetrics, GameRunMetricsComparison, GameRunMetricsComparisonStatus, GameRunMetricsComparisonSummary, GameTimebackIntegration, GameType, GameUser, LeaderboardEntry, LeaderboardOptions, LeaderboardTimeframe, ManifestV1, ManifestV2, ManifestVersions, PopulateStudentResponse, UserEnrollment, UserInfo, UserOrganization, UserRank, UserRankResponse, UserRoleEnumType, UserScore, UserTimebackData } from '@playcademy/types';
4
- import { TimebackCourseConfig, CourseConfig, OrganizationConfig, ComponentConfig, ResourceConfig, ComponentResourceConfig, TimebackGrade, TimebackSubject } from '@playcademy/types/timeback';
4
+ import { TimebackCourseConfig, CourseConfig, OrganizationConfig, ComponentConfig, ResourceConfig, ComponentResourceConfig, TimebackGrade, TimebackSubject, HeartbeatRequest } from '@playcademy/types/timeback';
5
5
  export { QtiTestQuestionRef, QtiTestQuestionsResponse } from '@playcademy/types/timeback';
6
6
  import { TimebackUserRole, UserEnrollment, UserOrganization, UserInfo } from '@playcademy/types/user';
7
7
  import { AUTH_PROVIDER_IDS } from '@playcademy/constants';
@@ -157,6 +157,12 @@ declare enum MessageEvents {
157
157
  * - `metadata?`: `Record<string, unknown>`
158
158
  */
159
159
  DEMO_END = "PLAYCADEMY_DEMO_END",
160
+ /**
161
+ * Game shares its latest TimeBack heartbeat window with the parent shell.
162
+ * The shell can relay this payload during top-level page teardown, which is
163
+ * more reliable than relying only on cross-origin iframe unload events.
164
+ */
165
+ TIMEBACK_HEARTBEAT_RELAY = "PLAYCADEMY_TIMEBACK_HEARTBEAT_RELAY",
160
166
  /**
161
167
  * Notifies about authentication state changes.
162
168
  * Can be sent in both directions depending on auth flow.
@@ -1799,6 +1805,8 @@ interface InitPayload {
1799
1805
  mode?: PlaycademyMode;
1800
1806
  /** Launch session correlation ID (UUID, set by platform on game launch) */
1801
1807
  launchId?: string;
1808
+ /** When `true`, the parent shell provides a heartbeat relay via postMessage, so the SDK can skip its own `fetch({ keepalive })` beacon on pagehide. Defaults to `false`. */
1809
+ hasHeartbeatRelay?: boolean;
1802
1810
  }
1803
1811
  /**
1804
1812
  * Simplified user data passed to games via InitPayload
@@ -1943,6 +1951,9 @@ interface DemoEndOptions {
1943
1951
  interface DemoEndPayload extends DemoEndOptions {
1944
1952
  score: number;
1945
1953
  }
1954
+ type TimebackHeartbeatRelayRequest = Omit<HeartbeatRequest, 'gameId' | 'studentId' | 'windowStartedAtMs' | 'windowSequence'> & {
1955
+ windowStartedAtMs: number;
1956
+ };
1946
1957
 
1947
1958
  /**
1948
1959
  * SDK-specific API response types
@@ -2182,4 +2193,4 @@ interface AssessmentBankStatus {
2182
2193
  }
2183
2194
 
2184
2195
  export { PlaycademyClient };
2185
- export type { AssessmentBankStatus, AssessmentRow, AssessmentSummary, AuthCallbackPayload, AuthOptions, AuthProviderType, AuthResult, AuthServerMessage, AuthStateChangePayload, AuthStateUpdate, BetterAuthApiKey, BetterAuthApiKeyResponse, BetterAuthSignInResponse, BucketFile, ClientConfig, ClientEvents, CourseMastery, CourseXp, DemoEndOptions, DemoEndPayload, DevUploadEvent, DevUploadHooks, EventListeners, ExternalGame, FetchedGame, Game, GameContextPayload, GameCustomHostname, GameInitUser, GameRow as GameRecord, GameTokenResponse, GetHighestGradeMasteredOptions, GetMasteryOptions, GetXpOptions, HighestGradeMasteredResponse, HostedGame, InitErrorPayload, InitPayload, KVKeyEntry, KVKeyMetadata, KVSeedEntry, KVStatsResponse, KeyEventPayload, LoginResponse, MasteryResponse, PlatformTimebackUser, PlatformTimebackUserContext, PlaycademyMode, PlaycademyServerClientConfig, PlaycademyServerClientState, ScoreSubmission, StartActivityOptions, StartActivityResult, TelemetryPayload, TimebackEnrollment, TimebackInitContext, TimebackOrganization, TimebackUser, TimebackUserContext, TimebackUserHighestGradeMastered, TimebackUserMastery, TimebackUserRefreshField, TimebackUserRefreshOptions, TimebackUserXp, TokenRefreshPayload, TokenType, UpsertGameMetadataInput, UserRow as User, XpResponse };
2196
+ export type { AssessmentBankStatus, AssessmentRow, AssessmentSummary, AuthCallbackPayload, AuthOptions, AuthProviderType, AuthResult, AuthServerMessage, AuthStateChangePayload, AuthStateUpdate, BetterAuthApiKey, BetterAuthApiKeyResponse, BetterAuthSignInResponse, BucketFile, ClientConfig, ClientEvents, CourseMastery, CourseXp, DemoEndOptions, DemoEndPayload, DevUploadEvent, DevUploadHooks, EventListeners, ExternalGame, FetchedGame, Game, GameContextPayload, GameCustomHostname, GameInitUser, GameRow as GameRecord, GameTokenResponse, GetHighestGradeMasteredOptions, GetMasteryOptions, GetXpOptions, HighestGradeMasteredResponse, HostedGame, InitErrorPayload, InitPayload, KVKeyEntry, KVKeyMetadata, KVSeedEntry, KVStatsResponse, KeyEventPayload, LoginResponse, MasteryResponse, PlatformTimebackUser, PlatformTimebackUserContext, PlaycademyMode, PlaycademyServerClientConfig, PlaycademyServerClientState, ScoreSubmission, StartActivityOptions, StartActivityResult, TelemetryPayload, TimebackEnrollment, TimebackHeartbeatRelayRequest, TimebackInitContext, TimebackOrganization, TimebackUser, TimebackUserContext, TimebackUserHighestGradeMastered, TimebackUserMastery, TimebackUserRefreshField, TimebackUserRefreshOptions, TimebackUserXp, TokenRefreshPayload, TokenType, UpsertGameMetadataInput, UserRow as User, XpResponse };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@playcademy/sdk",
3
- "version": "0.12.1-beta.1",
3
+ "version": "0.12.1-beta.2",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {