@frak-labs/core-sdk 0.2.1 → 1.0.0-beta.61e6fb99

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.
Files changed (92) hide show
  1. package/README.md +1 -2
  2. package/cdn/bundle.js +3 -3
  3. package/dist/actions-Di4welXI.cjs +1 -0
  4. package/dist/actions-DyMkUe65.js +1 -0
  5. package/dist/actions.cjs +1 -1
  6. package/dist/actions.d.cts +3 -3
  7. package/dist/actions.d.ts +3 -3
  8. package/dist/actions.js +1 -1
  9. package/dist/bundle.cjs +1 -1
  10. package/dist/bundle.d.cts +4 -4
  11. package/dist/bundle.d.ts +4 -4
  12. package/dist/bundle.js +1 -1
  13. package/dist/{computeLegacyProductId-CCAZvLa5.d.cts → index-B_Uj-puh.d.ts} +249 -73
  14. package/dist/{computeLegacyProductId-b5cUWdAm.d.ts → index-ByVpu25D.d.cts} +249 -73
  15. package/dist/{siweAuthenticate-CnCZ7mok.d.ts → index-CGyEOo9J.d.cts} +122 -8
  16. package/dist/{siweAuthenticate-CVigMOxz.d.cts → index-Cdf5j2_W.d.ts} +122 -8
  17. package/dist/index.cjs +1 -1
  18. package/dist/index.d.cts +3 -3
  19. package/dist/index.d.ts +3 -3
  20. package/dist/index.js +1 -1
  21. package/dist/{openSso-B0g7-807.d.cts → openSso-B6pD2oA6.d.ts} +380 -46
  22. package/dist/{openSso-CMzwvaCa.d.ts → openSso-qjaccFd0.d.cts} +379 -45
  23. package/dist/sdkConfigStore-DvwFc6Ym.cjs +1 -0
  24. package/dist/sdkConfigStore-M37skmM8.js +1 -0
  25. package/dist/src-BqpqVHCq.cjs +13 -0
  26. package/dist/src-BxRYON49.js +13 -0
  27. package/package.json +12 -13
  28. package/src/actions/displayEmbeddedWallet.ts +6 -2
  29. package/src/actions/displayModal.ts +6 -2
  30. package/src/actions/displaySharingPage.ts +49 -0
  31. package/src/actions/ensureIdentity.ts +2 -2
  32. package/src/actions/getMerchantInformation.test.ts +13 -1
  33. package/src/actions/getMerchantInformation.ts +20 -5
  34. package/src/actions/getMergeToken.ts +33 -0
  35. package/src/actions/getUserReferralStatus.ts +42 -0
  36. package/src/actions/index.ts +8 -1
  37. package/src/actions/referral/processReferral.test.ts +4 -8
  38. package/src/actions/referral/processReferral.ts +5 -11
  39. package/src/actions/referral/setupReferral.test.ts +79 -0
  40. package/src/actions/referral/setupReferral.ts +32 -0
  41. package/src/actions/trackPurchaseStatus.test.ts +32 -20
  42. package/src/actions/trackPurchaseStatus.ts +3 -5
  43. package/src/actions/wrapper/modalBuilder.test.ts +4 -2
  44. package/src/actions/wrapper/modalBuilder.ts +6 -8
  45. package/src/clients/createIFrameFrakClient.ts +233 -28
  46. package/src/clients/transports/iframeLifecycleManager.test.ts +14 -94
  47. package/src/clients/transports/iframeLifecycleManager.ts +35 -53
  48. package/src/index.ts +25 -5
  49. package/src/stubs/rrweb.ts +9 -0
  50. package/src/types/config.ts +19 -3
  51. package/src/types/index.ts +15 -1
  52. package/src/types/lifecycle/client.ts +29 -27
  53. package/src/types/lifecycle/iframe.ts +7 -8
  54. package/src/types/resolvedConfig.ts +138 -0
  55. package/src/types/rpc/displaySharingPage.ts +100 -0
  56. package/src/types/rpc/embedded/index.ts +1 -1
  57. package/src/types/rpc/interaction.ts +4 -0
  58. package/src/types/rpc/userReferralStatus.ts +20 -0
  59. package/src/types/rpc.ts +54 -5
  60. package/src/types/tracking.ts +36 -0
  61. package/src/utils/FrakContext.test.ts +151 -0
  62. package/src/utils/FrakContext.ts +67 -1
  63. package/src/utils/analytics/events/component.ts +58 -0
  64. package/src/utils/analytics/events/index.ts +20 -0
  65. package/src/utils/analytics/events/lifecycle.ts +26 -0
  66. package/src/utils/analytics/events/referral.ts +10 -0
  67. package/src/utils/analytics/index.ts +8 -0
  68. package/src/utils/{trackEvent.test.ts → analytics/trackEvent.test.ts} +22 -30
  69. package/src/utils/analytics/trackEvent.ts +34 -0
  70. package/src/utils/backendUrl.test.ts +2 -2
  71. package/src/utils/backendUrl.ts +1 -1
  72. package/src/utils/cache/index.ts +7 -0
  73. package/src/utils/cache/lruMap.test.ts +55 -0
  74. package/src/utils/cache/lruMap.ts +38 -0
  75. package/src/utils/cache/withCache.test.ts +168 -0
  76. package/src/utils/cache/withCache.ts +124 -0
  77. package/src/utils/inAppBrowser.ts +60 -0
  78. package/src/utils/index.ts +11 -5
  79. package/src/utils/mergeAttribution.test.ts +153 -0
  80. package/src/utils/mergeAttribution.ts +75 -0
  81. package/src/utils/sdkConfigStore.test.ts +405 -0
  82. package/src/utils/sdkConfigStore.ts +263 -0
  83. package/src/utils/sso.ts +3 -7
  84. package/dist/setupClient-BduY6Sym.cjs +0 -13
  85. package/dist/setupClient-ftmdQ-I8.js +0 -13
  86. package/dist/siweAuthenticate-BWmI2_TN.cjs +0 -1
  87. package/dist/siweAuthenticate-zczqxm0a.js +0 -1
  88. package/dist/trackEvent-CeLFVzZn.js +0 -1
  89. package/dist/trackEvent-Ew5r5zfI.cjs +0 -1
  90. package/src/utils/merchantId.test.ts +0 -653
  91. package/src/utils/merchantId.ts +0 -143
  92. package/src/utils/trackEvent.ts +0 -41
