@frak-labs/core-sdk 1.1.3 → 1.1.5-beta.727c2f3f
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/cdn/bundle.js +1 -1
- package/dist/actions-DrZ_PIs1.cjs +2 -0
- package/dist/actions-DrZ_PIs1.cjs.map +1 -0
- package/dist/actions-HG5M8wX7.js +2 -0
- package/dist/actions-HG5M8wX7.js.map +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/frakContext-Bgidv-Se.js +2 -0
- package/dist/frakContext-Bgidv-Se.js.map +1 -0
- package/dist/frakContext-CeVlWFcX.cjs +2 -0
- package/dist/frakContext-CeVlWFcX.cjs.map +1 -0
- package/dist/{index-lcImYQyL.d.ts → index-BJMazhpm.d.ts} +3 -2
- package/dist/{index-C5pP_9We.d.cts → index-BysidNLB.d.ts} +42 -6
- package/dist/{index-DR4n-QOe.d.cts → index-C1A3I4x1.d.cts} +3 -2
- package/dist/{index-DrYZwIjb.d.ts → index-k1lfbVkl.d.cts} +42 -6
- 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-DRLH4D9a.d.ts → openSso-B0igO6Iu.d.cts} +9 -3
- package/dist/{openSso-BErs7_Bg.d.cts → openSso-BWeD0Fb2.d.ts} +9 -3
- package/dist/src-CCJgTN1H.cjs +2 -0
- package/dist/src-CCJgTN1H.cjs.map +1 -0
- package/dist/src-DKLOAY9w.js +2 -0
- package/dist/src-DKLOAY9w.js.map +1 -0
- package/package.json +4 -3
- package/src/actions/referral/processReferral.test.ts +4 -4
- package/src/actions/referral/processReferral.ts +4 -4
- package/src/clients/createIFrameFrakClient.ts +7 -7
- package/src/clients/transports/iframeLifecycleManager.ts +5 -21
- package/src/config/sdkConfigStore.ts +6 -3
- package/src/context/frakContext.test.ts +55 -0
- package/src/context/frakContext.ts +13 -2
- package/src/index.ts +2 -0
- package/src/types/rpc/merchantInformation.ts +13 -6
- package/src/utils/analytics/events/lifecycle.ts +1 -1
- package/src/utils/analytics/events/referral.ts +3 -3
- package/src/utils/browser/deepLinkWithFallback.ts +4 -1
- package/src/utils/browser/inAppBrowser.ts +2 -2
- package/src/utils/cache/withCache.ts +30 -11
- package/src/utils/iframe/iframeHelper.test.ts +39 -0
- package/src/utils/iframe/iframeHelper.ts +31 -2
- package/src/utils/index.ts +5 -0
- package/src/utils/url/queryParams.test.ts +85 -0
- package/src/utils/url/queryParams.ts +57 -0
- package/dist/actions-D5rNeJC0.js +0 -1
- package/dist/actions-gmdNZvNh.cjs +0 -1
- package/dist/frakContext-Cfp7sSid.cjs +0 -1
- package/dist/frakContext-UEOk9Bk4.js +0 -1
- package/dist/src-BEMVYU3J.js +0 -1
- package/dist/src-C9inMs4I.cjs +0 -1
|
@@ -53,9 +53,9 @@ function trackArrivalIfValid(
|
|
|
53
53
|
): boolean {
|
|
54
54
|
if (isV2Context(frakContext)) {
|
|
55
55
|
trackEvent(client, "user_referred_started", {
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
56
|
+
referrer_client_id: frakContext.c,
|
|
57
|
+
referrer_wallet: frakContext.w,
|
|
58
|
+
wallet_status: walletStatus?.key,
|
|
59
59
|
});
|
|
60
60
|
sendInteraction(client, {
|
|
61
61
|
type: "arrival",
|
|
@@ -70,7 +70,7 @@ function trackArrivalIfValid(
|
|
|
70
70
|
if (isV1Context(frakContext)) {
|
|
71
71
|
trackEvent(client, "user_referred_started", {
|
|
72
72
|
referrer: frakContext.r,
|
|
73
|
-
|
|
73
|
+
wallet_status: walletStatus?.key,
|
|
74
74
|
});
|
|
75
75
|
sendInteraction(client, {
|
|
76
76
|
type: "arrival",
|
|
@@ -153,11 +153,11 @@ export function createIFrameFrakClient({
|
|
|
153
153
|
if (!payload?.properties) return true;
|
|
154
154
|
|
|
155
155
|
// Check if we we got the properties once initialized
|
|
156
|
-
if (!("
|
|
156
|
+
if (!("sdk_version" in payload.properties)) {
|
|
157
157
|
payload.properties = {
|
|
158
158
|
...payload.properties,
|
|
159
|
-
|
|
160
|
-
|
|
159
|
+
sdk_version: process.env.SDK_VERSION,
|
|
160
|
+
user_anonymous_client_id: getClientId(),
|
|
161
161
|
};
|
|
162
162
|
}
|
|
163
163
|
|
|
@@ -165,12 +165,12 @@ export function createIFrameFrakClient({
|
|
|
165
165
|
},
|
|
166
166
|
});
|
|
167
167
|
openPanel.setGlobalProperties({
|
|
168
|
-
|
|
169
|
-
|
|
168
|
+
sdk_version: process.env.SDK_VERSION,
|
|
169
|
+
user_anonymous_client_id: getClientId(),
|
|
170
170
|
});
|
|
171
171
|
openPanel.init();
|
|
172
172
|
openPanel.track("sdk_initialized", {
|
|
173
|
-
|
|
173
|
+
sdk_version: process.env.SDK_VERSION,
|
|
174
174
|
});
|
|
175
175
|
|
|
176
176
|
// Race the connection against the heartbeat timeout so we can
|
|
@@ -398,7 +398,7 @@ async function postConnectionSetup({
|
|
|
398
398
|
const current = openPanel.global ?? {};
|
|
399
399
|
openPanel.setGlobalProperties({
|
|
400
400
|
...current,
|
|
401
|
-
|
|
401
|
+
merchant_id: resolved.merchantId,
|
|
402
402
|
domain: resolved.domain ?? "",
|
|
403
403
|
});
|
|
404
404
|
}
|
|
@@ -5,29 +5,13 @@ import {
|
|
|
5
5
|
isFrakDeepLink,
|
|
6
6
|
triggerDeepLinkWithFallback,
|
|
7
7
|
} from "../../utils/browser/deepLinkWithFallback";
|
|
8
|
+
import { isInAppBrowser, isIOS } from "../../utils/browser/inAppBrowser";
|
|
8
9
|
import { changeIframeVisibility } from "../../utils/iframe/iframeHelper";
|
|
9
10
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
*/
|
|
15
|
-
const isIOSInAppBrowser = (() => {
|
|
16
|
-
if (typeof navigator === "undefined") return false;
|
|
17
|
-
const ua = navigator.userAgent;
|
|
18
|
-
// Standard iOS or iPadOS 13+ (reports as Macintosh with touch)
|
|
19
|
-
const isIOS =
|
|
20
|
-
/iPhone|iPad|iPod/i.test(ua) ||
|
|
21
|
-
(/Macintosh/i.test(ua) && navigator.maxTouchPoints > 1);
|
|
22
|
-
if (!isIOS) return false;
|
|
23
|
-
const lower = ua.toLowerCase();
|
|
24
|
-
return (
|
|
25
|
-
lower.includes("instagram") ||
|
|
26
|
-
lower.includes("fban") ||
|
|
27
|
-
lower.includes("fbav") ||
|
|
28
|
-
lower.includes("facebook")
|
|
29
|
-
);
|
|
30
|
-
})();
|
|
11
|
+
// iOS in-app browsers (Instagram, Facebook) silently swallow server-side 302
|
|
12
|
+
// redirects to custom URL schemes (x-safari-https://) in WKWebView; direct
|
|
13
|
+
// window.location.href assignment works.
|
|
14
|
+
const isIOSInAppBrowser = isIOS && isInAppBrowser;
|
|
31
15
|
|
|
32
16
|
/** @ignore */
|
|
33
17
|
export type IframeLifecycleManager = {
|
|
@@ -89,23 +89,26 @@ function removeCache(): void {
|
|
|
89
89
|
}
|
|
90
90
|
|
|
91
91
|
// ---------------------------------------------------------------------------
|
|
92
|
-
// Initialise window-backed config (
|
|
92
|
+
// Initialise window-backed config (lazily, on first access)
|
|
93
93
|
// ---------------------------------------------------------------------------
|
|
94
94
|
|
|
95
|
+
// Seed `window.__frakSdkConfig` from the localStorage cache. Do NOT call this at
|
|
96
|
+
// module top-level: that side effect contradicts `sideEffects: false` (a bundler
|
|
97
|
+
// may legally drop it) and forces a localStorage read on every consumer at import.
|
|
98
|
+
// It is invoked lazily from `getConfig` instead.
|
|
95
99
|
function initConfig(): void {
|
|
96
100
|
if (!isBrowser) return;
|
|
97
101
|
if (window[GLOBAL_KEY]) return;
|
|
98
102
|
window[GLOBAL_KEY] = readCache() ?? freshEmptyConfig();
|
|
99
103
|
}
|
|
100
104
|
|
|
101
|
-
initConfig();
|
|
102
|
-
|
|
103
105
|
// ---------------------------------------------------------------------------
|
|
104
106
|
// Helpers
|
|
105
107
|
// ---------------------------------------------------------------------------
|
|
106
108
|
|
|
107
109
|
function getConfig(): SdkResolvedConfig {
|
|
108
110
|
if (!isBrowser) return freshEmptyConfig();
|
|
111
|
+
initConfig();
|
|
109
112
|
return window[GLOBAL_KEY] ?? freshEmptyConfig();
|
|
110
113
|
}
|
|
111
114
|
|
|
@@ -584,6 +584,61 @@ describe("FrakContextManager", () => {
|
|
|
584
584
|
});
|
|
585
585
|
});
|
|
586
586
|
|
|
587
|
+
describe("case-insensitive fCtx key", () => {
|
|
588
|
+
const context: FrakContextV2 = {
|
|
589
|
+
v: 2,
|
|
590
|
+
c: "550e8400-e29b-41d4-a716-446655440001",
|
|
591
|
+
m: "550e8400-e29b-41d4-a716-446655440000",
|
|
592
|
+
t: 1709654400,
|
|
593
|
+
};
|
|
594
|
+
|
|
595
|
+
it("should parse a lowercased fctx key with an intact value", () => {
|
|
596
|
+
const compressed = FrakContextManager.compress(context);
|
|
597
|
+
const url = `https://example.com?fctx=${compressed}`;
|
|
598
|
+
|
|
599
|
+
const result = FrakContextManager.parse({ url });
|
|
600
|
+
|
|
601
|
+
expect(result).toEqual(context);
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
it("should parse an uppercased FCTX key with an intact value", () => {
|
|
605
|
+
const compressed = FrakContextManager.compress(context);
|
|
606
|
+
const url = `https://example.com?FCTX=${compressed}`;
|
|
607
|
+
|
|
608
|
+
const result = FrakContextManager.parse({ url });
|
|
609
|
+
|
|
610
|
+
expect(result).toEqual(context);
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
it("should prefer the exact-case fCtx key when both variants exist", () => {
|
|
614
|
+
const canonical = FrakContextManager.compress(context);
|
|
615
|
+
const url = `https://example.com?fctx=stale&fCtx=${canonical}`;
|
|
616
|
+
|
|
617
|
+
const result = FrakContextManager.parse({ url });
|
|
618
|
+
|
|
619
|
+
expect(result).toEqual(context);
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
it("should remove a lowercased fctx key", () => {
|
|
623
|
+
const url = "https://example.com?fctx=abc&keep=me";
|
|
624
|
+
|
|
625
|
+
const result = FrakContextManager.remove(url);
|
|
626
|
+
|
|
627
|
+
expect(result).not.toContain("fctx");
|
|
628
|
+
expect(result).toContain("keep=me");
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
it("should not leave a stale lowercased fctx variant on update", () => {
|
|
632
|
+
const url = "https://example.com?fctx=stale";
|
|
633
|
+
|
|
634
|
+
const result = FrakContextManager.update({ url, context });
|
|
635
|
+
|
|
636
|
+
expect(result).toContain("fCtx=");
|
|
637
|
+
expect(result).not.toContain("fctx=stale");
|
|
638
|
+
expect(FrakContextManager.parse({ url: result! })).toEqual(context);
|
|
639
|
+
});
|
|
640
|
+
});
|
|
641
|
+
|
|
587
642
|
describe("replaceUrl", () => {
|
|
588
643
|
const mockAddress =
|
|
589
644
|
"0x1234567890123456789012345678901234567890" as Address;
|
|
@@ -6,6 +6,10 @@ import type {
|
|
|
6
6
|
} from "../types";
|
|
7
7
|
import { isV2Context } from "../types";
|
|
8
8
|
import { base64urlDecode, base64urlEncode } from "../utils/compression/b64";
|
|
9
|
+
import {
|
|
10
|
+
deleteQueryParamCaseInsensitive,
|
|
11
|
+
getQueryParamCaseInsensitive,
|
|
12
|
+
} from "../utils/url/queryParams";
|
|
9
13
|
import { addressToBytes, bytesToAddress, isAddress } from "./address";
|
|
10
14
|
import { decodeFrakContextV2, encodeFrakContextV2 } from "./frakContextV2Codec";
|
|
11
15
|
|
|
@@ -77,6 +81,9 @@ function decompress(context?: string): FrakContext | undefined {
|
|
|
77
81
|
/**
|
|
78
82
|
* Parse a URL to extract the Frak referral context from the `fCtx` query parameter.
|
|
79
83
|
*
|
|
84
|
+
* The key is matched case-insensitively: some link channels (emails, messaging
|
|
85
|
+
* apps) lowercase query-param keys in transit, so `fCtx` can arrive as `fctx`.
|
|
86
|
+
*
|
|
80
87
|
* @param args
|
|
81
88
|
* @param args.url - The URL to parse
|
|
82
89
|
* @returns The parsed FrakContext, or null if absent
|
|
@@ -85,7 +92,10 @@ function parse({ url }: { url: string }): FrakContext | null | undefined {
|
|
|
85
92
|
if (!url) return null;
|
|
86
93
|
|
|
87
94
|
const urlObj = new URL(url);
|
|
88
|
-
const frakContext =
|
|
95
|
+
const frakContext = getQueryParamCaseInsensitive(
|
|
96
|
+
urlObj.searchParams,
|
|
97
|
+
contextKey
|
|
98
|
+
);
|
|
89
99
|
if (!frakContext) return null;
|
|
90
100
|
|
|
91
101
|
return decompress(frakContext);
|
|
@@ -165,6 +175,7 @@ function update({
|
|
|
165
175
|
if (!compressedContext) return null;
|
|
166
176
|
|
|
167
177
|
const urlObj = new URL(url);
|
|
178
|
+
deleteQueryParamCaseInsensitive(urlObj.searchParams, contextKey);
|
|
168
179
|
urlObj.searchParams.set(contextKey, compressedContext);
|
|
169
180
|
applyAttributionParams(urlObj, attribution);
|
|
170
181
|
return urlObj.toString();
|
|
@@ -178,7 +189,7 @@ function update({
|
|
|
178
189
|
*/
|
|
179
190
|
function remove(url: string): string {
|
|
180
191
|
const urlObj = new URL(url);
|
|
181
|
-
urlObj.searchParams
|
|
192
|
+
deleteQueryParamCaseInsensitive(urlObj.searchParams, contextKey);
|
|
182
193
|
return urlObj.toString();
|
|
183
194
|
}
|
|
184
195
|
|
package/src/index.ts
CHANGED
|
@@ -107,11 +107,13 @@ export {
|
|
|
107
107
|
compressJsonToB64,
|
|
108
108
|
type DeepLinkFallbackOptions,
|
|
109
109
|
decompressJsonFromB64,
|
|
110
|
+
deleteQueryParamCaseInsensitive,
|
|
110
111
|
type FullSsoParams,
|
|
111
112
|
findIframeInOpener,
|
|
112
113
|
formatAmount,
|
|
113
114
|
generateSsoUrl,
|
|
114
115
|
getCurrencyAmountKey,
|
|
116
|
+
getQueryParamCaseInsensitive,
|
|
115
117
|
getSupportedCurrency,
|
|
116
118
|
isInAppBrowser,
|
|
117
119
|
isIOS,
|
|
@@ -12,13 +12,20 @@ export type TokenAmountType = {
|
|
|
12
12
|
};
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
|
-
* A tier definition for tiered rewards
|
|
15
|
+
* A tier definition for tiered rewards — pays either a flat token amount
|
|
16
|
+
* or a percent of the tier field value
|
|
16
17
|
*/
|
|
17
|
-
export type RewardTier =
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
18
|
+
export type RewardTier =
|
|
19
|
+
| {
|
|
20
|
+
minValue: number;
|
|
21
|
+
maxValue?: number;
|
|
22
|
+
amount: TokenAmountType;
|
|
23
|
+
}
|
|
24
|
+
| {
|
|
25
|
+
minValue: number;
|
|
26
|
+
maxValue?: number;
|
|
27
|
+
percent: number;
|
|
28
|
+
};
|
|
22
29
|
|
|
23
30
|
/**
|
|
24
31
|
* Estimated reward amount — discriminated union by payout type
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
export type SdkReferralEventMap = {
|
|
2
2
|
user_referred_started: {
|
|
3
3
|
referrer?: string;
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
4
|
+
referrer_client_id?: string;
|
|
5
|
+
referrer_wallet?: string;
|
|
6
|
+
wallet_status?: string;
|
|
7
7
|
};
|
|
8
8
|
user_referred_completed: {
|
|
9
9
|
status: "success";
|
|
@@ -40,7 +40,10 @@ export function isChromiumAndroid(): boolean {
|
|
|
40
40
|
*
|
|
41
41
|
* Format: intent://path#Intent;scheme=<scheme>;end
|
|
42
42
|
*/
|
|
43
|
-
const DEEP_LINK_SCHEME_NAME = DEEP_LINK_SCHEME.replace(
|
|
43
|
+
const DEEP_LINK_SCHEME_NAME = /* @__PURE__ */ DEEP_LINK_SCHEME.replace(
|
|
44
|
+
"://",
|
|
45
|
+
""
|
|
46
|
+
);
|
|
44
47
|
|
|
45
48
|
export function toAndroidIntentUrl(deepLink: string): string {
|
|
46
49
|
// Extract everything after the scheme (e.g. "frakwallet://" or "frakwallet-dev://")
|
|
@@ -14,7 +14,7 @@ function checkIsIOS(): boolean {
|
|
|
14
14
|
/**
|
|
15
15
|
* Whether the current device runs iOS (including iPadOS 13+).
|
|
16
16
|
*/
|
|
17
|
-
export const isIOS: boolean = checkIsIOS();
|
|
17
|
+
export const isIOS: boolean = /* @__PURE__ */ checkIsIOS();
|
|
18
18
|
|
|
19
19
|
/**
|
|
20
20
|
* Check if the current device is a mobile device (iOS, iPadOS, Android,
|
|
@@ -48,7 +48,7 @@ function checkInAppBrowser(): boolean {
|
|
|
48
48
|
* Whether the current browser is a social media in-app browser
|
|
49
49
|
* (Instagram, Facebook).
|
|
50
50
|
*/
|
|
51
|
-
export const isInAppBrowser: boolean = checkInAppBrowser();
|
|
51
|
+
export const isInAppBrowser: boolean = /* @__PURE__ */ checkInAppBrowser();
|
|
52
52
|
|
|
53
53
|
/**
|
|
54
54
|
* Redirect to external browser from in-app WebView.
|
|
@@ -5,20 +5,35 @@ type CacheEntry<TData> = {
|
|
|
5
5
|
created: number;
|
|
6
6
|
};
|
|
7
7
|
|
|
8
|
-
/** Global cache for in-flight promises (dedup concurrent calls) */
|
|
9
|
-
const promiseCache = new LruMap<Promise<unknown>>(1024);
|
|
10
|
-
|
|
11
|
-
/** Global cache for resolved responses (TTL-based) */
|
|
12
|
-
const responseCache = new LruMap<CacheEntry<unknown>>(1024);
|
|
13
|
-
|
|
14
8
|
/** Default cache time: 30 seconds */
|
|
15
9
|
export const DEFAULT_CACHE_TIME = 30_000;
|
|
16
10
|
|
|
17
11
|
/** Short negative cache to avoid flooding on transient failures */
|
|
18
12
|
const NEGATIVE_CACHE_TIME = 1_000;
|
|
19
13
|
|
|
20
|
-
|
|
21
|
-
|
|
14
|
+
type CacheStore = {
|
|
15
|
+
/** In-flight promises (dedup concurrent calls) */
|
|
16
|
+
promiseCache: LruMap<Promise<unknown>>;
|
|
17
|
+
/** Resolved responses (TTL-based) */
|
|
18
|
+
responseCache: LruMap<CacheEntry<unknown>>;
|
|
19
|
+
/** Recently failed keys to avoid request floods */
|
|
20
|
+
failureCache: LruMap<number>;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
// Allocate the three LruMaps lazily on first use rather than at module init.
|
|
24
|
+
// Eager top-level allocation is a side effect at odds with `sideEffects: false`
|
|
25
|
+
// and runs for every consumer that merely imports this module.
|
|
26
|
+
let store: CacheStore | undefined;
|
|
27
|
+
function caches(): CacheStore {
|
|
28
|
+
if (!store) {
|
|
29
|
+
store = {
|
|
30
|
+
promiseCache: new LruMap<Promise<unknown>>(1024),
|
|
31
|
+
responseCache: new LruMap<CacheEntry<unknown>>(1024),
|
|
32
|
+
failureCache: new LruMap<number>(1024),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
return store;
|
|
36
|
+
}
|
|
22
37
|
|
|
23
38
|
type WithCacheOptions = {
|
|
24
39
|
/** The key to cache the data against */
|
|
@@ -47,6 +62,7 @@ export async function withCache<TData>(
|
|
|
47
62
|
fn: () => Promise<TData>,
|
|
48
63
|
{ cacheKey, cacheTime = DEFAULT_CACHE_TIME }: WithCacheOptions
|
|
49
64
|
): Promise<TData> {
|
|
65
|
+
const { promiseCache, responseCache, failureCache } = caches();
|
|
50
66
|
// Check response cache — return immediately if fresh
|
|
51
67
|
if (cacheTime > 0) {
|
|
52
68
|
const cached = responseCache.get(cacheKey) as
|
|
@@ -101,11 +117,13 @@ export function getCache(cacheKey: string) {
|
|
|
101
117
|
return {
|
|
102
118
|
/** Clear both the pending promise and the cached response */
|
|
103
119
|
clear: () => {
|
|
120
|
+
const { promiseCache, responseCache } = caches();
|
|
104
121
|
promiseCache.delete(cacheKey);
|
|
105
122
|
responseCache.delete(cacheKey);
|
|
106
123
|
},
|
|
107
124
|
/** Check if a non-expired response exists */
|
|
108
125
|
has: (cacheTime: number = DEFAULT_CACHE_TIME) => {
|
|
126
|
+
const { responseCache } = caches();
|
|
109
127
|
const cached = responseCache.get(cacheKey);
|
|
110
128
|
if (!cached) return false;
|
|
111
129
|
return Date.now() - cached.created < cacheTime;
|
|
@@ -118,7 +136,8 @@ export function getCache(cacheKey: string) {
|
|
|
118
136
|
* Called automatically when the client is destroyed.
|
|
119
137
|
*/
|
|
120
138
|
export function clearAllCache() {
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
139
|
+
if (!store) return;
|
|
140
|
+
store.promiseCache.clear();
|
|
141
|
+
store.responseCache.clear();
|
|
142
|
+
store.failureCache.clear();
|
|
124
143
|
}
|
|
@@ -185,6 +185,45 @@ describe("iframeHelper", () => {
|
|
|
185
185
|
|
|
186
186
|
expect(mockIframe.style.zIndex).toBe("2000001");
|
|
187
187
|
});
|
|
188
|
+
|
|
189
|
+
it("should append #preload=... when config.preload is non-empty", async () => {
|
|
190
|
+
const config: FrakWalletSdkConfig = {
|
|
191
|
+
metadata: { name: "Test" },
|
|
192
|
+
preload: ["sharing"],
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
await createIframe({ config });
|
|
196
|
+
|
|
197
|
+
expect(mockIframe.src).toBe(
|
|
198
|
+
"https://wallet.frak.id/listener?clientId=mock-client-id-for-test#preload=sharing"
|
|
199
|
+
);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("should join multiple preload options with comma", async () => {
|
|
203
|
+
const config: FrakWalletSdkConfig = {
|
|
204
|
+
metadata: { name: "Test" },
|
|
205
|
+
preload: ["sharing", "modal"],
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
await createIframe({ config });
|
|
209
|
+
|
|
210
|
+
expect(mockIframe.src).toBe(
|
|
211
|
+
"https://wallet.frak.id/listener?clientId=mock-client-id-for-test#preload=sharing,modal"
|
|
212
|
+
);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it("should omit the preload hash when config.preload is an empty array", async () => {
|
|
216
|
+
const config: FrakWalletSdkConfig = {
|
|
217
|
+
metadata: { name: "Test" },
|
|
218
|
+
preload: [],
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
await createIframe({ config });
|
|
222
|
+
|
|
223
|
+
expect(mockIframe.src).toBe(
|
|
224
|
+
"https://wallet.frak.id/listener?clientId=mock-client-id-for-test"
|
|
225
|
+
);
|
|
226
|
+
});
|
|
188
227
|
});
|
|
189
228
|
|
|
190
229
|
describe("changeIframeVisibility", () => {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { getBackendUrl } from "../../config/backendUrl";
|
|
2
2
|
import { getClientId } from "../../config/clientId";
|
|
3
|
-
import type { FrakWalletSdkConfig } from "../../types";
|
|
3
|
+
import type { FrakWalletSdkConfig, ListenerPreloadOption } from "../../types";
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* Base props for the iframe
|
|
@@ -64,7 +64,11 @@ export function createIframe({
|
|
|
64
64
|
preconnect(walletUrl);
|
|
65
65
|
preconnect(getBackendUrl(walletUrl));
|
|
66
66
|
|
|
67
|
-
iframe.src =
|
|
67
|
+
iframe.src = buildListenerUrl({
|
|
68
|
+
walletUrl,
|
|
69
|
+
clientId,
|
|
70
|
+
preload: config?.preload,
|
|
71
|
+
});
|
|
68
72
|
|
|
69
73
|
return new Promise((resolve) => {
|
|
70
74
|
iframe.addEventListener("load", () => resolve(iframe));
|
|
@@ -72,6 +76,31 @@ export function createIframe({
|
|
|
72
76
|
});
|
|
73
77
|
}
|
|
74
78
|
|
|
79
|
+
/**
|
|
80
|
+
* Build the listener iframe URL.
|
|
81
|
+
*
|
|
82
|
+
* Query params:
|
|
83
|
+
* - `clientId` — anonymous SDK client identifier used for funnel joining.
|
|
84
|
+
*
|
|
85
|
+
* Hash params (consumed by `apps/listener/app/bootstrap.ts#setupPreloadHints`):
|
|
86
|
+
* - `preload=modal,sharing` — idle-warms the matching Ring 1 + Ring 2 chunks.
|
|
87
|
+
* Skipped entirely when no preload hints are provided so the listener
|
|
88
|
+
* doesn't pay for warm-ups that nobody asked for.
|
|
89
|
+
*/
|
|
90
|
+
function buildListenerUrl({
|
|
91
|
+
walletUrl,
|
|
92
|
+
clientId,
|
|
93
|
+
preload,
|
|
94
|
+
}: {
|
|
95
|
+
walletUrl: string;
|
|
96
|
+
clientId: string;
|
|
97
|
+
preload?: ListenerPreloadOption[];
|
|
98
|
+
}): string {
|
|
99
|
+
const base = `${walletUrl}/listener?clientId=${encodeURIComponent(clientId)}`;
|
|
100
|
+
if (!preload || preload.length === 0) return base;
|
|
101
|
+
return `${base}#preload=${preload.join(",")}`;
|
|
102
|
+
}
|
|
103
|
+
|
|
75
104
|
/**
|
|
76
105
|
* Change the visibility of the given iframe
|
|
77
106
|
* @ignore
|
package/src/utils/index.ts
CHANGED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { describe, expect, it } from "../../../tests/vitest-fixtures";
|
|
2
|
+
import {
|
|
3
|
+
deleteQueryParamCaseInsensitive,
|
|
4
|
+
getQueryParamCaseInsensitive,
|
|
5
|
+
} from "./queryParams";
|
|
6
|
+
|
|
7
|
+
describe("getQueryParamCaseInsensitive", () => {
|
|
8
|
+
it("reads an exact-case key", () => {
|
|
9
|
+
const params = new URLSearchParams("frakAction=share");
|
|
10
|
+
expect(getQueryParamCaseInsensitive(params, "frakAction")).toBe(
|
|
11
|
+
"share"
|
|
12
|
+
);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("reads a fully lowercased key (email/browser mangling)", () => {
|
|
16
|
+
const params = new URLSearchParams("frakaction=share");
|
|
17
|
+
expect(getQueryParamCaseInsensitive(params, "frakAction")).toBe(
|
|
18
|
+
"share"
|
|
19
|
+
);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("reads an uppercased key", () => {
|
|
23
|
+
const params = new URLSearchParams("FRAKACTION=share");
|
|
24
|
+
expect(getQueryParamCaseInsensitive(params, "frakAction")).toBe(
|
|
25
|
+
"share"
|
|
26
|
+
);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("reads a mixed-case key regardless of the lookup casing", () => {
|
|
30
|
+
const params = new URLSearchParams("FrAkAcTiOn=share");
|
|
31
|
+
expect(getQueryParamCaseInsensitive(params, "FRAKACTION")).toBe(
|
|
32
|
+
"share"
|
|
33
|
+
);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("returns null when no key matches", () => {
|
|
37
|
+
const params = new URLSearchParams("other=1");
|
|
38
|
+
expect(getQueryParamCaseInsensitive(params, "frakAction")).toBeNull();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("prefers the exact-case key over a case-folded duplicate", () => {
|
|
42
|
+
const params = new URLSearchParams("fctx=stale&fCtx=canonical");
|
|
43
|
+
expect(getQueryParamCaseInsensitive(params, "fCtx")).toBe("canonical");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("falls back to the first case-folded variant when no exact match", () => {
|
|
47
|
+
const params = new URLSearchParams("fctx=first&FCTX=second");
|
|
48
|
+
expect(getQueryParamCaseInsensitive(params, "fCtx")).toBe("first");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("preserves the value casing (only the key is normalized)", () => {
|
|
52
|
+
const params = new URLSearchParams("placement=Klaviyo-Post-Purchase");
|
|
53
|
+
expect(getQueryParamCaseInsensitive(params, "PLACEMENT")).toBe(
|
|
54
|
+
"Klaviyo-Post-Purchase"
|
|
55
|
+
);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe("deleteQueryParamCaseInsensitive", () => {
|
|
60
|
+
it("deletes an exact-case key", () => {
|
|
61
|
+
const params = new URLSearchParams("frakAction=share&keep=me");
|
|
62
|
+
deleteQueryParamCaseInsensitive(params, "frakAction");
|
|
63
|
+
expect(params.toString()).toBe("keep=me");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("deletes a lowercased key", () => {
|
|
67
|
+
const params = new URLSearchParams("frakaction=share&keep=me");
|
|
68
|
+
deleteQueryParamCaseInsensitive(params, "frakAction");
|
|
69
|
+
expect(params.toString()).toBe("keep=me");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("deletes every case variant of the key", () => {
|
|
73
|
+
const params = new URLSearchParams(
|
|
74
|
+
"frakAction=a&frakaction=b&FRAKACTION=c&keep=me"
|
|
75
|
+
);
|
|
76
|
+
deleteQueryParamCaseInsensitive(params, "frakAction");
|
|
77
|
+
expect(params.toString()).toBe("keep=me");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("is a no-op when the key is absent", () => {
|
|
81
|
+
const params = new URLSearchParams("keep=me");
|
|
82
|
+
deleteQueryParamCaseInsensitive(params, "frakAction");
|
|
83
|
+
expect(params.toString()).toBe("keep=me");
|
|
84
|
+
});
|
|
85
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Case-insensitive helpers for reading URL query parameters.
|
|
3
|
+
*
|
|
4
|
+
* Some email tools (Klaviyo, Omnisend, Customer.io …) and a few browsers
|
|
5
|
+
* lowercase the entire URL before the recipient opens it, so a mixed-case key
|
|
6
|
+
* authored as `frakAction` or `fCtx` can arrive as `frakaction` / `fctx`. A
|
|
7
|
+
* plain `searchParams.get("frakAction")` would miss it. Matching the key
|
|
8
|
+
* ignoring case keeps UI-triggering and referral params working regardless of
|
|
9
|
+
* how the link was mangled in transit.
|
|
10
|
+
*
|
|
11
|
+
* Note: only the key is normalised. An encoded value (base64url, tokens) is not
|
|
12
|
+
* recoverable if the same channel also lowercased the value itself.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Read a query parameter, matching its key case-insensitively.
|
|
17
|
+
*
|
|
18
|
+
* An exact-case match wins when present, so a canonical link is never shadowed
|
|
19
|
+
* by a mangled duplicate (`?fctx=stale&fCtx=real` resolves to `real`). Only when
|
|
20
|
+
* the exact key is absent do we scan for a case-folded variant.
|
|
21
|
+
*
|
|
22
|
+
* @returns the param value, or `null` when no key matches.
|
|
23
|
+
*/
|
|
24
|
+
export function getQueryParamCaseInsensitive(
|
|
25
|
+
searchParams: URLSearchParams,
|
|
26
|
+
key: string
|
|
27
|
+
): string | null {
|
|
28
|
+
const exact = searchParams.get(key);
|
|
29
|
+
if (exact !== null) return exact;
|
|
30
|
+
|
|
31
|
+
const target = key.toLowerCase();
|
|
32
|
+
for (const [paramKey, value] of searchParams) {
|
|
33
|
+
if (paramKey.toLowerCase() === target) {
|
|
34
|
+
return value;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Delete every query parameter whose key matches `key` case-insensitively.
|
|
42
|
+
*
|
|
43
|
+
* Keys are collected before deletion because mutating a `URLSearchParams`
|
|
44
|
+
* while iterating it skips entries.
|
|
45
|
+
*/
|
|
46
|
+
export function deleteQueryParamCaseInsensitive(
|
|
47
|
+
searchParams: URLSearchParams,
|
|
48
|
+
key: string
|
|
49
|
+
): void {
|
|
50
|
+
const target = key.toLowerCase();
|
|
51
|
+
const matchingKeys = [...searchParams.keys()].filter(
|
|
52
|
+
(paramKey) => paramKey.toLowerCase() === target
|
|
53
|
+
);
|
|
54
|
+
for (const paramKey of matchingKeys) {
|
|
55
|
+
searchParams.delete(paramKey);
|
|
56
|
+
}
|
|
57
|
+
}
|