@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.
Files changed (57) hide show
  1. package/cdn/bundle.js +1 -1
  2. package/dist/actions-DrZ_PIs1.cjs +2 -0
  3. package/dist/actions-DrZ_PIs1.cjs.map +1 -0
  4. package/dist/actions-HG5M8wX7.js +2 -0
  5. package/dist/actions-HG5M8wX7.js.map +1 -0
  6. package/dist/actions.cjs +1 -1
  7. package/dist/actions.d.cts +3 -3
  8. package/dist/actions.d.ts +3 -3
  9. package/dist/actions.js +1 -1
  10. package/dist/bundle.cjs +1 -1
  11. package/dist/bundle.d.cts +4 -4
  12. package/dist/bundle.d.ts +4 -4
  13. package/dist/bundle.js +1 -1
  14. package/dist/frakContext-Bgidv-Se.js +2 -0
  15. package/dist/frakContext-Bgidv-Se.js.map +1 -0
  16. package/dist/frakContext-CeVlWFcX.cjs +2 -0
  17. package/dist/frakContext-CeVlWFcX.cjs.map +1 -0
  18. package/dist/{index-lcImYQyL.d.ts → index-BJMazhpm.d.ts} +3 -2
  19. package/dist/{index-C5pP_9We.d.cts → index-BysidNLB.d.ts} +42 -6
  20. package/dist/{index-DR4n-QOe.d.cts → index-C1A3I4x1.d.cts} +3 -2
  21. package/dist/{index-DrYZwIjb.d.ts → index-k1lfbVkl.d.cts} +42 -6
  22. package/dist/index.cjs +1 -1
  23. package/dist/index.d.cts +3 -3
  24. package/dist/index.d.ts +3 -3
  25. package/dist/index.js +1 -1
  26. package/dist/{openSso-DRLH4D9a.d.ts → openSso-B0igO6Iu.d.cts} +9 -3
  27. package/dist/{openSso-BErs7_Bg.d.cts → openSso-BWeD0Fb2.d.ts} +9 -3
  28. package/dist/src-CCJgTN1H.cjs +2 -0
  29. package/dist/src-CCJgTN1H.cjs.map +1 -0
  30. package/dist/src-DKLOAY9w.js +2 -0
  31. package/dist/src-DKLOAY9w.js.map +1 -0
  32. package/package.json +4 -3
  33. package/src/actions/referral/processReferral.test.ts +4 -4
  34. package/src/actions/referral/processReferral.ts +4 -4
  35. package/src/clients/createIFrameFrakClient.ts +7 -7
  36. package/src/clients/transports/iframeLifecycleManager.ts +5 -21
  37. package/src/config/sdkConfigStore.ts +6 -3
  38. package/src/context/frakContext.test.ts +55 -0
  39. package/src/context/frakContext.ts +13 -2
  40. package/src/index.ts +2 -0
  41. package/src/types/rpc/merchantInformation.ts +13 -6
  42. package/src/utils/analytics/events/lifecycle.ts +1 -1
  43. package/src/utils/analytics/events/referral.ts +3 -3
  44. package/src/utils/browser/deepLinkWithFallback.ts +4 -1
  45. package/src/utils/browser/inAppBrowser.ts +2 -2
  46. package/src/utils/cache/withCache.ts +30 -11
  47. package/src/utils/iframe/iframeHelper.test.ts +39 -0
  48. package/src/utils/iframe/iframeHelper.ts +31 -2
  49. package/src/utils/index.ts +5 -0
  50. package/src/utils/url/queryParams.test.ts +85 -0
  51. package/src/utils/url/queryParams.ts +57 -0
  52. package/dist/actions-D5rNeJC0.js +0 -1
  53. package/dist/actions-gmdNZvNh.cjs +0 -1
  54. package/dist/frakContext-Cfp7sSid.cjs +0 -1
  55. package/dist/frakContext-UEOk9Bk4.js +0 -1
  56. package/dist/src-BEMVYU3J.js +0 -1
  57. 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
- referrerClientId: frakContext.c,
57
- referrerWallet: frakContext.w,
58
- walletStatus: walletStatus?.key,
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
- walletStatus: walletStatus?.key,
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 (!("sdkVersion" in payload.properties)) {
156
+ if (!("sdk_version" in payload.properties)) {
157
157
  payload.properties = {
158
158
  ...payload.properties,
159
- sdkVersion: process.env.SDK_VERSION,
160
- userAnonymousClientId: getClientId(),
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
- sdkVersion: process.env.SDK_VERSION,
169
- userAnonymousClientId: getClientId(),
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
- sdkVersion: process.env.SDK_VERSION,
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
- merchantId: resolved.merchantId,
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
- * Detect iOS in-app browsers (Instagram, Facebook) where server-side
12
- * 302 redirects to custom URL schemes (x-safari-https://) are silently
13
- * swallowed by WKWebView. Direct window.location.href assignment works.
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 (once per bundle boundary)
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 = urlObj.searchParams.get(contextKey);
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.delete(contextKey);
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
- minValue: number;
19
- maxValue?: number;
20
- amount: TokenAmountType;
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
@@ -6,7 +6,7 @@ export type SdkHandshakeFailureReason =
6
6
 
7
7
  export type SdkLifecycleEventMap = {
8
8
  sdk_initialized: {
9
- sdkVersion?: string;
9
+ sdk_version?: string;
10
10
  };
11
11
  sdk_iframe_connected: {
12
12
  handshake_duration_ms: number;
@@ -1,9 +1,9 @@
1
1
  export type SdkReferralEventMap = {
2
2
  user_referred_started: {
3
3
  referrer?: string;
4
- referrerClientId?: string;
5
- referrerWallet?: string;
6
- walletStatus?: string;
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
- /** Tracks recently failed keys to avoid request floods */
21
- const failureCache = new LruMap<number>(1024);
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
- promiseCache.clear();
122
- responseCache.clear();
123
- failureCache.clear();
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 = `${walletUrl}/listener?clientId=${encodeURIComponent(clientId)}`;
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
@@ -47,3 +47,8 @@ export {
47
47
  type FullSsoParams,
48
48
  generateSsoUrl,
49
49
  } from "./sso/sso";
50
+ // URL query params (case-insensitive key matching)
51
+ export {
52
+ deleteQueryParamCaseInsensitive,
53
+ getQueryParamCaseInsensitive,
54
+ } from "./url/queryParams";
@@ -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
+ }