@@ -1,6 +1,6 @@
1
1
  import { getBackendUrl } from "../utils/backendUrl";
2
2
  import { getClientId } from "../utils/clientId";
3
- import { fetchMerchantId } from "../utils/merchantId";
3
+ import { sdkConfigStore } from "../utils/sdkConfigStore";
4
4
 
5
5
  /**
6
6
  * Function used to track the status of a purchase
@@ -26,7 +26,7 @@ import { fetchMerchantId } from "../utils/merchantId";
26
26
  * }
27
27
  *
28
28
  * @remarks
29
- * - Merchant id is resolved in this order: explicit `args.merchantId`, `frak-merchant-id` from session storage, then `fetchMerchantId()`.
29
+ * - Merchant id is resolved in this order: explicit `args.merchantId`, then `sdkConfigStore.resolveMerchantId()` (config store sessionStorage → backend fetch).
30
30
  * - This function supports anonymous users and will use the `x-frak-client-id` header when available.
31
31
  * - At least one identity source must exist (`frak-wallet-interaction-token` or `x-frak-client-id`), otherwise the tracking request is skipped.
32
32
  * - This function will print a warning if used in a non-browser environment or if no identity / merchant id can be resolved.
@@ -52,10 +52,8 @@ export async function trackPurchaseStatus(args: {
52
52
  return;
53
53
  }
54
54
 
55
- const merchantIdFromStorage =
56
- window.sessionStorage.getItem("frak-merchant-id");
57
55
  const merchantId =
58
- args.merchantId ?? merchantIdFromStorage ?? (await fetchMerchantId());
56
+ args.merchantId ?? (await sdkConfigStore.resolveMerchantId());
59
57
 
60
58
  if (!merchantId) {
61
59
  console.warn("[Frak] No merchant id found, skipping purchase check");
@@ -196,7 +196,8 @@ describe("modalBuilder", () => {
196
196
 
197
197
  expect(displayModal).toHaveBeenCalledWith(
198
198
  mockClient,
199
- builder.params
199
+ builder.params,
200
+ undefined
200
201
  );
201
202
  });
202
203
 
@@ -216,7 +217,8 @@ describe("modalBuilder", () => {
216
217
  mockClient,
217
218
  expect.objectContaining({
218
219
  metadata: { header: { title: "Overridden" } },
219
- })
220
+ }),
221
+ undefined
220
222
  );
221
223
  });
222
224
 
@@ -46,11 +46,13 @@ export type ModalStepBuilder<
46
46
  /**
47
47
  * Display the modal
48
48
  * @param metadataOverride - Function returning optional metadata to override the current modal metadata
49
+ * @param placement - Optional placement ID to associate with this modal display
49
50
  */
