@frak-labs/core-sdk 0.2.1 → 1.0.0

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 (77) hide show
  1. package/README.md +1 -2
  2. package/cdn/bundle.js +3 -3
  3. package/dist/actions-D4aBXbdp.cjs +1 -0
  4. package/dist/actions-Dq_uN-wn.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-BV5D9DsW.d.ts} +91 -37
  14. package/dist/{siweAuthenticate-CnCZ7mok.d.ts → index-BphwTmKA.d.cts} +122 -8
  15. package/dist/{computeLegacyProductId-b5cUWdAm.d.ts → index-Dwmo109y.d.cts} +91 -37
  16. package/dist/{siweAuthenticate-CVigMOxz.d.cts → index-_f8EuN_1.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-BwEK2M98.d.cts} +283 -44
  22. package/dist/{openSso-CMzwvaCa.d.ts → openSso-C1Wzl5-i.d.ts} +283 -44
  23. package/dist/src-B1eliIi6.cjs +13 -0
  24. package/dist/src-C0UH1GsN.js +13 -0
  25. package/dist/trackEvent-BqJqRZ-u.cjs +1 -0
  26. package/dist/trackEvent-Bqq4jd6R.js +1 -0
  27. package/package.json +11 -12
  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/setupReferral.test.ts +79 -0
  38. package/src/actions/referral/setupReferral.ts +32 -0
  39. package/src/actions/trackPurchaseStatus.test.ts +32 -20
  40. package/src/actions/trackPurchaseStatus.ts +3 -5
  41. package/src/actions/wrapper/modalBuilder.test.ts +4 -2
  42. package/src/actions/wrapper/modalBuilder.ts +6 -8
  43. package/src/clients/createIFrameFrakClient.ts +151 -27
  44. package/src/clients/transports/iframeLifecycleManager.test.ts +14 -94
  45. package/src/clients/transports/iframeLifecycleManager.ts +35 -53
  46. package/src/index.ts +17 -4
  47. package/src/stubs/rrweb.ts +9 -0
  48. package/src/types/config.ts +10 -3
  49. package/src/types/index.ts +13 -1
  50. package/src/types/lifecycle/client.ts +22 -27
  51. package/src/types/lifecycle/iframe.ts +7 -8
  52. package/src/types/resolvedConfig.ts +128 -0
  53. package/src/types/rpc/displaySharingPage.ts +82 -0
  54. package/src/types/rpc/embedded/index.ts +1 -1
  55. package/src/types/rpc/interaction.ts +4 -0
  56. package/src/types/rpc/userReferralStatus.ts +20 -0
  57. package/src/types/rpc.ts +54 -5
  58. package/src/utils/backendUrl.test.ts +2 -2
  59. package/src/utils/backendUrl.ts +1 -1
  60. package/src/utils/cache/index.ts +7 -0
  61. package/src/utils/cache/lruMap.test.ts +55 -0
  62. package/src/utils/cache/lruMap.ts +38 -0
  63. package/src/utils/cache/withCache.test.ts +168 -0
  64. package/src/utils/cache/withCache.ts +124 -0
  65. package/src/utils/inAppBrowser.ts +60 -0
  66. package/src/utils/index.ts +6 -4
  67. package/src/utils/sdkConfigStore.test.ts +405 -0
  68. package/src/utils/sdkConfigStore.ts +263 -0
  69. package/src/utils/sso.ts +3 -7
  70. package/dist/setupClient-BduY6Sym.cjs +0 -13
  71. package/dist/setupClient-ftmdQ-I8.js +0 -13
  72. package/dist/siweAuthenticate-BWmI2_TN.cjs +0 -1
  73. package/dist/siweAuthenticate-zczqxm0a.js +0 -1
  74. package/dist/trackEvent-CeLFVzZn.js +0 -1
  75. package/dist/trackEvent-Ew5r5zfI.cjs +0 -1
  76. package/src/utils/merchantId.test.ts +0 -653
  77. package/src/utils/merchantId.ts +0 -143
