@mochabug/adapt-web 1.0.0-rc53 → 1.0.0-rc54

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.
@@ -13,6 +13,7 @@ const OBSERVED_ATTRIBUTES = [
13
13
  "dialog-resize-to-content",
14
14
  "dark-mode",
15
15
  "auto-resizing",
16
+ "persist",
16
17
  ];
17
18
  /**
18
19
  * `<adapt-automation>` custom element for embedding Adapt automations.
@@ -107,6 +108,25 @@ export class AdaptAutomationElement extends BaseElement {
107
108
  this.removeAttribute("dialog-resize-to-content");
108
109
  }
109
110
  }
111
+ get persist() {
112
+ return this.hasAttribute("persist");
113
+ }
114
+ set persist(v) {
115
+ if (v) {
116
+ this.setAttribute("persist", "");
117
+ }
118
+ else {
119
+ this.removeAttribute("persist");
120
+ }
121
+ }
122
+ /**
123
+ * Clear persisted state, stop current session, and reinitialize fresh.
124
+ */
125
+ async newSession() {
126
+ if (this._client) {
127
+ await this._client.newSession();
128
+ }
129
+ }
110
130
  // --- Lifecycle ---
111
131
  connectedCallback() {
112
132
  // Defer to next microtask to avoid TDZ issues from circular imports
@@ -119,7 +139,7 @@ export class AdaptAutomationElement extends BaseElement {
119
139
  attributeChangedCallback(name, oldValue, newValue) {
120
140
  if (oldValue === newValue)
121
141
  return;
122
- if (name === "automation-id") {
142
+ if (name === "automation-id" || name === "persist") {
123
143
  this._destroyClient();
124
144
  queueMicrotask(() => this._tryInit());
125
145
  return;
@@ -229,6 +249,13 @@ export class AdaptAutomationElement extends BaseElement {
229
249
  options.classNames = this.classNames;
230
250
  if (this.styles !== undefined)
231
251
  options.styles = this.styles;
252
+ // Persist: object options take precedence, otherwise use boolean attribute
253
+ if (this.persistOptions !== undefined) {
254
+ options.persist = this.persistOptions;
255
+ }
256
+ else if (this.persist) {
257
+ options.persist = true;
258
+ }
232
259
  this._client = new AdaptWebClient(options);
233
260
  }
234
261
  _destroyClient() {
@@ -251,4 +278,3 @@ if (typeof customElements !== "undefined" &&
251
278
  document.head.appendChild(s);
252
279
  }
253
280
  }
254
- //# sourceMappingURL=AdaptAutomationElement.js.map
@@ -145,4 +145,3 @@ if (typeof customElements !== "undefined" &&
145
145
  document.head.appendChild(s);
146
146
  }
147
147
  }
148
- //# sourceMappingURL=AdaptCapElement.js.map
@@ -287,4 +287,3 @@ export class AdaptCapWidget {
287
287
  this.widgetElement = null;
288
288
  }
289
289
  }
290
- //# sourceMappingURL=AdaptCapWidget.js.map
@@ -144,4 +144,3 @@ export function cleanupCapAdapter() {
144
144
  currentClient = null;
145
145
  currentAutomationId = null;
146
146
  }
147
- //# sourceMappingURL=cap-adapter.js.map
@@ -2,4 +2,3 @@
2
2
  // Usage: import "@mochabug/adapt-web/elements";
3
3
  import "./AdaptAutomationElement.js";
4
4
  import "./AdaptCapElement.js";
5
- //# sourceMappingURL=elements.js.map
@@ -22,4 +22,3 @@
22
22
  export function getIframeSrc(url, token) {
23
23
  return `${url}${token ? `#mb_token=${encodeURIComponent(token)}` : ""}`;
24
24
  }
25
- //# sourceMappingURL=iframe-url.js.map
package/dist/esm/index.js CHANGED
@@ -19,6 +19,22 @@ export { configure, resetConfig };
19
19
  // Re-export custom elements
20
20
  export { AdaptAutomationElement } from "./AdaptAutomationElement.js";
21
21
  export { AdaptCapElement } from "./AdaptCapElement.js";
22
+ /**
23
+ * Clear persisted session state for a given automation ID.
24
+ * Use this to force a fresh session on next page load.
25
+ *
26
+ * @param automationId - The automation ID whose state to clear
27
+ * @param storage - Which storage to clear: 'session' (default), 'local', or 'both'
28
+ */
29
+ export function clearPersistedState(automationId, storage = "both") {
30
+ const key = `mb_adapt_${automationId}`;
31
+ if (storage === "session" || storage === "both") {
32
+ sessionStorage.removeItem(key);
33
+ }
34
+ if (storage === "local" || storage === "both") {
35
+ localStorage.removeItem(key);
36
+ }
37
+ }
22
38
  /**
23
39
  * Default CSS styles with CSS Custom Properties for theming.
24
40
  */
@@ -1021,6 +1037,8 @@ export class AdaptWebClient {
1021
1037
  }
1022
1038
  // Reset for next drag
1023
1039
  unclampedSplit = this.splitPercentage;
1040
+ // Persist the new split position
1041
+ this.saveState();
1024
1042
  };
1025
1043
  this.mouseMoveHandler = handleMouseMove;
1026
1044
  this.mouseUpHandler = handleMouseUp;
@@ -1098,6 +1116,13 @@ export class AdaptWebClient {
1098
1116
  if (sessionJson.status === "STATUS_COMPLETED" && !sessionJson.fork) {
1099
1117
  this.handleSessionComplete();
1100
1118
  }
1119
+ // On any session-level terminal status, clear persisted state
1120
+ if (!sessionJson.fork &&
1121
+ (sessionJson.status === "STATUS_COMPLETED" ||
1122
+ sessionJson.status === "STATUS_STOPPED" ||
1123
+ sessionJson.status === "STATUS_ERRORED")) {
1124
+ this.clearState();
1125
+ }
1101
1126
  if (this.options.onSession) {
1102
1127
  this.options.onSession(sessionJson.status || "STATUS_UNSPECIFIED", sessionJson.fork);
1103
1128
  }
@@ -1106,6 +1131,12 @@ export class AdaptWebClient {
1106
1131
  if (this.options.onOutput) {
1107
1132
  handlers.onOutput = this.options.onOutput;
1108
1133
  }
1134
+ // Try to restore persisted session (skip when sessionToken is provided — server manages lifecycle)
1135
+ if (!this.options.sessionToken && this.resolvePersistOptions()) {
1136
+ const restored = await this.tryRestoreSession(handlers);
1137
+ if (restored)
1138
+ return;
1139
+ }
1109
1140
  // Priority: sessionToken > challengeToken > inheritToken/inheritFrom > authToken
1110
1141
  if (this.options.sessionToken) {
1111
1142
  // Use pre-created session token directly (server-side scenario)
@@ -1154,6 +1185,8 @@ export class AdaptWebClient {
1154
1185
  result = await this.client.run(runOptions);
1155
1186
  }
1156
1187
  this.sessionToken = result.sessionToken;
1188
+ this.sessionExpiresAt = result.expiresAt;
1189
+ this.saveState();
1157
1190
  }
1158
1191
  }
1159
1192
  /**
@@ -1198,12 +1231,220 @@ export class AdaptWebClient {
1198
1231
  this.capWidgetInstance = null;
1199
1232
  }
1200
1233
  }
1234
+ // --- Persistence helpers ---
1235
+ resolvePersistOptions() {
1236
+ const p = this.options.persist;
1237
+ if (!p)
1238
+ return null;
1239
+ if (p === true)
1240
+ return { storage: "session", ttl: 3600 };
1241
+ return { storage: p.storage ?? "session", ttl: p.ttl ?? 3600 };
1242
+ }
1243
+ getStorageKey() {
1244
+ return `mb_adapt_${this.options.id}`;
1245
+ }
1246
+ getStorage() {
1247
+ const opts = this.resolvePersistOptions();
1248
+ if (!opts)
1249
+ return null;
1250
+ return opts.storage === "local" ? localStorage : sessionStorage;
1251
+ }
1252
+ saveState() {
1253
+ const storage = this.getStorage();
1254
+ if (!storage || !this.sessionToken)
1255
+ return;
1256
+ const opts = this.resolvePersistOptions();
1257
+ const state = {
1258
+ v: 1,
1259
+ token: this.sessionToken,
1260
+ expiresAt: this.sessionExpiresAt
1261
+ ? this.sessionExpiresAt.toISOString()
1262
+ : null,
1263
+ savedAt: Date.now(),
1264
+ ttl: opts.ttl,
1265
+ mainUrl: this.mainUrl,
1266
+ mainToken: this.mainToken,
1267
+ currentFork: this.currentFork
1268
+ ? {
1269
+ url: this.currentFork.url,
1270
+ token: this.currentFork.token,
1271
+ fork: this.currentFork.fork,
1272
+ depth: this.currentFork.depth,
1273
+ time: this.currentFork.time,
1274
+ completed: this.currentFork.completed,
1275
+ }
1276
+ : null,
1277
+ forkQueue: this.forkQueue.map((f) => ({
1278
+ url: f.url,
1279
+ token: f.token,
1280
+ fork: f.fork,
1281
+ depth: f.depth,
1282
+ time: f.time,
1283
+ completed: f.completed,
1284
+ })),
1285
+ forkDisplayMode: this.forkDisplay.mode,
1286
+ splitPercentage: this.splitPercentage,
1287
+ };
1288
+ storage.setItem(this.getStorageKey(), JSON.stringify(state));
1289
+ }
1290
+ loadState() {
1291
+ const storage = this.getStorage();
1292
+ if (!storage)
1293
+ return null;
1294
+ const raw = storage.getItem(this.getStorageKey());
1295
+ if (!raw)
1296
+ return null;
1297
+ let state;
1298
+ try {
1299
+ state = JSON.parse(raw);
1300
+ }
1301
+ catch {
1302
+ this.clearState();
1303
+ return null;
1304
+ }
1305
+ // Version check
1306
+ if (state.v !== 1) {
1307
+ this.clearState();
1308
+ return null;
1309
+ }
1310
+ const now = Date.now();
1311
+ const leeway = 30000; // 30 seconds
1312
+ // TTL check
1313
+ if (now - state.savedAt > state.ttl * 1000 + leeway) {
1314
+ this.clearState();
1315
+ return null;
1316
+ }
1317
+ // Session expiresAt check
1318
+ if (state.expiresAt) {
1319
+ const expires = new Date(state.expiresAt).getTime();
1320
+ if (now > expires + leeway) {
1321
+ this.clearState();
1322
+ return null;
1323
+ }
1324
+ }
1325
+ return state;
1326
+ }
1327
+ clearState() {
1328
+ const storage = this.getStorage();
1329
+ if (!storage)
1330
+ return;
1331
+ storage.removeItem(this.getStorageKey());
1332
+ }
1333
+ async tryRestoreSession(handlers) {
1334
+ const state = this.loadState();
1335
+ if (!state)
1336
+ return false;
1337
+ // Restore state and show iframes IMMEDIATELY — don't wait for subscribe
1338
+ this.sessionToken = state.token;
1339
+ this.sessionExpiresAt = state.expiresAt
1340
+ ? new Date(state.expiresAt)
1341
+ : undefined;
1342
+ this.mainUrl = state.mainUrl;
1343
+ this.mainToken = state.mainToken;
1344
+ this.forkQueue = state.forkQueue;
1345
+ this.currentFork = state.currentFork;
1346
+ // Restore UI state if fork display mode is consistent with current options
1347
+ if (state.forkDisplayMode === this.forkDisplay.mode) {
1348
+ if (this.forkDisplay.mode === "side-by-side") {
1349
+ this.splitPercentage = state.splitPercentage;
1350
+ }
1351
+ // Dialog collapsed state is already captured by currentFork/forkQueue
1352
+ }
1353
+ if (this.mainUrl && this.mainIframe) {
1354
+ const newSrc = getIframeSrc(this.mainUrl, this.mainToken);
1355
+ if (this.mainIframe.src !== newSrc) {
1356
+ this.mainIframe.src = newSrc;
1357
+ }
1358
+ this.showIframe(this.mainIframe);
1359
+ }
1360
+ if (this.currentFork && this.forkIframe) {
1361
+ this.updateForkIframe();
1362
+ }
1363
+ // Update visibility for the current display mode — must run even when
1364
+ // currentFork is null so that collapsed-state UI (expand buttons) renders
1365
+ if (this.forkDisplay.mode === "dialog") {
1366
+ this.updateDialogVisibility();
1367
+ }
1368
+ else {
1369
+ this.updateSideBySideVisibility();
1370
+ }
1371
+ // Now reconnect the stream — if it fails, reset everything and fall through
1372
+ try {
1373
+ await this.client.subscribe(state.token, handlers);
1374
+ }
1375
+ catch {
1376
+ this.clearState();
1377
+ this.sessionToken = null;
1378
+ this.sessionExpiresAt = undefined;
1379
+ this.mainUrl = null;
1380
+ this.mainToken = undefined;
1381
+ this.forkQueue = [];
1382
+ this.currentFork = null;
1383
+ if (this.mainIframe) {
1384
+ this.mainIframe.src = "about:blank";
1385
+ this.mainIframe.classList.remove("mb-adapt__iframe--visible");
1386
+ }
1387
+ if (this.forkIframe) {
1388
+ this.forkIframe.src = "about:blank";
1389
+ this.forkIframe.classList.remove("mb-adapt__iframe--visible");
1390
+ this.forkIframe.classList.add("mb-adapt__iframe--hidden");
1391
+ }
1392
+ if (this.forkDisplay.mode === "side-by-side") {
1393
+ this.updateSideBySideVisibility();
1394
+ }
1395
+ else {
1396
+ this.updateDialogVisibility();
1397
+ }
1398
+ return false;
1399
+ }
1400
+ return true;
1401
+ }
1402
+ /**
1403
+ * Clear persisted state, stop current session, and reinitialize fresh.
1404
+ */
1405
+ async newSession() {
1406
+ this.clearState();
1407
+ // Stop current session best-effort
1408
+ if (this.sessionToken) {
1409
+ this.client.stop(this.sessionToken).catch(() => { });
1410
+ }
1411
+ await this.client.unsubscribe();
1412
+ // Reset internal state
1413
+ this.mainUrl = null;
1414
+ this.mainToken = undefined;
1415
+ this.forkQueue = [];
1416
+ this.currentFork = null;
1417
+ this.sessionToken = null;
1418
+ this.sessionExpiresAt = undefined;
1419
+ this.lastForkActive = null;
1420
+ // Reset iframes
1421
+ if (this.mainIframe) {
1422
+ this.mainIframe.src = "about:blank";
1423
+ this.mainIframe.classList.remove("mb-adapt__iframe--visible");
1424
+ }
1425
+ if (this.forkIframe) {
1426
+ this.forkIframe.src = "about:blank";
1427
+ this.forkIframe.classList.remove("mb-adapt__iframe--visible");
1428
+ this.forkIframe.classList.add("mb-adapt__iframe--hidden");
1429
+ }
1430
+ // Reset visual state
1431
+ this.removeStoppedPlaceholder();
1432
+ if (this.forkDisplay.mode === "side-by-side") {
1433
+ this.updateSideBySideVisibility();
1434
+ }
1435
+ else {
1436
+ this.updateDialogVisibility();
1437
+ }
1438
+ // Reinitialize
1439
+ await this.init();
1440
+ }
1201
1441
  async destroy() {
1202
1442
  this.destroyed = true;
1203
1443
  // Clean up Cap.js widget if present
1204
1444
  this.destroyCapWidget();
1205
- // We stop best-effort. Sometimes you will get perm denied etc...
1206
- if (this.sessionToken) {
1445
+ // Stop session on destroy but NOT when persist has saved state (session survives page refresh).
1446
+ // If persisted state was cleared (explicit stop, terminal status), stop() is still called.
1447
+ if (this.sessionToken && !this.loadState()) {
1207
1448
  this.client.stop(this.sessionToken).catch(() => { });
1208
1449
  }
1209
1450
  await this.client.unsubscribe();
@@ -1255,10 +1496,12 @@ export class AdaptWebClient {
1255
1496
  const fork = msg.fork || "";
1256
1497
  if (msg.stopped) {
1257
1498
  this.handleStoppedMessage(fork);
1499
+ this.saveState();
1258
1500
  return;
1259
1501
  }
1260
1502
  if (msg.done) {
1261
1503
  this.handleDoneMessage(fork);
1504
+ this.saveState();
1262
1505
  return;
1263
1506
  }
1264
1507
  if (!msg.url)
@@ -1271,6 +1514,7 @@ export class AdaptWebClient {
1271
1514
  // Fork URL - should open dialog
1272
1515
  this.handleForkUrl(msg.url, msg.token, fork);
1273
1516
  }
1517
+ this.saveState();
1274
1518
  }
1275
1519
  handleSessionComplete() {
1276
1520
  // Clear all forks and minimize to show only main frame
@@ -1400,6 +1644,7 @@ export class AdaptWebClient {
1400
1644
  else {
1401
1645
  this.updateSideBySideVisibility();
1402
1646
  }
1647
+ this.saveState();
1403
1648
  }
1404
1649
  updateMainIframe() {
1405
1650
  if (!this.rootElement || !this.mainUrl || !this.mainIframe)
@@ -1510,6 +1755,7 @@ export class AdaptWebClient {
1510
1755
  this.splitPercentage = 50;
1511
1756
  }
1512
1757
  this.updateSideBySideVisibility();
1758
+ this.saveState();
1513
1759
  });
1514
1760
  this.wrapperElement.appendChild(this.mainFrameElement);
1515
1761
  this.wrapperElement.appendChild(this.forkFrameElement);
@@ -1682,6 +1928,7 @@ export class AdaptWebClient {
1682
1928
  this.currentFork = null;
1683
1929
  this.updateDialogVisibility();
1684
1930
  }
1931
+ this.saveState();
1685
1932
  }
1686
1933
  handleForkExit() {
1687
1934
  if (!this.currentFork)
@@ -1711,6 +1958,7 @@ export class AdaptWebClient {
1711
1958
  this.updateDialogVisibility();
1712
1959
  }
1713
1960
  }
1961
+ this.saveState();
1714
1962
  }
1715
1963
  createDialogStructure() {
1716
1964
  if (this.mainContainer)
@@ -1849,4 +2097,3 @@ export class AdaptWebClient {
1849
2097
  AdaptWebClient.COLLAPSE_THRESHOLD = 15;
1850
2098
  AdaptWebClient.RESPONSIVE_BREAKPOINT = 900; // px - below this, auto-collapse to single view
1851
2099
  AdaptWebClient.DIALOG_FULLSCREEN_BREAKPOINT = 639; // px - at or below this, dialog is fullscreen
1852
- //# sourceMappingURL=index.js.map
package/dist/esm/types.js CHANGED
@@ -1,2 +1 @@
1
1
  export {};
2
- //# sourceMappingURL=types.js.map
@@ -1,4 +1,4 @@
1
- import type { AdaptWebClientOptions, CapWidgetOptions, Output, SignalValue, StatusJson } from "./types.js";
1
+ import type { AdaptWebClientOptions, CapWidgetOptions, Output, PersistOptions, SignalValue, StatusJson } from "./types.js";
2
2
  /**
3
3
  * `<adapt-automation>` custom element for embedding Adapt automations.
4
4
  *
@@ -33,6 +33,7 @@ export declare class AdaptAutomationElement extends BaseElement {
33
33
  } | undefined;
34
34
  classNames: AdaptWebClientOptions["classNames"];
35
35
  styles: Partial<CSSStyleDeclaration> | undefined;
36
+ persistOptions: PersistOptions | undefined;
36
37
  onSessionCallback: ((status: StatusJson, fork?: string) => void) | undefined;
37
38
  onOutputCallback: ((output: Output) => void) | undefined;
38
39
  onForkActiveCallback: ((active: boolean) => void) | undefined;
@@ -48,6 +49,12 @@ export declare class AdaptAutomationElement extends BaseElement {
48
49
  set dialogBackdropClose(v: boolean);
49
50
  get dialogResizeToContent(): boolean;
50
51
  set dialogResizeToContent(v: boolean);
52
+ get persist(): boolean;
53
+ set persist(v: boolean);
54
+ /**
55
+ * Clear persisted state, stop current session, and reinitialize fresh.
56
+ */
57
+ newSession(): Promise<void>;
51
58
  connectedCallback(): void;
52
59
  disconnectedCallback(): void;
53
60
  attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null): void;
@@ -6,7 +6,7 @@ declare global {
6
6
  import { configure, resetConfig } from "@mochabug/adapt-core";
7
7
  import { createConnectClient } from "@mochabug/adapt-core/connect";
8
8
  import type { AdaptWebClientOptions } from "./types.js";
9
- export type { AdaptWebClientOptions, CapWidgetI18n, CapWidgetOptions, ChallengeInfo, ForkDisplay, Output, RedeemedChallenge, SignalValue, StatusJson, } from "./types.js";
9
+ export type { AdaptWebClientOptions, CapWidgetI18n, CapWidgetOptions, ChallengeInfo, ForkDisplay, Output, PersistOptions, RedeemedChallenge, SignalValue, StatusJson, } from "./types.js";
10
10
  export { AdaptCapWidget, type AdaptCapWidgetOptions, } from "./AdaptCapWidget.js";
11
11
  export { createChallenge, redeemChallenge } from "./cap-adapter.js";
12
12
  export type { SignalData } from "@mochabug/adapt-core";
@@ -27,6 +27,14 @@ export interface AdaptAutoResizingMessage {
27
27
  type: "adapt-autoResizing";
28
28
  autoResizing: boolean;
29
29
  }
30
+ /**
31
+ * Clear persisted session state for a given automation ID.
32
+ * Use this to force a fresh session on next page load.
33
+ *
34
+ * @param automationId - The automation ID whose state to clear
35
+ * @param storage - Which storage to clear: 'session' (default), 'local', or 'both'
36
+ */
37
+ export declare function clearPersistedState(automationId: string, storage?: "session" | "local" | "both"): void;
30
38
  /**
31
39
  * Browser client for rendering Adapt automation sessions in iframes.
32
40
  *
@@ -73,6 +81,7 @@ export declare class AdaptWebClient {
73
81
  private forkDisplay;
74
82
  private destroyed;
75
83
  private lastForkActive;
84
+ private sessionExpiresAt;
76
85
  private stoppedPlaceholder;
77
86
  private capWidgetInstance;
78
87
  constructor(options: AdaptWebClientOptions);
@@ -126,6 +135,17 @@ export declare class AdaptWebClient {
126
135
  * Destroy the Cap.js widget instance if present.
127
136
  */
128
137
  private destroyCapWidget;
138
+ private resolvePersistOptions;
139
+ private getStorageKey;
140
+ private getStorage;
141
+ private saveState;
142
+ private loadState;
143
+ private clearState;
144
+ private tryRestoreSession;
145
+ /**
146
+ * Clear persisted state, stop current session, and reinitialize fresh.
147
+ */
148
+ newSession(): Promise<void>;
129
149
  destroy(): Promise<void>;
130
150
  private onUrl;
131
151
  private handleSessionComplete;
@@ -1,5 +1,14 @@
1
1
  import type { Output, SignalValue, StatusJson } from "@mochabug/adapt-core";
2
2
  export type { Output, SignalValue, StatusJson } from "@mochabug/adapt-core";
3
+ /**
4
+ * Options for session persistence across page refreshes.
5
+ */
6
+ export interface PersistOptions {
7
+ /** Storage type: 'session' (default) uses sessionStorage, 'local' uses localStorage. */
8
+ storage?: "session" | "local";
9
+ /** Max TTL in seconds (default: 3600). Capped by the session's own expiresAt. */
10
+ ttl?: number;
11
+ }
3
12
  /**
4
13
  * Discriminated union for fork display configuration.
5
14
  *
@@ -374,4 +383,16 @@ export interface AdaptWebClientOptions {
374
383
  * ```
375
384
  */
376
385
  styles?: Partial<CSSStyleDeclaration>;
386
+ /**
387
+ * Enable session persistence across page refreshes.
388
+ *
389
+ * When enabled, the session token and URL state are stored in browser storage.
390
+ * On page load, the client reconnects to the existing session instead of creating a new one.
391
+ *
392
+ * - `true` — use sessionStorage with 3600s TTL
393
+ * - `PersistOptions` — customize storage type and TTL
394
+ *
395
+ * @default undefined (disabled)
396
+ */
397
+ persist?: boolean | PersistOptions;
377
398
  }