@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.
- package/README.md +1 -2
- package/cdn/bundle.js +3 -3
- package/dist/actions-D4aBXbdp.cjs +1 -0
- package/dist/actions-Dq_uN-wn.js +1 -0
- package/dist/actions.cjs +1 -1
- package/dist/actions.d.cts +3 -3
- package/dist/actions.d.ts +3 -3
- package/dist/actions.js +1 -1
- package/dist/bundle.cjs +1 -1
- package/dist/bundle.d.cts +4 -4
- package/dist/bundle.d.ts +4 -4
- package/dist/bundle.js +1 -1
- package/dist/{computeLegacyProductId-CCAZvLa5.d.cts → index-BV5D9DsW.d.ts} +91 -37
- package/dist/{siweAuthenticate-CnCZ7mok.d.ts → index-BphwTmKA.d.cts} +122 -8
- package/dist/{computeLegacyProductId-b5cUWdAm.d.ts → index-Dwmo109y.d.cts} +91 -37
- package/dist/{siweAuthenticate-CVigMOxz.d.cts → index-_f8EuN_1.d.ts} +122 -8
- package/dist/index.cjs +1 -1
- package/dist/index.d.cts +3 -3
- package/dist/index.d.ts +3 -3
- package/dist/index.js +1 -1
- package/dist/{openSso-B0g7-807.d.cts → openSso-BwEK2M98.d.cts} +283 -44
- package/dist/{openSso-CMzwvaCa.d.ts → openSso-C1Wzl5-i.d.ts} +283 -44
- package/dist/src-B1eliIi6.cjs +13 -0
- package/dist/src-C0UH1GsN.js +13 -0
- package/dist/trackEvent-BqJqRZ-u.cjs +1 -0
- package/dist/trackEvent-Bqq4jd6R.js +1 -0
- package/package.json +11 -12
- package/src/actions/displayEmbeddedWallet.ts +6 -2
- package/src/actions/displayModal.ts +6 -2
- package/src/actions/displaySharingPage.ts +49 -0
- package/src/actions/ensureIdentity.ts +2 -2
- package/src/actions/getMerchantInformation.test.ts +13 -1
- package/src/actions/getMerchantInformation.ts +20 -5
- package/src/actions/getMergeToken.ts +33 -0
- package/src/actions/getUserReferralStatus.ts +42 -0
- package/src/actions/index.ts +8 -1
- package/src/actions/referral/setupReferral.test.ts +79 -0
- package/src/actions/referral/setupReferral.ts +32 -0
- package/src/actions/trackPurchaseStatus.test.ts +32 -20
- package/src/actions/trackPurchaseStatus.ts +3 -5
- package/src/actions/wrapper/modalBuilder.test.ts +4 -2
- package/src/actions/wrapper/modalBuilder.ts +6 -8
- package/src/clients/createIFrameFrakClient.ts +151 -27
- package/src/clients/transports/iframeLifecycleManager.test.ts +14 -94
- package/src/clients/transports/iframeLifecycleManager.ts +35 -53
- package/src/index.ts +17 -4
- package/src/stubs/rrweb.ts +9 -0
- package/src/types/config.ts +10 -3
- package/src/types/index.ts +13 -1
- package/src/types/lifecycle/client.ts +22 -27
- package/src/types/lifecycle/iframe.ts +7 -8
- package/src/types/resolvedConfig.ts +128 -0
- package/src/types/rpc/displaySharingPage.ts +82 -0
- package/src/types/rpc/embedded/index.ts +1 -1
- package/src/types/rpc/interaction.ts +4 -0
- package/src/types/rpc/userReferralStatus.ts +20 -0
- package/src/types/rpc.ts +54 -5
- package/src/utils/backendUrl.test.ts +2 -2
- package/src/utils/backendUrl.ts +1 -1
- package/src/utils/cache/index.ts +7 -0
- package/src/utils/cache/lruMap.test.ts +55 -0
- package/src/utils/cache/lruMap.ts +38 -0
- package/src/utils/cache/withCache.test.ts +168 -0
- package/src/utils/cache/withCache.ts +124 -0
- package/src/utils/inAppBrowser.ts +60 -0
- package/src/utils/index.ts +6 -4
- package/src/utils/sdkConfigStore.test.ts +405 -0
- package/src/utils/sdkConfigStore.ts +263 -0
- package/src/utils/sso.ts +3 -7
- package/dist/setupClient-BduY6Sym.cjs +0 -13
- package/dist/setupClient-ftmdQ-I8.js +0 -13
- package/dist/siweAuthenticate-BWmI2_TN.cjs +0 -1
- package/dist/siweAuthenticate-zczqxm0a.js +0 -1
- package/dist/trackEvent-CeLFVzZn.js +0 -1
- package/dist/trackEvent-Ew5r5zfI.cjs +0 -1
- package/src/utils/merchantId.test.ts +0 -653
- 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
|
|
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:
|
|
128
|
+
iframeLifecycle: (event, _context) => {
|
|
102
129
|
// Delegate to lifecycle manager (cast for type compatibility)
|
|
103
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) =>
|
|
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
|
-
|
|
100
|
-
|
|
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
|
-
|
|
103
|
-
|
|
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 =
|
|
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
|
}
|