@@ -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,35 @@ 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
+
56
83
  // Create our debug info gatherer
57
84
  const debugInfo = new DebugInfoGatherer(config, iframe);
58
85
 
@@ -70,10 +97,9 @@ export function createIFrameFrakClient({
70
97
  listeningTransport: window,
71
98
  targetOrigin: frakWalletUrl,
72
99
  middleware: [
73
- // Ensure we are connected before sending request
100
+ // Ensure we are connected and context is sent before sending request
74
101
  {
75
102
  async onRequest(_message, ctx) {
76
- // Ensure the iframe is connected
77
103
  const isConnected = await lifecycleManager.isConnected;
78
104
  if (!isConnected) {
79
105
  throw new FrakRpcError(
@@ -81,6 +107,7 @@ export function createIFrameFrakClient({
81
107
  "The iframe provider isn't connected yet"
82
108
  );
83
109
  }
110
+ await contextSent.promise;
84
111
  return ctx;
85
112
  },
86
113
  },
@@ -98,9 +125,9 @@ export function createIFrameFrakClient({
98
125
  ],
99
126
  // Add lifecycle handlers to process iframe lifecycle events
100
127
  lifecycleHandlers: {
101
- iframeLifecycle: async (event, _context) => {
128
+ iframeLifecycle: (event, _context) => {
102
129
  // Delegate to lifecycle manager (cast for type compatibility)
103
- await lifecycleManager.handleEvent(event);
130
+ lifecycleManager.handleEvent(event);
104
131
  },
105
132
  },
106
133
  });
@@ -108,14 +135,13 @@ export function createIFrameFrakClient({
108
135
  // Setup heartbeat
109
136
  const stopHeartbeat = setupHeartbeat(rpcClient, lifecycleManager);
110
137
 
111
- // Build our destroy function
112
138
  const destroy = async () => {
113
- // Stop heartbeat
114
139
  stopHeartbeat();
115
- // Cleanup the RPC client
116
140
  rpcClient.cleanup();
117
- // Remove the iframe
118
141
  iframe.remove();
142
+ clearAllCache();
143
+ sdkConfigStore.clearCache();
144
+ sdkConfigStore.reset();
119
145
  };
120
146
 
121
147
  // Init open panel
@@ -161,7 +187,14 @@ export function createIFrameFrakClient({
161
187
  config,
162
188
  rpcClient,
163
189
  lifecycleManager,
164
- }).then(() => debugInfo.updateSetupStatus(true));
190
+ configPromise,
191
+ contextSent,
192
+ })
193
+ .then(() => debugInfo.updateSetupStatus(true))
194
+ .catch((err) => {
195
+ contextSent.reject(err);
196
+ throw err;
197
+ });
165
198
 
166
199
  return {
167
200
  config,
@@ -238,54 +271,145 @@ async function postConnectionSetup({
238
271
  config,
239
272
  rpcClient,
240
273
  lifecycleManager,
274
+ configPromise,
275
+ contextSent,
241
276
  }: {
242
277
  config: FrakWalletSdkConfig;
243
278
  rpcClient: SdkRpcClient;
244
279
  lifecycleManager: IframeLifecycleManager;
280
+ configPromise: Promise<MerchantConfigResult> | undefined;
281
+ contextSent: Deferred<void>;
245
282
  }): Promise<void> {
246
- // Wait for the handler to be connected
247
283
  await lifecycleManager.isConnected;
248
284
 
249
- // Setup SSO URL listener to detect and forward SSO redirects
250
- // This checks for ?sso= parameter and forwards compressed data to iframe
251
285
  setupSsoUrlListener(rpcClient, lifecycleManager.isConnected);
252
286
 
287
+ // Read and consume the pending merge token from URL (SSO identity merge)
288
+ const url = new URL(window.location.href);
289
+ const pendingMergeToken = url.searchParams.get("fmt") ?? undefined;
290
+ if (pendingMergeToken) {
291
+ url.searchParams.delete("fmt");
292
+ window.history.replaceState({}, "", url.toString());
293
+ }
294
+
295
+ // Merge a raw backend response with SDK metadata and persist to store
296
+ const mergeAndSetConfig = (merchantConfig: MerchantConfigResult) => {
297
+ const merchantId =
298
+ merchantConfig?.merchantId ?? config.metadata.merchantId ?? "";
299
+ const domain = merchantConfig?.domain ?? "";
300
+ const allowedDomains = merchantConfig?.allowedDomains ?? [];
301
+ const raw = merchantConfig?.sdkConfig;
302
+
303
+ sdkConfigStore.setConfig(
304
+ raw
305
+ ? {
306
+ isResolved: true,
307
+ merchantId,
308
+ domain,
309
+ allowedDomains,
310
+ hasRawSdkConfig: true,
311
+ name: raw.name ?? config.metadata.name,
312
+ logoUrl: raw.logoUrl ?? config.metadata.logoUrl,
313
+ homepageLink:
314
+ raw.homepageLink ?? config.metadata.homepageLink,
315
+ lang: raw.lang ?? config.metadata.lang,
316
+ currency: raw.currency ?? config.metadata.currency,
317
+ hidden: raw.hidden,
318
+ css: raw.css,
319
+ translations: raw.translations,
320
+ placements: raw.placements,
321
+ components: raw.components,
322
+ }
323
+ : {
324
+ isResolved: true,
325
+ merchantId,
326
+ domain,
327
+ allowedDomains,
328
+ name: config.metadata.name,
329
+ logoUrl: config.metadata.logoUrl,
330
+ homepageLink: config.metadata.homepageLink,
331
+ lang: config.metadata.lang,
332
+ currency: config.metadata.currency,
333
+ }
334
+ );
335
+ };
336
+
337
+ // Send the resolved-config lifecycle event to the iframe
338
+ let mergeTokenConsumed = false;
339
+ const sendLifecycleConfig = (resolved: SdkResolvedConfig) => {
340
+ const token = mergeTokenConsumed ? undefined : pendingMergeToken;
341
+ mergeTokenConsumed = true;
342
+
343
+ const sdkConfig = resolved.hasRawSdkConfig
344
+ ? {
345
+ name: resolved.name,
346
+ logoUrl: resolved.logoUrl,
347
+ homepageLink: resolved.homepageLink,
348
+ lang: resolved.lang,
349
+ currency: resolved.currency,
350
+ hidden: resolved.hidden,
351
+ css: resolved.css,
352
+ translations: resolved.translations,
353
+ placements: resolved.placements,
354
+ }
355
+ : undefined;
356
+
357
+ rpcClient.sendLifecycle({
358
+ clientLifecycle: "resolved-config",
359
+ data: {
360
+ merchantId: resolved.merchantId,
361
+ domain: resolved.domain ?? "",
362
+ allowedDomains: resolved.allowedDomains ?? [],
363
+ sourceUrl: window.location.href,
364
+ ...(token && { pendingMergeToken: token }),
365
+ ...(sdkConfig && { sdkConfig }),
366
+ },
367
+ });
368
+ };
369
+
370
+ // SWR: if we have cached data, send it to the iframe immediately
371
+ if (sdkConfigStore.isResolved) {
372
+ sendLifecycleConfig(sdkConfigStore.getConfig());
373
+ contextSent.resolve();
374
+ }
375
+
376
+ // If a fetch is running (stale/missing cache), wait for fresh data and update
377
+ if (configPromise) {
378
+ const merchantConfig = await configPromise;
379
+ mergeAndSetConfig(merchantConfig);
380
+ sendLifecycleConfig(sdkConfigStore.getConfig());
381
+ contextSent.resolve();
382
+ }
383
+
253
384
  // Push raw CSS if needed
254
385
  async function pushCss() {
255
386
  const cssLink = config.customizations?.css;
256
387
  if (!cssLink) return;
257
-
258
- const message = {
388
+ rpcClient.sendLifecycle({
259
389
  clientLifecycle: "modal-css" as const,
260
390
  data: { cssLink },
261
- };
262
- rpcClient.sendLifecycle(message);
391
+ });
263
392
  }
264
393
 
265
394
  // Push i18n if needed
266
395
  async function pushI18n() {
267
396
  const i18n = config.customizations?.i18n;
268
397
  if (!i18n) return;
269
-
270
- const message = {
398
+ rpcClient.sendLifecycle({
271
399
  clientLifecycle: "modal-i18n" as const,
272
400
  data: { i18n },
273
- };
274
- rpcClient.sendLifecycle(message);
401
+ });
275
402
  }
276
403
 
277
404
  // Push local backup if needed
278
405
  async function pushBackup() {
279
406
  if (typeof window === "undefined") return;
280
-
281
407
  const backup = window.localStorage.getItem(BACKUP_KEY);
282
408
  if (!backup) return;
283
-
284
- const message = {
409
+ rpcClient.sendLifecycle({
285
410
  clientLifecycle: "restore-backup" as const,
286
411
  data: { backup },
287
- };
288
- rpcClient.sendLifecycle(message);
412
+ });
289
413
  }
290
414
 
291
415
  await Promise.allSettled([pushCss(), pushI18n(), pushBackup()]);
@@ -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();
@@ -1,6 +1,5 @@
1
1
  import { Deferred } from "@frak-labs/frame-connector";
2
2
  import type { FrakLifecycleEvent } from "../../types";
3
- import { getClientId } from "../../utils/clientId";
4
3
  import { BACKUP_KEY } from "../../utils/constants";
5
4
  import {
6
5
  isFrakDeepLink,
@@ -33,7 +32,7 @@ const isIOSInAppBrowser = (() => {
33
32
  /** @ignore */
34
33
  export type IframeLifecycleManager = {
35
34
  isConnected: Promise<boolean>;
36
- handleEvent: (messageEvent: FrakLifecycleEvent) => Promise<void>;
35
+ handleEvent: (messageEvent: FrakLifecycleEvent) => void;
37
36
  };
38
37
 
39
38
  /**
@@ -47,42 +46,6 @@ function handleBackup(backup: string | undefined): void {
47
46
  }
48
47
  }
49
48
 
50
- /**
51
- * Handle handshake with iframe — sends client metadata so the listener can resolve the correct merchant
52
- * @param iframe - The iframe element to post the handshake response to
53
- * @param token - The handshake token received from the iframe
54
- * @param targetOrigin - The target origin for postMessage security
55
- * @param configDomain - Optional override domain for merchant resolution in tunneled/proxied environments
56
- */
57
- function handleHandshake(
58
- iframe: HTMLIFrameElement,
59
- token: string,
60
- targetOrigin: string,
61
- configDomain?: string
62
- ): void {
63
- const url = new URL(window.location.href);
64
- const pendingMergeToken = url.searchParams.get("fmt") ?? undefined;
65
-
66
- iframe.contentWindow?.postMessage(
67
- {
68
- clientLifecycle: "handshake-response",
69
- data: {
70
- token,
71
- currentUrl: window.location.href,
72
- pendingMergeToken,
73
- configDomain,
74
- clientId: getClientId(),
75
- },
76
- },
77
- targetOrigin
78
- );
79
-
80
- if (pendingMergeToken) {
81
- url.searchParams.delete("fmt");
82
- window.history.replaceState({}, "", url.toString());
83
- }
84
- }
85
-
86
49
  /**
87
50
  * Compute final redirect URL with parameter substitution
88
51
  */
@@ -96,12 +59,12 @@ function computeRedirectUrl(
96
59
  return baseRedirectUrl;
97
60
  }
98
61
 
99
- redirectUrl.searchParams.delete("u");
100
- redirectUrl.searchParams.append("u", window.location.href);
62
+ // Append merge token to the page URL so it survives
63
+ // the backend /common/social redirect chain
64
+ const finalPageUrl = appendMergeToken(window.location.href, mergeToken);
101
65
 
102
- if (mergeToken) {
103
- redirectUrl.searchParams.append("fmt", mergeToken);
104
- }
66
+ redirectUrl.searchParams.delete("u");
67
+ redirectUrl.searchParams.append("u", finalPageUrl);
105
68
 
106
69
  return redirectUrl.toString();
107
70
  } catch {
@@ -130,6 +93,21 @@ function isSocialRedirect(url: string): boolean {
130
93
  return url.includes("/common/social");
131
94
  }
132
95
 
96
+ /**
97
+ * Append merge token to a URL as the `fmt` query parameter.
98
+ */
99
+ function appendMergeToken(urlString: string, mergeToken?: string): string {
100
+ if (!mergeToken) return urlString;
101
+ try {
102
+ const url = new URL(urlString);
103
+ url.searchParams.set("fmt", mergeToken);
104
+ return url.toString();
105
+ } catch {
106
+ const sep = urlString.includes("?") ? "&" : "?";
107
+ return `${urlString}${sep}fmt=${encodeURIComponent(mergeToken)}`;
108
+ }
109
+ }
110
+
133
111
  /**
134
112
  * Handle redirect with deep link fallback
135
113
  */
@@ -137,8 +115,18 @@ function handleRedirect(
137
115
  iframe: HTMLIFrameElement,
138
116
  baseRedirectUrl: string,
139
117
  targetOrigin: string,
140
- mergeToken?: string
118
+ mergeToken?: string,
119
+ openInNewTab?: boolean
141
120
  ): void {
121
+ // If requested, open in a new tab instead of navigating the current page.
122
+ // This preserves the merchant page while triggering universal links.
123
+ // Requires the iframe postMessage to include user activation delegation.
124
+ if (openInNewTab) {
125
+ const finalUrl = computeRedirectUrl(baseRedirectUrl, mergeToken);
126
+ window.open(finalUrl, "_blank");
127
+ return;
128
+ }
129
+
142
130
  if (isFrakDeepLink(baseRedirectUrl)) {
143
131
  const finalUrl = computeRedirectUrl(baseRedirectUrl, mergeToken);
144
132
  triggerDeepLinkWithFallback(finalUrl, {
@@ -167,23 +155,20 @@ function handleRedirect(
167
155
  * @param args
168
156
  * @param args.iframe - The iframe element used for wallet communication
169
157
  * @param args.targetOrigin - The wallet URL origin for postMessage security
170
- * @param args.configDomain - Optional domain override forwarded during handshake for tunneled/proxied environments
171
158
  * @ignore
172
159
  */
173
160
  export function createIFrameLifecycleManager({
174
161
  iframe,
175
162
  targetOrigin,
176
- configDomain,
177
163
  }: {
178
164
  iframe: HTMLIFrameElement;
179
165
  targetOrigin: string;
180
- configDomain?: string;
181
166
  }): IframeLifecycleManager {
182
167
  // Create the isConnected listener
183
168
  const isConnectedDeferred = new Deferred<boolean>();
184
169
 
185
170
  // Build the handler itself
186
- const handler = async (messageEvent: FrakLifecycleEvent) => {
171
+ const handler = (messageEvent: FrakLifecycleEvent) => {
187
172
  if (!("iframeLifecycle" in messageEvent)) return;
188
173
 
189
174
  const { iframeLifecycle: event, data } = messageEvent;
@@ -206,17 +191,14 @@ export function createIFrameLifecycleManager({
206
191
  case "hide":
207
192
  changeIframeVisibility({ iframe, isVisible: event === "show" });
208
193
  break;
209
- // Handshake handling
210
- case "handshake":
211
- handleHandshake(iframe, data.token, targetOrigin, configDomain);
212
- break;
213
194
  // Redirect handling
214
195
  case "redirect":
215
196
  handleRedirect(
216
197
  iframe,
217
198
  data.baseRedirectUrl,
218
199
  targetOrigin,
219
- data.mergeToken
200
+ data.mergeToken,
201
+ data.openInNewTab
220
202
  );
221
203
  break;
222
204
  }