@frak-labs/core-sdk 0.1.0-beta.afa252b0 → 0.1.0-beta.d9302e66

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 (116) hide show
  1. package/package.json +22 -17
  2. package/src/actions/displayEmbeddedWallet.test.ts +194 -0
  3. package/src/actions/displayEmbeddedWallet.ts +20 -0
  4. package/src/actions/displayModal.test.ts +387 -0
  5. package/src/actions/displayModal.ts +131 -0
  6. package/src/actions/getProductInformation.test.ts +133 -0
  7. package/src/actions/getProductInformation.ts +14 -0
  8. package/src/actions/index.ts +29 -0
  9. package/src/actions/openSso.test.ts +407 -0
  10. package/src/actions/openSso.ts +116 -0
  11. package/src/actions/prepareSso.test.ts +223 -0
  12. package/src/actions/prepareSso.ts +48 -0
  13. package/src/actions/referral/processReferral.ts +230 -0
  14. package/src/actions/referral/referralInteraction.ts +57 -0
  15. package/src/actions/sendInteraction.test.ts +219 -0
  16. package/src/actions/sendInteraction.ts +32 -0
  17. package/src/actions/trackPurchaseStatus.test.ts +287 -0
  18. package/src/actions/trackPurchaseStatus.ts +53 -0
  19. package/src/actions/watchWalletStatus.test.ts +372 -0
  20. package/src/actions/watchWalletStatus.ts +94 -0
  21. package/src/actions/wrapper/modalBuilder.ts +212 -0
  22. package/src/actions/wrapper/sendTransaction.ts +62 -0
  23. package/src/actions/wrapper/siweAuthenticate.ts +94 -0
  24. package/src/bundle.ts +3 -0
  25. package/src/clients/DebugInfo.ts +182 -0
  26. package/src/clients/createIFrameFrakClient.ts +287 -0
  27. package/src/clients/index.ts +3 -0
  28. package/src/clients/setupClient.ts +73 -0
  29. package/src/clients/transports/iframeLifecycleManager.ts +90 -0
  30. package/src/constants/interactionTypes.ts +44 -0
  31. package/src/constants/locales.ts +14 -0
  32. package/src/constants/productTypes.ts +33 -0
  33. package/src/index.ts +101 -0
  34. package/src/interactions/index.ts +5 -0
  35. package/src/interactions/pressEncoder.test.ts +215 -0
  36. package/src/interactions/pressEncoder.ts +53 -0
  37. package/src/interactions/purchaseEncoder.test.ts +291 -0
  38. package/src/interactions/purchaseEncoder.ts +99 -0
  39. package/src/interactions/referralEncoder.test.ts +170 -0
  40. package/src/interactions/referralEncoder.ts +47 -0
  41. package/src/interactions/retailEncoder.test.ts +107 -0
  42. package/src/interactions/retailEncoder.ts +37 -0
  43. package/src/interactions/webshopEncoder.test.ts +56 -0
  44. package/src/interactions/webshopEncoder.ts +30 -0
  45. package/src/types/client.ts +14 -0
  46. package/src/types/compression.ts +22 -0
  47. package/src/types/config.ts +111 -0
  48. package/src/types/context.ts +13 -0
  49. package/src/types/index.ts +71 -0
  50. package/src/types/lifecycle/client.ts +46 -0
  51. package/src/types/lifecycle/iframe.ts +35 -0
  52. package/src/types/lifecycle/index.ts +2 -0
  53. package/src/types/rpc/displayModal.ts +84 -0
  54. package/src/types/rpc/embedded/index.ts +68 -0
  55. package/src/types/rpc/embedded/loggedIn.ts +55 -0
  56. package/src/types/rpc/embedded/loggedOut.ts +28 -0
  57. package/src/types/rpc/interaction.ts +43 -0
  58. package/src/types/rpc/modal/final.ts +46 -0
  59. package/src/types/rpc/modal/generic.ts +46 -0
  60. package/src/types/rpc/modal/index.ts +20 -0
  61. package/src/types/rpc/modal/login.ts +32 -0
  62. package/src/types/rpc/modal/openSession.ts +25 -0
  63. package/src/types/rpc/modal/siweAuthenticate.ts +37 -0
  64. package/src/types/rpc/modal/transaction.ts +33 -0
  65. package/src/types/rpc/productInformation.ts +59 -0
  66. package/src/types/rpc/sso.ts +80 -0
  67. package/src/types/rpc/walletStatus.ts +35 -0
  68. package/src/types/rpc.ts +158 -0
  69. package/src/types/transport.ts +34 -0
  70. package/src/utils/FrakContext.test.ts +338 -0
  71. package/src/utils/FrakContext.ts +158 -0
  72. package/src/utils/compression/b64.test.ts +181 -0
  73. package/src/utils/compression/b64.ts +29 -0
  74. package/src/utils/compression/compress.test.ts +123 -0
  75. package/src/utils/compression/compress.ts +11 -0
  76. package/src/utils/compression/decompress.test.ts +145 -0
  77. package/src/utils/compression/decompress.ts +11 -0
  78. package/src/utils/compression/index.ts +3 -0
  79. package/src/utils/computeProductId.test.ts +80 -0
  80. package/src/utils/computeProductId.ts +11 -0
  81. package/src/utils/constants.test.ts +23 -0
  82. package/src/utils/constants.ts +4 -0
  83. package/src/utils/formatAmount.test.ts +113 -0
  84. package/src/utils/formatAmount.ts +18 -0
  85. package/src/utils/getCurrencyAmountKey.test.ts +44 -0
  86. package/src/utils/getCurrencyAmountKey.ts +15 -0
  87. package/src/utils/getSupportedCurrency.test.ts +51 -0
  88. package/src/utils/getSupportedCurrency.ts +14 -0
  89. package/src/utils/getSupportedLocale.test.ts +64 -0
  90. package/src/utils/getSupportedLocale.ts +16 -0
  91. package/src/utils/iframeHelper.test.ts +450 -0
  92. package/src/utils/iframeHelper.ts +143 -0
  93. package/src/utils/index.ts +21 -0
  94. package/src/utils/sso.test.ts +361 -0
  95. package/src/utils/sso.ts +119 -0
  96. package/src/utils/ssoUrlListener.ts +60 -0
  97. package/src/utils/trackEvent.test.ts +162 -0
  98. package/src/utils/trackEvent.ts +26 -0
  99. package/cdn/bundle.js +0 -19
  100. package/cdn/bundle.js.LICENSE.txt +0 -10
  101. package/dist/actions.cjs +0 -1
  102. package/dist/actions.d.cts +0 -1481
  103. package/dist/actions.d.ts +0 -1481
  104. package/dist/actions.js +0 -1
  105. package/dist/bundle.cjs +0 -13
  106. package/dist/bundle.d.cts +0 -2087
  107. package/dist/bundle.d.ts +0 -2087
  108. package/dist/bundle.js +0 -13
  109. package/dist/index.cjs +0 -13
  110. package/dist/index.d.cts +0 -1387
  111. package/dist/index.d.ts +0 -1387
  112. package/dist/index.js +0 -13
  113. package/dist/interactions.cjs +0 -1
  114. package/dist/interactions.d.cts +0 -182
  115. package/dist/interactions.d.ts +0 -182
  116. package/dist/interactions.js +0 -1