50
51
  display: (
51
52
  metadataOverride?: (
52
53
  current?: ModalRpcMetadata
53
- ) => ModalRpcMetadata | undefined
54
+ ) => ModalRpcMetadata | undefined,
55
+ placement?: string
54
56
  ) => Promise<ModalRpcStepsResultType<Steps>>;
55
57
  };
56
58
 
@@ -177,27 +179,23 @@ function modalStepsBuilder<CurrentSteps extends ModalStepTypes[]>(
177
179
  );
178
180
  }
179
181
 
180
- // Function to display it
181
182
  async function display(
182
183
  metadataOverride?: (
183
184
  current?: ModalRpcMetadata
184
- ) => ModalRpcMetadata | undefined
185
+ ) => ModalRpcMetadata | undefined,
186
+ placement?: string
185
187
  ) {
186
- // If we have a metadata override, apply it
187
188
  if (metadataOverride) {
188
189
  params.metadata = metadataOverride(params.metadata ?? {});
189
190
  }
190
- return await displayModal(client, params);
191
+ return await displayModal(client, params, placement);
191
192
  }
192
193
 
193
194
  return {
194
- // Access current modal params
195
195
  params,
196
- // Function to add new steps
197
196
  sendTx,
198
197
  reward,
199
198
  sharing,
200
- // Display the modal
201
199
  display,
202
200
  };
203
201
  }
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  createRpcClient,
3
+ Deferred,
3
4
  FrakRpcError,
4
5
  type RpcClient,
5
6
  RpcErrorCodes,
@@ -8,9 +9,12 @@ import { OpenPanel } from "@openpanel/web";
8
9
  import type { FrakLifecycleEvent } from "../types";
9
10
  import type { FrakClient } from "../types/client";
10
11
  import type { FrakWalletSdkConfig } from "../types/config";
12
+ import type { SdkResolvedConfig } from "../types/resolvedConfig";
11
13
  import type { IFrameRpcSchema } from "../types/rpc";
12
14
  import { getClientId } from "../utils";
15
+ import { clearAllCache } from "../utils/cache";
13
16
  import { BACKUP_KEY } from "../utils/constants";
17
+ import { sdkConfigStore } from "../utils/sdkConfigStore";
14
18
  import { setupSsoUrlListener } from "../utils/ssoUrlListener";
15
19
  import { DebugInfoGatherer } from "./DebugInfo";
