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

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,223 @@ 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
+ // Fire synthetic STATUS_RUNNING so consumers don't need to distinguish
1375
+ // hydration from a fresh start
1376
+ handlers.onSession?.({ status: "STATUS_RUNNING" });
1377
+ }
1378
+ catch {
1379
+ this.clearState();
1380
+ this.sessionToken = null;
1381
+ this.sessionExpiresAt = undefined;
1382
+ this.mainUrl = null;
1383
+ this.mainToken = undefined;
1384
+ this.forkQueue = [];
1385
+ this.currentFork = null;
1386
+ if (this.mainIframe) {
1387
+ this.mainIframe.src = "about:blank";
1388
+ this.mainIframe.classList.remove("mb-adapt__iframe--visible");
1389
+ }
1390
+ if (this.forkIframe) {
1391
+ this.forkIframe.src = "about:blank";
1392
+ this.forkIframe.classList.remove("mb-adapt__iframe--visible");
1393
+ this.forkIframe.classList.add("mb-adapt__iframe--hidden");
1394
+ }
1395
+ if (this.forkDisplay.mode === "side-by-side") {
1396
+ this.updateSideBySideVisibility();
1397
+ }
1398
+ else {
1399
+ this.updateDialogVisibility();
1400
+ }
1401
+ return false;
1402
+ }
1403
+ return true;
1404
+ }
1405
+ /**
1406
+ * Clear persisted state, stop current session, and reinitialize fresh.
1407
+ */
1408
+ async newSession() {
1409
+ this.clearState();
1410
+ // Stop current session best-effort
1411
+ if (this.sessionToken) {
1412
+ this.client.stop(this.sessionToken).catch(() => { });
1413
+ }
1414
+ await this.client.unsubscribe();
1415
+ // Reset internal state
1416
+ this.mainUrl = null;
1417
+ this.mainToken = undefined;
1418
+ this.forkQueue = [];
1419
+ this.currentFork = null;
1420
+ this.sessionToken = null;
1421
+ this.sessionExpiresAt = undefined;
1422
+ this.lastForkActive = null;
1423
+ // Reset iframes
1424
+ if (this.mainIframe) {
1425
+ this.mainIframe.src = "about:blank";
1426
+ this.mainIframe.classList.remove("mb-adapt__iframe--visible");
1427
+ }
1428
+ if (this.forkIframe) {
1429
+ this.forkIframe.src = "about:blank";
1430
+ this.forkIframe.classList.remove("mb-adapt__iframe--visible");
1431
+ this.forkIframe.classList.add("mb-adapt__iframe--hidden");
1432
+ }
1433
+ // Reset visual state
1434
+ this.removeStoppedPlaceholder();
1435
+ if (this.forkDisplay.mode === "side-by-side") {
1436
+ this.updateSideBySideVisibility();
1437
+ }
1438
+ else {
1439
+ this.updateDialogVisibility();
1440
+ }
1441
+ // Reinitialize
1442
+ await this.init();
1443
+ }
1201
1444
  async destroy() {
1202
1445
  this.destroyed = true;
1203
1446
  // Clean up Cap.js widget if present
1204
1447
  this.destroyCapWidget();
1205
- // We stop best-effort. Sometimes you will get perm denied etc...
1206
- if (this.sessionToken) {
1448
+ // Stop session on destroy but NOT when persist has saved state (session survives page refresh).
1449
+ // If persisted state was cleared (explicit stop, terminal status), stop() is still called.
1450
+ if (this.sessionToken && !this.loadState()) {
1207
1451
  this.client.stop(this.sessionToken).catch(() => { });
1208
1452
  }
1209
1453
  await this.client.unsubscribe();
@@ -1255,10 +1499,12 @@ export class AdaptWebClient {
1255
1499
  const fork = msg.fork || "";
1256
1500
  if (msg.stopped) {
1257
1501
  this.handleStoppedMessage(fork);
1502
+ this.saveState();
1258
1503
  return;
1259
1504
  }
1260
1505
  if (msg.done) {
1261
1506
  this.handleDoneMessage(fork);
1507
+ this.saveState();
1262
1508
  return;
1263
1509
  }
1264
1510
  if (!msg.url)
@@ -1271,6 +1517,7 @@ export class AdaptWebClient {
1271
1517
  // Fork URL - should open dialog
1272
1518
  this.handleForkUrl(msg.url, msg.token, fork);
1273
1519
  }
1520
+ this.saveState();
1274
1521
  }
1275
1522
  handleSessionComplete() {
1276
1523
  // Clear all forks and minimize to show only main frame
@@ -1400,6 +1647,7 @@ export class AdaptWebClient {
1400
1647
  else {
1401
1648
  this.updateSideBySideVisibility();
1402
1649
  }
1650
+ this.saveState();
1403
1651
  }
1404
1652
  updateMainIframe() {
1405
1653
  if (!this.rootElement || !this.mainUrl || !this.mainIframe)
@@ -1510,6 +1758,7 @@ export class AdaptWebClient {
1510
1758
  this.splitPercentage = 50;
1511
1759
  }
1512
1760
  this.updateSideBySideVisibility();
1761
+ this.saveState();
1513
1762
  });
1514
1763
  this.wrapperElement.appendChild(this.mainFrameElement);
1515
1764
  this.wrapperElement.appendChild(this.forkFrameElement);
@@ -1682,6 +1931,7 @@ export class AdaptWebClient {
1682
1931
  this.currentFork = null;
1683
1932
  this.updateDialogVisibility();
1684
1933
  }
1934
+ this.saveState();
1685
1935
  }
1686
1936
  handleForkExit() {
1687
1937
  if (!this.currentFork)
@@ -1711,6 +1961,7 @@ export class AdaptWebClient {
1711
1961
  this.updateDialogVisibility();
1712
1962
  }
1713
1963
  }
1964
+ this.saveState();
1714
1965
  }
1715
1966
  createDialogStructure() {
1716
1967
  if (this.mainContainer)
@@ -1849,4 +2100,3 @@ export class AdaptWebClient {
1849
2100
  AdaptWebClient.COLLAPSE_THRESHOLD = 15;
1850
2101
  AdaptWebClient.RESPONSIVE_BREAKPOINT = 900; // px - below this, auto-collapse to single view
1851
2102
  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
  }