@@ -0,0 +1,158 @@
1
+ import type { Hex } from "viem";
2
+ import type { FrakWalletSdkConfig } from "./config";
3
+ import type {
4
+ ModalRpcMetadata,
5
+ ModalRpcStepsInput,
6
+ ModalRpcStepsResultType,
7
+ } from "./rpc/displayModal";
8
+ import type {
9
+ DisplayEmbeddedWalletParamsType,
10
+ DisplayEmbeddedWalletResultType,
11
+ } from "./rpc/embedded";
12
+ import type {
13
+ PreparedInteraction,
14
+ SendInteractionReturnType,
15
+ } from "./rpc/interaction";
16
+ import type { GetProductInformationReturnType } from "./rpc/productInformation";
17
+ import type {
18
+ OpenSsoParamsType,
19
+ OpenSsoReturnType,
20
+ PrepareSsoParamsType,
21
+ PrepareSsoReturnType,
22
+ } from "./rpc/sso";
23
+ import type { WalletStatusReturnType } from "./rpc/walletStatus";
24
+
25
+ /**
26
+ * RPC interface that's used for the iframe communication
27
+ *
28
+ * Define all the methods available within the iFrame RPC client with response type annotations
29
+ *
30
+ * @group RPC Schema
31
+ *
32
+ * @remarks
33
+ * Each method in the schema now includes a ResponseType field that indicates:
34
+ * - "promise": One-shot request that resolves once
35
+ * - "stream": Streaming request that can emit multiple values
36
+ *
37
+ * ### Methods:
38
+ *
39
+ * #### frak_listenToWalletStatus
40
+ * - Params: None
41
+ * - Returns: {@link WalletStatusReturnType}
42
+ * - Response Type: stream (emits updates when wallet status changes)
43
+ *
44
+ * #### frak_displayModal
45
+ * - Params: [requests: {@link ModalRpcStepsInput}, metadata?: {@link ModalRpcMetadata}, configMetadata: {@link FrakWalletSdkConfig}["metadata"]]
46
+ * - Returns: {@link ModalRpcStepsResultType}
47
+ * - Response Type: promise (one-shot)
48
+ *
49
+ * #### frak_sendInteraction
50
+ * - Params: [productId: Hex, interaction: {@link PreparedInteraction}, signature?: Hex]
51
+ * - Returns: {@link SendInteractionReturnType}
52
+ * - Response Type: promise (one-shot)
53
+ *
54
+ * #### frak_sso
55
+ * - Params: [params: {@link OpenSsoParamsType}, name: string, customCss?: string]
56
+ * - Returns: {@link OpenSsoReturnType}
57
+ * - Response Type: promise (one-shot)
58
+ *
59
+ * #### frak_getProductInformation
60
+ * - Params: None
61
+ * - Returns: {@link GetProductInformationReturnType}
62
+ * - Response Type: promise (one-shot)
63
+ *
64
+ * #### frak_displayEmbeddedWallet
65
+ * - Params: [request: {@link DisplayEmbeddedWalletParamsType}, metadata: {@link FrakWalletSdkConfig}["metadata"]]
66
+ * - Returns: {@link DisplayEmbeddedWalletResultType}
67
+ * - Response Type: promise (one-shot)
68
+ */
69
+ export type IFrameRpcSchema = [
70
+ /**
71
+ * Method used to listen to the wallet status
72
+ * This is a streaming method that emits updates when wallet status changes
73
+ */
74
+ {
75
+ Method: "frak_listenToWalletStatus";
76
+ Parameters?: undefined;
77
+ ReturnType: WalletStatusReturnType;
78
+ },
79
+ /**
80
+ * Method to display a modal with the provided steps
81
+ * This is a one-shot request
82
+ */
83
+ {
84
+ Method: "frak_displayModal";
85
+ Parameters: [
86
+ requests: ModalRpcStepsInput,
87
+ metadata: ModalRpcMetadata | undefined,
88
+ configMetadata: FrakWalletSdkConfig["metadata"],
89
+ ];
90
+ ReturnType: ModalRpcStepsResultType;
91
+ },
92
+ /**
93
+ * Method to transmit a user interaction
94
+ * This is a one-shot request
95
+ */
96
+ {
97
+ Method: "frak_sendInteraction";
98
+ Parameters: [
99
+ productId: Hex,
100
+ interaction: PreparedInteraction,
101
+ signature?: Hex,
102
+ ];
103
+ ReturnType: SendInteractionReturnType;
104
+ },
105
+ /**
106
+ * Method to prepare SSO (generate URL for popup)
107
+ * Returns the SSO URL that should be opened in a popup
108
+ * Only used for popup flows (not redirect flows)
109
+ */
110
+ {
111
+ Method: "frak_prepareSso";
112
+ Parameters: [
113
+ params: PrepareSsoParamsType,
114
+ name: string,
115
+ customCss?: string,
116
+ ];
117
+ ReturnType: PrepareSsoReturnType;
118
+ },
119
+ /**
120
+ * Method to open/trigger SSO
121
+ * Either triggers redirect (if openInSameWindow/redirectUrl)
122
+ * Or waits for popup completion (if popup mode)
123
+ * This method handles BOTH redirect and popup flows
124
+ */
125
+ {
126
+ Method: "frak_openSso";
127
+ Parameters: [
128
+ params: OpenSsoParamsType,
129
+ name: string,
130
+ customCss?: string,
131
+ ];
132
+ ReturnType: OpenSsoReturnType;
133
+ },
134
+ /**
135
+ * Method to get current product information's
136
+ * - Is product minted?
137
+ * - Does it have running campaign?
138
+ * - Estimated reward on actions
139
+ * This is a one-shot request
140
+ */
141
+ {
142
+ Method: "frak_getProductInformation";
143
+ Parameters?: undefined;
144
+ ReturnType: GetProductInformationReturnType;
145
+ },
146
+ /**
147
+ * Method to show the embedded wallet, with potential customization
148
+ * This is a one-shot request
149
+ */
150
+ {
151
+ Method: "frak_displayEmbeddedWallet";
152
+ Parameters: [
153
+ request: DisplayEmbeddedWalletParamsType,
154
+ metadata: FrakWalletSdkConfig["metadata"],
155
+ ];
156
+ ReturnType: DisplayEmbeddedWalletResultType;
157
+ },
158
+ ];
@@ -0,0 +1,34 @@
1
+ import type { LifecycleMessage, RpcClient } from "@frak-labs/frame-connector";
2
+ import type { ClientLifecycleEvent, IFrameLifecycleEvent } from "./lifecycle";
3
+ import type { IFrameRpcSchema } from "./rpc";
4
+
5
+ /**
6
+ * IFrame transport interface
7
+ */
8
+ export type IFrameTransport = {
9
+ /**
10
+ * Wait for the connection to be established
11
+ */
12
+ waitForConnection: Promise<boolean>;
13
+ /**
14
+ * Wait for the setup to be done
15
+ */
16
+ waitForSetup: Promise<void>;
17
+ /**
18
+ * Function used to perform a single request via the iframe transport
19
+ */
20
+ request: RpcClient<IFrameRpcSchema, LifecycleMessage>["request"];
21
+ /**
22
+ * Function used to listen to a request response via the iframe transport
23
+ */
24
+ listenerRequest: RpcClient<IFrameRpcSchema, LifecycleMessage>["listen"];
25
+ /**
26
+ * Function used to destroy the iframe transport
27
+ */
28
+ destroy: () => Promise<void>;
29
+ };
30
+
31
+ /**
32
+ * Represent an iframe event
33
+ */
34
+ export type FrakLifecycleEvent = IFrameLifecycleEvent | ClientLifecycleEvent;
@@ -0,0 +1,338 @@
1
+ /**
2
+ * Tests for FrakContextManager utility
3
+ * Tests Frak context compression, URL parsing, and management
4
+ */
5
+
6
+ import type { Address } from "viem";
7
+ import {
8
+ afterEach,
9
+ beforeEach,
10
+ describe,
11
+ expect,
12
+ it,
13
+ vi,
14
+ } from "../../tests/vitest-fixtures";
15
+ import type { FrakContext } from "../types";
16
+ import { FrakContextManager } from "./FrakContext";
17
+
18
+ describe("FrakContextManager", () => {
19
+ let consoleErrorSpy: any;
20
+
21
+ beforeEach(() => {
22
+ consoleErrorSpy = vi
23
+ .spyOn(console, "error")
24
+ .mockImplementation(() => {});
25
+ });
26
+
27
+ afterEach(() => {
28
+ consoleErrorSpy.mockRestore();
29
+ });
30
+
31
+ describe("compress", () => {
32
+ it("should compress context with referrer address", () => {
33
+ const context: Partial<FrakContext> = {
34
+ r: "0x1234567890123456789012345678901234567890" as Address,
35
+ };
36
+
37
+ const result = FrakContextManager.compress(context);
38
+
39
+ expect(result).toBeDefined();
40
+ expect(typeof result).toBe("string");
41
+ expect(result?.length).toBeGreaterThan(0);
42
+ // Base64url should not contain +, /, or =
43
+ expect(result).not.toMatch(/[+/=]/);
44
+ });
45
+
46
+ it("should return undefined when context has no referrer", () => {
47
+ const context: Partial<FrakContext> = {};
48
+
49
+ const result = FrakContextManager.compress(context);
50
+
51
+ expect(result).toBeUndefined();
52
+ });
53
+
54
+ it("should return undefined when context is undefined", () => {
55
+ const result = FrakContextManager.compress(undefined);
56
+
57
+ expect(result).toBeUndefined();
58
+ });
59
+
60
+ it("should handle compression errors gracefully", () => {
61
+ const invalidContext = {
62
+ r: "invalid-address" as Address,
63
+ };
64
+
65
+ const result = FrakContextManager.compress(invalidContext);
66
+
67
+ expect(consoleErrorSpy).toHaveBeenCalled();
68
+ expect(result).toBeUndefined();
69
+ });
70
+ });
71
+
72
+ describe("decompress", () => {
73
+ it("should decompress valid base64url context", () => {
74
+ // First compress a context
75
+ const originalContext: FrakContext = {
76
+ r: "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd" as Address,
77
+ };
78
+ const compressed = FrakContextManager.compress(originalContext);
79
+
80
+ // Then decompress it
81
+ const result = FrakContextManager.decompress(compressed);
82
+
83
+ expect(result).toBeDefined();
84
+ expect(result?.r).toBe(
85
+ "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd"
86
+ );
87
+ });
88
+
89
+ it("should return undefined for empty string", () => {
90
+ const result = FrakContextManager.decompress("");
91
+
92
+ expect(result).toBeUndefined();
93
+ });
94
+
95
+ it("should return undefined for undefined input", () => {
96
+ const result = FrakContextManager.decompress(undefined);
97
+
98
+ expect(result).toBeUndefined();
99
+ });
100
+
101
+ it("should handle decompression errors gracefully", () => {
102
+ const result = FrakContextManager.decompress(
103
+ "invalid-base64url!@#"
104
+ );
105
+
106
+ expect(consoleErrorSpy).toHaveBeenCalled();
107
+ expect(result).toBeUndefined();
108
+ });
109
+
110
+ it("should round-trip compress and decompress", () => {
111
+ const original: FrakContext = {
112
+ r: "0x1234567890123456789012345678901234567890" as Address,
113
+ };
114
+
115
+ const compressed = FrakContextManager.compress(original);
116
+ const decompressed = FrakContextManager.decompress(compressed);
117
+
118
+ expect(decompressed).toEqual(original);
119
+ });
120
+ });
121
+
122
+ describe("parse", () => {
123
+ it("should parse URL with fCtx parameter", () => {
124
+ const context: FrakContext = {
125
+ r: "0x1234567890123456789012345678901234567890" as Address,
126
+ };
127
+ const compressed = FrakContextManager.compress(context);
128
+ const url = `https://example.com?fCtx=${compressed}`;
129
+
130
+ const result = FrakContextManager.parse({ url });
131
+
132
+ expect(result).toBeDefined();
133
+ expect(result?.r).toBe(
134
+ "0x1234567890123456789012345678901234567890"
135
+ );
136
+ });
137
+
138
+ it("should return null for URL without fCtx parameter", () => {
139
+ const url = "https://example.com?other=param";
140
+
141
+ const result = FrakContextManager.parse({ url });
142
+
143
+ expect(result).toBeNull();
144
+ });
145
+
146
+ it("should return null for empty URL", () => {
147
+ const result = FrakContextManager.parse({ url: "" });
148
+
149
+ expect(result).toBeNull();
150
+ });
151
+
152
+ it("should parse URL with multiple parameters", () => {
153
+ const context: FrakContext = {
154
+ r: "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd" as Address,
155
+ };
156
+ const compressed = FrakContextManager.compress(context);
157
+ const url = `https://example.com?foo=bar&fCtx=${compressed}&baz=qux`;
158
+
159
+ const result = FrakContextManager.parse({ url });
160
+
161
+ expect(result).toBeDefined();
162
+ expect(result?.r).toBe(
163
+ "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd"
164
+ );
165
+ });
166
+
167
+ it("should return undefined for malformed fCtx parameter", () => {
168
+ // Use a string that will fail base64url decoding
169
+ const url = "https://example.com?fCtx=!!!invalid!!!";
170
+
171
+ const result = FrakContextManager.parse({ url });
172
+
173
+ // Should handle the error and return undefined
174
+ expect(result).toBeUndefined();
175
+ });
176
+ });
177
+
178
+ describe("update", () => {
179
+ it("should add fCtx to URL without existing context", () => {
180
+ const url = "https://example.com";
181
+ const context: FrakContext = {
182
+ r: "0x1234567890123456789012345678901234567890" as Address,
183
+ };
184
+
185
+ const result = FrakContextManager.update({ url, context });
186
+
187
+ expect(result).toBeDefined();
188
+ expect(result).toContain("fCtx=");
189
+ expect(result).toContain("https://example.com");
190
+ });
191
+
192
+ it("should merge with existing context in URL", () => {
193
+ const existingContext: FrakContext = {
194
+ r: "0x1234567890123456789012345678901234567890" as Address,
195
+ };
196
+ const compressed = FrakContextManager.compress(existingContext);
197
+ const url = `https://example.com?fCtx=${compressed}`;
198
+
199
+ const newContext: FrakContext = {
200
+ r: "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd" as Address,
201
+ };
202
+
203
+ const result = FrakContextManager.update({
204
+ url,
205
+ context: newContext,
206
+ });
207
+
208
+ expect(result).toBeDefined();
209
+ expect(result).toContain("fCtx=");
210
+
211
+ // Parse the result and check it has the new referrer
212
+ const parsedResult = FrakContextManager.parse({ url: result! });
213
+ expect(parsedResult?.r).toBe(
214
+ "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd"
215
+ );
216
+ });
217
+
218
+ it("should return null when URL is undefined", () => {
219
+ const context: FrakContext = {
220
+ r: "0x1234567890123456789012345678901234567890" as Address,
221
+ };
222
+
223
+ const result = FrakContextManager.update({
224
+ url: undefined,
225
+ context,
226
+ });
227
+
228
+ expect(result).toBeNull();
229
+ });
230
+
231
+ it("should return null when context has no referrer", () => {
232
+ const url = "https://example.com";
233
+ const context: Partial<FrakContext> = {};
234
+
235
+ const result = FrakContextManager.update({ url, context });
236
+
237
+ expect(result).toBeNull();
238
+ });
239
+
240
+ it("should preserve other URL parameters", () => {
241
+ const url = "https://example.com?foo=bar&baz=qux";
242
+ const context: FrakContext = {
243
+ r: "0x1234567890123456789012345678901234567890" as Address,
244
+ };
245
+
246
+ const result = FrakContextManager.update({ url, context });
247
+
248
+ expect(result).toContain("foo=bar");
249
+ expect(result).toContain("baz=qux");
250
+ expect(result).toContain("fCtx=");
251
+ });
252
+
253
+ it("should preserve URL hash", () => {
254
+ const url = "https://example.com#section";
255
+ const context: FrakContext = {
256
+ r: "0x1234567890123456789012345678901234567890" as Address,
257
+ };
258
+
259
+ const result = FrakContextManager.update({ url, context });
260
+
261
+ expect(result).toContain("#section");
262
+ expect(result).toContain("fCtx=");
263
+ });
264
+ });
265
+
266
+ describe("remove", () => {
267
+ it("should remove fCtx parameter from URL", () => {
268
+ const context: FrakContext = {
269
+ r: "0x1234567890123456789012345678901234567890" as Address,
270
+ };
271
+ const compressed = FrakContextManager.compress(context);
272
+ const url = `https://example.com?fCtx=${compressed}`;
273
+
274
+ const result = FrakContextManager.remove(url);
275
+
276
+ expect(result).toBe("https://example.com/");
277
+ expect(result).not.toContain("fCtx");
278
+ });
279
+
280
+ it("should preserve other parameters when removing fCtx", () => {
281
+ const context: FrakContext = {
282
+ r: "0x1234567890123456789012345678901234567890" as Address,
283
+ };
284
+ const compressed = FrakContextManager.compress(context);
285
+ const url = `https://example.com?foo=bar&fCtx=${compressed}&baz=qux`;
286
+
287
+ const result = FrakContextManager.remove(url);
288
+
289
+ expect(result).toContain("foo=bar");
290
+ expect(result).toContain("baz=qux");
291
+ expect(result).not.toContain("fCtx");
292
+ });
293
+
294
+ it("should handle URL without fCtx parameter", () => {
295
+ const url = "https://example.com?foo=bar";
296
+
297
+ const result = FrakContextManager.remove(url);
298
+
299
+ expect(result).toContain("foo=bar");
300
+ expect(result).not.toContain("fCtx");
301
+ });
302
+
303
+ it("should preserve URL hash", () => {
304
+ const url = "https://example.com?fCtx=test#section";
305
+
306
+ const result = FrakContextManager.remove(url);
307
+
308
+ expect(result).toContain("#section");
309
+ expect(result).not.toContain("fCtx");
310
+ });
311
+ });
312
+
313
+ describe("replaceUrl", () => {
314
+ // Note: replaceUrl tests are skipped because window.location cannot be mocked
315
+ // in JSDOM environment. The function is primarily used for browser DOM manipulation
316
+ // and is tested through integration/E2E tests.
317
+
318
+ it.skip("should update window.location with context", () => {
319
+ // Skip: Cannot mock window.location in JSDOM
320
+ });
321
+
322
+ it.skip("should use provided URL instead of window.location.href", () => {
323
+ // Skip: Cannot mock window.location in JSDOM
324
+ });
325
+
326
+ it.skip("should remove fCtx when context is null", () => {
327
+ // Skip: Cannot mock window.location in JSDOM
328
+ });
329
+
330
+ it.skip("should not call replaceState when context has no referrer", () => {
331
+ // Skip: Cannot mock window.location in JSDOM
332
+ });
333
+
334
+ it.skip("should handle missing window gracefully", () => {
335
+ // Skip: Cannot mock window.location in JSDOM
336
+ });
337
+ });
338
+ });
@@ -0,0 +1,158 @@
1
+ import { type Address, bytesToHex, hexToBytes } from "viem";
2
+ import type { FrakContext } from "../types";
3
+ import { base64urlDecode, base64urlEncode } from "./compression/b64";
4
+
5
+ /**
6
+ * The context key
7
+ */
8
+ const contextKey = "fCtx";
9
+
10
+ /**
11
+ * Compress the current Frak context
12
+ * @param context - The context to be compressed
13
+ * @returns A compressed string containing the Frak context
14
+ */
15
+ function compress(context?: Partial<FrakContext>): string | undefined {
16
+ if (!context?.r) return;
17
+ try {
18
+ const bytes = hexToBytes(context.r);
19
+ return base64urlEncode(bytes);
20
+ } catch (e) {
21
+ console.error("Error compressing Frak context", { e, context });
22
+ }
23
+ return undefined;
24
+ }
25
+
26
+ /**
27
+ * Decompress the given Frak context
28
+ * @param context - The raw context to be decompressed into a `FrakContext`
29
+ * @returns The decompressed Frak context, or undefined if it fails
30
+ */
31
+ function decompress(context?: string): FrakContext | undefined {
32
+ if (!context || context.length === 0) return;
33
+ try {
34
+ const bytes = base64urlDecode(context);
35
+ return { r: bytesToHex(bytes, { size: 20 }) as Address };
36
+ } catch (e) {
37
+ console.error("Error decompressing Frak context", { e, context });
38
+ }
39
+ return undefined;
40
+ }
41
+
42
+ /**
43
+ * Parse the current URL into a Frak Context
44
+ * @param args
45
+ * @param args.url - The url to parse
46
+ * @returns The parsed Frak context
47
+ */
48
+ function parse({ url }: { url: string }) {
49
+ if (!url) return null;
50
+
51
+ // Check if the url contain the frak context key
52
+ const urlObj = new URL(url);
53
+ const frakContext = urlObj.searchParams.get(contextKey);
54
+ if (!frakContext) return null;
55
+
56
+ // Decompress and return it
57
+ return decompress(frakContext);
58
+ }
59
+
60
+ /**
61
+ * Populate the current url with the given Frak context
62
+ * @param args
63
+ * @param args.url - The url to update
64
+ * @param args.context - The context to update
65
+ * @returns The new url with the Frak context
66
+ */
67
+ function update({
68
+ url,
69
+ context,
70
+ }: {
71
+ url?: string;
72
+ context: Partial<FrakContext>;
73
+ }) {
74
+ if (!url) return null;
75
+
76
+ // Parse the current context
77
+ const currentContext = parse({ url });
78
+
79
+ // Merge the current context with the new context
80
+ const mergedContext = currentContext
81
+ ? { ...currentContext, ...context }
82
+ : context;
83
+
84
+ // If we don't have a referrer, early exit
85
+ if (!mergedContext.r) return null;
86
+
87
+ // Compress it
88
+ const compressedContext = compress(mergedContext);
89
+ if (!compressedContext) return null;
90
+
91
+ // Build the new url and return it
92
+ const urlObj = new URL(url);
93
+ urlObj.searchParams.set(contextKey, compressedContext);
94
+ return urlObj.toString();
95
+ }
96
+
97
+ /**
98
+ * Remove Frak context from current url
99
+ * @param url - The url to update
100
+ * @returns The new url without the Frak context
101
+ */
102
+ function remove(url: string) {
103
+ const urlObj = new URL(url);
104
+ urlObj.searchParams.delete(contextKey);
105
+ return urlObj.toString();
106
+ }
107
+
108
+ /**
109
+ * Replace the current url with the given Frak context
110
+ * @param args
111
+ * @param args.url - The url to update
112
+ * @param args.context - The context to update
113
+ */
114
+ function replaceUrl({
115
+ url: baseUrl,
116
+ context,
117
+ }: {
118
+ url?: string;
119
+ context: Partial<FrakContext> | null;
120
+ }) {
121
+ // If no window here early exit
122
+ if (!window.location?.href || typeof window === "undefined") {
123
+ console.error("No window found, can't update context");
124
+ return;
125
+ }
126
+
127
+ // If no url, try to use the current one
128
+ const url = baseUrl ?? window.location.href;
129
+
130
+ // Get our new url with the frak context
131
+ let newUrl: string | null;
132
+ if (context !== null) {
133
+ newUrl = update({
134
+ url,
135
+ context,
136
+ });
137
+ } else {
138
+ newUrl = remove(url);
139
+ }
140
+
141
+ // If no new url, early exit
142
+ if (!newUrl) return;
143
+
144
+ // Update the url
145
+ window.history.replaceState(null, "", newUrl.toString());
146
+ }
147
+
148
+ /**
149
+ * Export our frak context
150
+ */
151
+ export const FrakContextManager = {
152
+ compress,
153
+ decompress,
154
+ parse,
155
+ update,
156
+ remove,
157
+ replaceUrl,
158
+ };