16
20
  import {
@@ -19,12 +23,13 @@ import {
19
23
  } from "./transports/iframeLifecycleManager";
20
24
 
21
25
  type SdkRpcClient = RpcClient<IFrameRpcSchema, FrakLifecycleEvent>;
26
+ type MerchantConfigResult = Awaited<ReturnType<typeof sdkConfigStore.resolve>>;
22
27
 
23
28
  /**
24
29
  * Create a new iframe Frak client
25
30
  * @param args
26
31
  * @param args.config - The configuration to use for the Frak Wallet SDK.
27
- * When `config.domain` is set, it is forwarded to the iframe handshake so the listener resolves the correct merchant in tunneled/proxied environments (e.g. Shopify dev with Cloudflare tunnel).
32
+ * When `config.domain` is set, it is used to resolve the correct merchant config in tunneled/proxied environments (e.g. Shopify dev with Cloudflare tunnel).
28
33
  * @param args.iframe - The iframe to use for the communication
29
34
  * @returns The created Frak Client
30
35
  *
@@ -46,13 +51,39 @@ export function createIFrameFrakClient({
46
51
  }): FrakClient {
47
52
  const frakWalletUrl = config?.walletUrl ?? "https://wallet.frak.id";
48
53
 
54
+ const browserLang =
55
+ typeof navigator !== "undefined"
56
+ ? navigator.language?.split("-")[0]
57
+ : undefined;
58
+ const detectedLang =
59
+ config.metadata.lang ??
60
+ (browserLang === "en" || browserLang === "fr"
61
+ ? browserLang
62
+ : undefined);
63
+ const targetDomain =
64
+ config.domain ??
65
+ (typeof window !== "undefined" ? window.location.hostname : "");
66
+ sdkConfigStore.setCacheScope(targetDomain, detectedLang);
67
+ sdkConfigStore.reset();
68
+
69
+ // Skip fetch entirely if cache is fresh, otherwise fetch (SWR)
70
+ const configPromise = sdkConfigStore.isCacheFresh
71
+ ? undefined
72
+ : sdkConfigStore.resolve(config.domain, config.walletUrl, detectedLang);
73
+
49
74
  // Create lifecycle manager
50
75
  const lifecycleManager = createIFrameLifecycleManager({
51
76
  iframe,
52
77
  targetOrigin: frakWalletUrl,
53
- configDomain: config.domain,
54
78
  });
55
79
 
80
+ // Resolved after first resolved-config is sent to iframe (prevents RPC before context exists)
81
+ const contextSent = new Deferred<void>();
82
+
83
+ // Handshake timing: measured from client creation until the iframe
84
+ // lifecycle manager resolves the `isConnected` promise.
85
+ const handshakeStartedAt = Date.now();
86
+
56
87
  // Create our debug info gatherer
57
88
  const debugInfo = new DebugInfoGatherer(config, iframe);
58
89
 
@@ -70,10 +101,9 @@ export function createIFrameFrakClient({
70
101
  listeningTransport: window,
71
102
  targetOrigin: frakWalletUrl,
72
103
  middleware: [
73
- // Ensure we are connected before sending request
104
+ // Ensure we are connected and context is sent before sending request
74
105
  {
75
106
  async onRequest(_message, ctx) {
76
- // Ensure the iframe is connected
77
107
  const isConnected = await lifecycleManager.isConnected;
78
108
  if (!isConnected) {
79
109
  throw new FrakRpcError(
@@ -81,6 +111,7 @@ export function createIFrameFrakClient({
81
111
  "The iframe provider isn't connected yet"
82
112
  );
83
113
  }
114
+ await contextSent.promise;
84
115
  return ctx;
85
116
  },
86
117
  },
@@ -98,9 +129,9 @@ export function createIFrameFrakClient({
98
129
  ],
99
130
  // Add lifecycle handlers to process iframe lifecycle events
100
131
  lifecycleHandlers: {
101
- iframeLifecycle: async (event, _context) => {
132
+ iframeLifecycle: (event, _context) => {
102
133
  // Delegate to lifecycle manager (cast for type compatibility)
103
- await lifecycleManager.handleEvent(event);
134
+ lifecycleManager.handleEvent(event);
104
135
  },
105
136
  },
106
137
  });
@@ -108,14 +139,13 @@ export function createIFrameFrakClient({
108
139
  // Setup heartbeat
109
140
  const stopHeartbeat = setupHeartbeat(rpcClient, lifecycleManager);
110
141
 
111
- // Build our destroy function
112
142
  const destroy = async () => {
113
- // Stop heartbeat
114
143
  stopHeartbeat();
115
- // Cleanup the RPC client
116
144
  rpcClient.cleanup();
117
- // Remove the iframe
118
145
  iframe.remove();
146
+ clearAllCache();
147
+ sdkConfigStore.clearCache();
148
+ sdkConfigStore.reset();
119
149
  };
120
150
 
121
151
  // Init open panel
@@ -154,6 +184,38 @@ export function createIFrameFrakClient({
154
184
  userAnonymousClientId: getClientId(),
155
185
  });
156
186
  openPanel.init();
187
+ openPanel.track("sdk_initialized", {
188
+ sdkVersion: process.env.SDK_VERSION,
189
+ });
190
+
191
+ // Race the connection against the heartbeat timeout so we can
192
+ // distinguish "connected" from "timeout" cleanly without touching
193
+ // the heartbeat plumbing. 30s matches `HEARTBEAT_TIMEOUT`.
194
+ let settled = false;
195
+ const timeoutHandle = setTimeout(() => {
196
+ if (settled) return;
197
+ settled = true;
198
+ openPanel?.track("sdk_iframe_handshake_failed", {
199
+ reason: "timeout",
200
+ });
201
+ }, 30_000);
202
+ lifecycleManager.isConnected
203
+ .then(() => {
204
+ if (settled) return;
205
+ settled = true;
206
+ clearTimeout(timeoutHandle);
207
+ openPanel?.track("sdk_iframe_connected", {
208
+ handshake_duration_ms: Date.now() - handshakeStartedAt,
209
+ });
210
+ })
211
+ .catch(() => {
212
+ if (settled) return;
213
+ settled = true;
214
+ clearTimeout(timeoutHandle);
215
+ openPanel?.track("sdk_iframe_handshake_failed", {
216
+ reason: "unknown",
217
+ });
218
+ });
157
219
  }
158
220
 
159
221
  // Perform the post connection setup
@@ -161,7 +223,15 @@ export function createIFrameFrakClient({
161
223
  config,
162
224
  rpcClient,
163
225
  lifecycleManager,
164
- }).then(() => debugInfo.updateSetupStatus(true));
226
+ configPromise,
227
+ contextSent,
228
+ openPanel,
229
+ })
230
+ .then(() => debugInfo.updateSetupStatus(true))
231
+ .catch((err) => {
232
+ contextSent.reject(err);
233
+ throw err;
234
+ });
165
235
 
166
236
  return {
167
237
  config,
@@ -238,55 +308,190 @@ async function postConnectionSetup({
238
308
  config,
239
309
  rpcClient,
240
310
  lifecycleManager,
311
+ configPromise,
312
+ contextSent,
313
+ openPanel,
241
314
  }: {
242
315
  config: FrakWalletSdkConfig;
243
316
  rpcClient: SdkRpcClient;
244
317
  lifecycleManager: IframeLifecycleManager;
318
+ configPromise: Promise<MerchantConfigResult> | undefined;
319
+ contextSent: Deferred<void>;
320
+ openPanel: OpenPanel | undefined;
245
321
  }): Promise<void> {
246
- // Wait for the handler to be connected
247
322
  await lifecycleManager.isConnected;
248
323
 
249
- // Setup SSO URL listener to detect and forward SSO redirects
250
- // This checks for ?sso= parameter and forwards compressed data to iframe
251
324
  setupSsoUrlListener(rpcClient, lifecycleManager.isConnected);
252
325
 
326
+ // Read and consume the pending merge token from URL (SSO identity merge)
327
+ const url = new URL(window.location.href);
328
+ const pendingMergeToken = url.searchParams.get("fmt") ?? undefined;
329
+ if (pendingMergeToken) {
330
+ url.searchParams.delete("fmt");
331
+ window.history.replaceState({}, "", url.toString());
332
+ }
333
+
334
+ // Merge a raw backend response with SDK metadata and persist to store
335
+ const mergeAndSetConfig = (merchantConfig: MerchantConfigResult) => {
336
+ const merchantId =
337
+ merchantConfig?.merchantId ?? config.metadata.merchantId ?? "";
338
+ const domain = merchantConfig?.domain ?? "";
339
+ const allowedDomains = merchantConfig?.allowedDomains ?? [];
340
+ const raw = merchantConfig?.sdkConfig;
341
+
342
+ // Per-field merge: backend wins over SDK static config.
343
+ const mergedAttribution =
344
+ raw?.attribution || config.attribution
345
+ ? { ...config.attribution, ...raw?.attribution }
346
+ : undefined;
347
+
348
+ sdkConfigStore.setConfig(
349
+ raw
350
+ ? {
351
+ isResolved: true,
352
+ merchantId,
353
+ domain,
354
+ allowedDomains,
355
+ hasRawSdkConfig: true,
356
+ name: raw.name ?? config.metadata.name,
357
+ logoUrl: raw.logoUrl ?? config.metadata.logoUrl,
358
+ homepageLink:
359
+ raw.homepageLink ?? config.metadata.homepageLink,
360
+ lang: raw.lang ?? config.metadata.lang,
361
+ currency: raw.currency ?? config.metadata.currency,
362
+ hidden: raw.hidden,
363
+ css: raw.css,
364
+ translations: raw.translations,
365
+ placements: raw.placements,
366
+ components: raw.components,
367
+ attribution: mergedAttribution,
368
+ }
369
+ : {
370
+ isResolved: true,
371
+ merchantId,
372
+ domain,
373
+ allowedDomains,
374
+ name: config.metadata.name,
375
+ logoUrl: config.metadata.logoUrl,
376
+ homepageLink: config.metadata.homepageLink,
377
+ lang: config.metadata.lang,
378
+ currency: config.metadata.currency,
379
+ attribution: mergedAttribution,
380
+ }
381
+ );
382
+ };
383
+
384
+ // Send the resolved-config lifecycle event to the iframe.
385
+ // This is where we also update SDK-side OpenPanel global props with
386
+ // `merchantId` + `domain` (first time they are known) so every
387
+ // subsequent SDK event is merchant-attributed. We pass
388
+ // `sdkAnonymousId` through so the listener can join SDK funnels.
389
+ let mergeTokenConsumed = false;
390
+ const sendLifecycleConfig = (resolved: SdkResolvedConfig) => {
391
+ const token = mergeTokenConsumed ? undefined : pendingMergeToken;
392
+ mergeTokenConsumed = true;
393
+
394
+ const sdkConfig = resolved.hasRawSdkConfig
395
+ ? {
396
+ name: resolved.name,
397
+ logoUrl: resolved.logoUrl,
398
+ homepageLink: resolved.homepageLink,
399
+ lang: resolved.lang,
400
+ currency: resolved.currency,
401
+ hidden: resolved.hidden,
402
+ css: resolved.css,
403
+ translations: resolved.translations,
404
+ placements: resolved.placements,
405
+ attribution: resolved.attribution,
406
+ }
407
+ : resolved.attribution
408
+ ? { attribution: resolved.attribution }
409
+ : undefined;
410
+
411
+ const sdkAnonymousId = getClientId();
412
+
413
+ if (openPanel) {
414
+ const current = openPanel.global ?? {};
415
+ openPanel.setGlobalProperties({
416
+ ...current,
417
+ merchantId: resolved.merchantId,
418
+ domain: resolved.domain ?? "",
419
+ });
420
+ }
421
+
422
+ rpcClient.sendLifecycle({
423
+ clientLifecycle: "resolved-config",
424
+ data: {
425
+ merchantId: resolved.merchantId,
426
+ domain: resolved.domain ?? "",
427
+ allowedDomains: resolved.allowedDomains ?? [],
428
+ sourceUrl: window.location.href,
429
+ ...(sdkAnonymousId && { sdkAnonymousId }),
430
+ ...(token && { pendingMergeToken: token }),
431
+ ...(sdkConfig && { sdkConfig }),
432
+ },
433
+ });
434
+ };
435
+
436
+ // SWR: if we have cached data, send it to the iframe immediately
437
+ if (sdkConfigStore.isResolved) {
438
+ sendLifecycleConfig(sdkConfigStore.getConfig());
439
+ contextSent.resolve();
440
+ }
441
+
442
+ // If a fetch is running (stale/missing cache), wait for fresh data and update
443
+ if (configPromise) {
444
+ const merchantConfig = await configPromise;
445
+ mergeAndSetConfig(merchantConfig);
446
+ sendLifecycleConfig(sdkConfigStore.getConfig());
447
+ contextSent.resolve();
448
+ }
449
+
253
450
  // Push raw CSS if needed
254
451
  async function pushCss() {
255
452
  const cssLink = config.customizations?.css;
256
453
  if (!cssLink) return;
257
-
258
- const message = {
454
+ rpcClient.sendLifecycle({
259
455
  clientLifecycle: "modal-css" as const,
260
456
  data: { cssLink },
261
- };
262
- rpcClient.sendLifecycle(message);
457
+ });
263
458
  }
264
459
 
265
460
  // Push i18n if needed
266
461
  async function pushI18n() {
267
462
  const i18n = config.customizations?.i18n;
268
463
  if (!i18n) return;
269
-
270
- const message = {
464
+ rpcClient.sendLifecycle({
271
465
  clientLifecycle: "modal-i18n" as const,
272
466
  data: { i18n },
273
- };
274
- rpcClient.sendLifecycle(message);
467
+ });
275
468
  }
276
469
 
277
470
  // Push local backup if needed
278
471
  async function pushBackup() {
279
472
  if (typeof window === "undefined") return;
280
-
281
473
  const backup = window.localStorage.getItem(BACKUP_KEY);
282
474
  if (!backup) return;
283
-
284
- const message = {
475
+ rpcClient.sendLifecycle({
285
476
  clientLifecycle: "restore-backup" as const,
286
477
  data: { backup },
287
- };
288
- rpcClient.sendLifecycle(message);
478
+ });
289
479
  }
290
480
 
291
- await Promise.allSettled([pushCss(), pushI18n(), pushBackup()]);
481
+ // Inspect each setup result — a failed CSS/i18n/backup push leaves the
482
+ // partner UI in a broken-but-connected state (iframe reports
483
+ // `sdk_iframe_connected`, user sees no modal styles / wrong locale).
484
+ // Surface it as a distinct handshake reason so dashboards can
485
+ // distinguish timeout vs. asset-push failures.
486
+ const results = await Promise.allSettled([
487
+ pushCss(),
488
+ pushI18n(),
489
+ pushBackup(),
490
+ ]);
491
+ const hasFailedAssetPush = results.some((r) => r.status === "rejected");
492
+ if (hasFailedAssetPush) {
493
+ openPanel?.track("sdk_iframe_handshake_failed", {
494
+ reason: "asset_push",
495
+ });
496
+ }
292
497
  }
@@ -102,7 +102,7 @@ describe("createIFrameLifecycleManager", () => {
102
102
  iframeLifecycle: "connected" as const,
103
103
  };
104
104
 
105
- await manager.handleEvent(event);
105
+ manager.handleEvent(event);
106
106
 
107
107
  await expect(manager.isConnected).resolves.toBe(true);
108
108
  });
@@ -126,7 +126,7 @@ describe("createIFrameLifecycleManager", () => {
126
126
  data: { backup },
127
127
  };
128
128
 
129
- await manager.handleEvent(event);
129
+ manager.handleEvent(event);
130
130
 
131
131
  expect(localStorage.getItem("frak-backup-key")).toBe(backup);
132
132
  });
@@ -150,7 +150,7 @@ describe("createIFrameLifecycleManager", () => {
150
150
  data: {},
151
151
  };
152
152
 
153
- await manager.handleEvent(event);
153
+ manager.handleEvent(event);
154
154
 
155
155
  expect(localStorage.getItem("frak-backup-key")).toBeNull();
156
156
  });
@@ -173,7 +173,7 @@ describe("createIFrameLifecycleManager", () => {
173
173
  iframeLifecycle: "remove-backup" as const,
174
174
  };
175
175
 
176
- await manager.handleEvent(event);
176
+ manager.handleEvent(event);
177
177
 
178
178
  expect(localStorage.getItem("frak-backup-key")).toBeNull();
179
179
  });
@@ -198,7 +198,7 @@ describe("createIFrameLifecycleManager", () => {
198
198
  iframeLifecycle: "show" as const,
199
199
  };
200
200
 
201
- await manager.handleEvent(event);
201
+ manager.handleEvent(event);
202
202
 
203
203
  expect(changeIframeVisibility).toHaveBeenCalledWith({
204
204
  iframe: mockIframe,
@@ -224,7 +224,7 @@ describe("createIFrameLifecycleManager", () => {
224
224
  iframeLifecycle: "hide" as const,
225
225
  };
226
226
 
227
- await manager.handleEvent(event);
227
+ manager.handleEvent(event);
228
228
 
229
229
  expect(changeIframeVisibility).toHaveBeenCalledWith({
230
230
  iframe: mockIframe,
@@ -233,86 +233,6 @@ describe("createIFrameLifecycleManager", () => {
233
233
  });
234
234
  });
235
235
 
236
- describe("handshake event", () => {
237
- test("should post handshake-response with token to iframe origin", async () => {
238
- const { createIFrameLifecycleManager } = await import(
239
- "./iframeLifecycleManager"
240
- );
241
-
242
- const mockPostMessage = vi.fn();
243
- const mockIframe = {
244
- src: "https://wallet.frak.id/listener",
245
- contentWindow: {
246
- postMessage: mockPostMessage,
247
- },
248
- } as any;
249
-
250
- const manager = createIFrameLifecycleManager({
251
- iframe: mockIframe,
252
- targetOrigin: WALLET_ORIGIN,
253
- });
254
-
255
- const event = {
256
- iframeLifecycle: "handshake" as const,
257
- data: { token: "handshake-token-123" },
258
- };
259
-
260
- await manager.handleEvent(event);
261
-
262
- expect(mockPostMessage).toHaveBeenCalledWith(
263
- {
264
- clientLifecycle: "handshake-response",
265
- data: {
266
- token: "handshake-token-123",
267
- currentUrl: "https://test.com",
268
- clientId: "mock-client-id",
269
- },
270
- },
271
- "https://wallet.frak.id"
272
- );
273
- });
274
-
275
- test("should include current URL in handshake response", async () => {
276
- const { createIFrameLifecycleManager } = await import(
277
- "./iframeLifecycleManager"
278
- );
279
-
280
- Object.defineProperty(window, "location", {
281
- value: { href: "https://example.com/page?param=value" },
282
- writable: true,
283
- });
284
-
285
- const mockPostMessage = vi.fn();
286
- const mockIframe = {
287
- src: "https://wallet.frak.id/listener",
288
- contentWindow: {
289
- postMessage: mockPostMessage,
290
- },
291
- } as any;
292
-
293
- const manager = createIFrameLifecycleManager({
294
- iframe: mockIframe,
295
- targetOrigin: WALLET_ORIGIN,
296
- });
297
-
298
- const event = {
299
- iframeLifecycle: "handshake" as const,
300
- data: { token: "token" },
301
- };
302
-
303
- await manager.handleEvent(event);
304
-
305
- expect(mockPostMessage).toHaveBeenCalledWith(
306
- expect.objectContaining({
307
- data: expect.objectContaining({
308
- currentUrl: "https://example.com/page?param=value",
309
- }),
310
- }),
311
- "https://wallet.frak.id"
312
- );
313
- });
314
- });
315
-
316
236
  describe("redirect event", () => {
317
237
  test("should redirect with appended current URL for HTTP URLs", async () => {
318
238
  const { createIFrameLifecycleManager } = await import(
@@ -339,7 +259,7 @@ describe("createIFrameLifecycleManager", () => {
339
259
  },
340
260
  };
341
261
 
342
- await manager.handleEvent(event);
262
+ manager.handleEvent(event);
343
263
 
344
264
  expect(window.location.href).toBe(
345
265
  "https://redirect.com/?u=https%3A%2F%2Foriginal.com"
@@ -371,7 +291,7 @@ describe("createIFrameLifecycleManager", () => {
371
291
  },
372
292
  };
373
293
 
374
- await manager.handleEvent(event);
294
+ manager.handleEvent(event);
375
295
 
376
296
  expect(window.location.href).toBe("https://redirect.com/path");
377
297
  });
@@ -405,7 +325,7 @@ describe("createIFrameLifecycleManager", () => {
405
325
  },
406
326
  };
407
327
 
408
- await manager.handleEvent(event);
328
+ manager.handleEvent(event);
409
329
 
410
330
  expect(triggerDeepLinkWithFallback).toHaveBeenCalledWith(
411
331
  "frakwallet://wallet",
@@ -450,7 +370,7 @@ describe("createIFrameLifecycleManager", () => {
450
370
  },
451
371
  };
452
372
 
453
- await manager.handleEvent(event);
373
+ manager.handleEvent(event);
454
374
 
455
375
  // Extract the onFallback callback from the mock call
456
376
  const callArgs = (triggerDeepLinkWithFallback as any).mock.calls[0];
@@ -499,7 +419,7 @@ describe("createIFrameLifecycleManager", () => {
499
419
  },
500
420
  };
501
421
 
502
- await manager.handleEvent(event);
422
+ manager.handleEvent(event);
503
423
 
504
424
  // Should NOT call fallback detection
505
425
  expect(triggerDeepLinkWithFallback).not.toHaveBeenCalled();
@@ -525,7 +445,7 @@ describe("createIFrameLifecycleManager", () => {
525
445
  } as any;
526
446
 
527
447
  // Should not throw
528
- await expect(manager.handleEvent(event)).resolves.toBeUndefined();
448
+ expect(manager.handleEvent(event)).toBeUndefined();
529
449
  });
530
450
 
531
451
  test("should only process events with iframeLifecycle", async () => {
@@ -543,13 +463,13 @@ describe("createIFrameLifecycleManager", () => {
543
463
  });
544
464
 
545
465
  // Event without iframeLifecycle
546
- await manager.handleEvent({ randomEvent: "show" } as any);
466
+ manager.handleEvent({ randomEvent: "show" } as any);
547
467
 
548
468
  // changeIframeVisibility should not be called
549
469
  expect(changeIframeVisibility).not.toHaveBeenCalled();
550
470
 
551
471
  // Event with iframeLifecycle
552
- await manager.handleEvent({ iframeLifecycle: "show" as const });
472
+ manager.handleEvent({ iframeLifecycle: "show" as const });
553
473
 
554
474
  // Now it should be called
555
475
  expect(changeIframeVisibility).toHaveBeenCalled();