@frak-labs/core-sdk 0.2.1-beta.b38eef2e → 0.2.1-beta.c7fe645d
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 +55 -3
- 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 → computeLegacyProductId-C35yITjX.d.ts} +91 -37
- package/dist/{computeLegacyProductId-b5cUWdAm.d.ts → computeLegacyProductId-mG4x5Cq0.d.cts} +91 -37
- 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-7e-OKMhg.d.ts} +266 -46
- package/dist/{openSso-CMzwvaCa.d.ts → openSso-BebjXCq0.d.cts} +266 -46
- package/dist/setupClient-CUYyoXtI.js +13 -0
- package/dist/setupClient-LgwHIFJc.cjs +13 -0
- package/dist/{siweAuthenticate-CnCZ7mok.d.ts → siweAuthenticate-BQEMZRg3.d.cts} +102 -8
- package/dist/siweAuthenticate-CWcVvP-G.cjs +1 -0
- package/dist/{siweAuthenticate-CVigMOxz.d.cts → siweAuthenticate-CdLD-9W2.d.ts} +102 -8
- package/dist/siweAuthenticate-DQfdb5UQ.js +1 -0
- package/dist/trackEvent-Ce1XlsIE.js +1 -0
- package/dist/trackEvent-CvbJTTqA.cjs +1 -0
- package/package.json +8 -8
- 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/getUserReferralStatus.ts +42 -0
- package/src/actions/index.ts +7 -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 +150 -27
- package/src/clients/transports/iframeLifecycleManager.test.ts +14 -94
- package/src/clients/transports/iframeLifecycleManager.ts +15 -48
- package/src/index.ts +17 -4
- 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 +123 -0
- package/src/types/rpc/displaySharingPage.ts +77 -0
- package/src/types/rpc/interaction.ts +4 -0
- package/src/types/rpc/userReferralStatus.ts +20 -0
- package/src/types/rpc.ts +42 -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 +162 -0
- package/src/utils/cache/withCache.ts +105 -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-CqTHGvVa.cjs +0 -13
- package/dist/setupClient-DTyvAPgh.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
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import type { Currency, Language } from "./config";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Response from the merchant resolve endpoint
|
|
5
|
+
* @category Config
|
|
6
|
+
*/
|
|
7
|
+
export type MerchantConfigResponse = {
|
|
8
|
+
merchantId: string;
|
|
9
|
+
name: string;
|
|
10
|
+
domain: string;
|
|
11
|
+
allowedDomains: string[];
|
|
12
|
+
sdkConfig?: ResolvedSdkConfig;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Resolved placement config from backend
|
|
17
|
+
* Translations already flattened: default + lang-specific merged into one record
|
|
18
|
+
* @category Config
|
|
19
|
+
*/
|
|
20
|
+
export type ResolvedPlacement = {
|
|
21
|
+
/** Per-component configuration within this placement */
|
|
22
|
+
components?: {
|
|
23
|
+
buttonShare?: {
|
|
24
|
+
text?: string;
|
|
25
|
+
noRewardText?: string;
|
|
26
|
+
clickAction?: "embedded-wallet" | "share-modal" | "sharing-page";
|
|
27
|
+
useReward?: boolean;
|
|
28
|
+
css?: string;
|
|
29
|
+
};
|
|
30
|
+
buttonWallet?: {
|
|
31
|
+
position?: "right" | "left";
|
|
32
|
+
css?: string;
|
|
33
|
+
};
|
|
34
|
+
openInApp?: {
|
|
35
|
+
text?: string;
|
|
36
|
+
css?: string;
|
|
37
|
+
};
|
|
38
|
+
postPurchase?: {
|
|
39
|
+
badgeText?: string;
|
|
40
|
+
refereeText?: string;
|
|
41
|
+
refereeNoRewardText?: string;
|
|
42
|
+
referrerText?: string;
|
|
43
|
+
referrerNoRewardText?: string;
|
|
44
|
+
ctaText?: string;
|
|
45
|
+
ctaNoRewardText?: string;
|
|
46
|
+
css?: string;
|
|
47
|
+
};
|
|
48
|
+
banner?: {
|
|
49
|
+
referralTitle?: string;
|
|
50
|
+
referralDescription?: string;
|
|
51
|
+
referralCta?: string;
|
|
52
|
+
inappTitle?: string;
|
|
53
|
+
inappDescription?: string;
|
|
54
|
+
inappCta?: string;
|
|
55
|
+
css?: string;
|
|
56
|
+
};
|
|
57
|
+
};
|
|
58
|
+
targetInteraction?: string;
|
|
59
|
+
/** Already flattened: default + lang-specific merged into one record */
|
|
60
|
+
translations?: Record<string, string>;
|
|
61
|
+
/** Global placement CSS (applied to modals/listener) */
|
|
62
|
+
css?: string;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Resolved SDK config from backend `/resolve` endpoint
|
|
67
|
+
* Language resolution and translation merging already applied
|
|
68
|
+
* @category Config
|
|
69
|
+
*/
|
|
70
|
+
export type ResolvedSdkConfig = {
|
|
71
|
+
name?: string;
|
|
72
|
+
logoUrl?: string;
|
|
73
|
+
homepageLink?: string;
|
|
74
|
+
currency?: Currency;
|
|
75
|
+
lang?: Language;
|
|
76
|
+
/** When true, all SDK components should be hidden */
|
|
77
|
+
hidden?: boolean;
|
|
78
|
+
css?: string;
|
|
79
|
+
translations?: Record<string, string>;
|
|
80
|
+
placements?: Record<string, ResolvedPlacement>;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Internal SDK config store state
|
|
85
|
+
* Merged config: backend > SDK static > defaults
|
|
86
|
+
* Components subscribe to this reactively
|
|
87
|
+
* @category Config
|
|
88
|
+
*/
|
|
89
|
+
export type SdkResolvedConfig = {
|
|
90
|
+
/** Whether the backend config has been resolved */
|
|
91
|
+
isResolved: boolean;
|
|
92
|
+
|
|
93
|
+
/** Merchant ID from resolution */
|
|
94
|
+
merchantId: string;
|
|
95
|
+
|
|
96
|
+
/** Domain returned by the resolve endpoint */
|
|
97
|
+
domain?: string;
|
|
98
|
+
|
|
99
|
+
/** Domains allowed for this merchant (used by iframe trust check) */
|
|
100
|
+
allowedDomains?: string[];
|
|
101
|
+
|
|
102
|
+
/** Whether the resolve returned a backend sdkConfig object */
|
|
103
|
+
hasRawSdkConfig?: boolean;
|
|
104
|
+
|
|
105
|
+
/** Merged metadata fields */
|
|
106
|
+
name?: string;
|
|
107
|
+
logoUrl?: string;
|
|
108
|
+
homepageLink?: string;
|
|
109
|
+
lang?: Language;
|
|
110
|
+
currency?: Currency;
|
|
111
|
+
|
|
112
|
+
/** When true, all SDK components should be hidden */
|
|
113
|
+
hidden?: boolean;
|
|
114
|
+
|
|
115
|
+
/** Global CSS from backend config (passed to iframe) */
|
|
116
|
+
css?: string;
|
|
117
|
+
|
|
118
|
+
/** Global translations (for reference / component fallback) */
|
|
119
|
+
translations?: Record<string, string>;
|
|
120
|
+
|
|
121
|
+
/** Named placements (keyed by placement ID) */
|
|
122
|
+
placements?: Record<string, ResolvedPlacement>;
|
|
123
|
+
};
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { InteractionTypeKey } from "../../constants/interactionTypes";
|
|
2
|
+
import type { I18nConfig } from "../config";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Product information to display on the sharing page
|
|
6
|
+
* @group Sharing Page
|
|
7
|
+
*/
|
|
8
|
+
export type SharingPageProduct = {
|
|
9
|
+
/**
|
|
10
|
+
* The product title / name
|
|
11
|
+
*/
|
|
12
|
+
title: string;
|
|
13
|
+
/**
|
|
14
|
+
* Optional product image URL
|
|
15
|
+
*/
|
|
16
|
+
imageUrl?: string;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Parameters to display the sharing page
|
|
21
|
+
* @group Sharing Page
|
|
22
|
+
* @group RPC Schema
|
|
23
|
+
*/
|
|
24
|
+
export type DisplaySharingPageParamsType = {
|
|
25
|
+
/**
|
|
26
|
+
* Products to showcase on the sharing page
|
|
27
|
+
* If provided, they will be displayed in a product card section
|
|
28
|
+
*/
|
|
29
|
+
products?: SharingPageProduct[];
|
|
30
|
+
/**
|
|
31
|
+
* Optional link override for sharing
|
|
32
|
+
* If not provided, the sharing link will be generated from the current page URL + merchant context
|
|
33
|
+
*/
|
|
34
|
+
link?: string;
|
|
35
|
+
/**
|
|
36
|
+
* Optional metadata overrides for the sharing page
|
|
37
|
+
*/
|
|
38
|
+
metadata?: {
|
|
39
|
+
/**
|
|
40
|
+
* Logo override for the sharing page header
|
|
41
|
+
*/
|
|
42
|
+
logo?: string;
|
|
43
|
+
/**
|
|
44
|
+
* Link to the homepage of the calling website
|
|
45
|
+
*/
|
|
46
|
+
homepageLink?: string;
|
|
47
|
+
/**
|
|
48
|
+
* The target interaction behind this sharing page
|
|
49
|
+
*/
|
|
50
|
+
targetInteraction?: InteractionTypeKey;
|
|
51
|
+
/**
|
|
52
|
+
* i18n overrides for the sharing page
|
|
53
|
+
*/
|
|
54
|
+
i18n?: I18nConfig;
|
|
55
|
+
};
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Result of the sharing page display
|
|
60
|
+
* @group Sharing Page
|
|
61
|
+
* @group RPC Schema
|
|
62
|
+
*/
|
|
63
|
+
export type DisplaySharingPageResultType = {
|
|
64
|
+
/**
|
|
65
|
+
* The action the user took
|
|
66
|
+
* - "shared": User used the native share dialog
|
|
67
|
+
* - "copied": User copied the link to clipboard
|
|
68
|
+
* - "dismissed": User dismissed the sharing page without acting
|
|
69
|
+
*/
|
|
70
|
+
action: "shared" | "copied" | "dismissed";
|
|
71
|
+
/**
|
|
72
|
+
* The install URL for the Frak app
|
|
73
|
+
* Can be used as a fallback to redirect the user to the install page
|
|
74
|
+
* from the merchant's top-level page (e.g. via `window.location.href`)
|
|
75
|
+
*/
|
|
76
|
+
installUrl?: string;
|
|
77
|
+
};
|
|
@@ -26,6 +26,10 @@ export type SendInteractionParamsType =
|
|
|
26
26
|
}
|
|
27
27
|
| {
|
|
28
28
|
type: "sharing";
|
|
29
|
+
/** Epoch seconds timestamp matching the V2 context `t` field embedded in the referral link URL, used for backend correlation */
|
|
30
|
+
sharingTimestamp?: number;
|
|
31
|
+
/** Merchant order ID linking this sharing event to a purchase (stays server-side, never in URL) */
|
|
32
|
+
purchaseId?: string;
|
|
29
33
|
}
|
|
30
34
|
| {
|
|
31
35
|
type: "custom";
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* User referral status returned by `frak_getUserReferralStatus`.
|
|
3
|
+
*
|
|
4
|
+
* Generic referral context for the current user on a merchant.
|
|
5
|
+
* Used by components like `<frak-post-purchase>` and `<frak-referred-banner>`
|
|
6
|
+
* to adapt their display based on the user's referral relationship.
|
|
7
|
+
*
|
|
8
|
+
* Returns `null` when the user's identity cannot be resolved
|
|
9
|
+
* (e.g. no clientId and no wallet session).
|
|
10
|
+
*
|
|
11
|
+
* @group RPC Schema
|
|
12
|
+
*/
|
|
13
|
+
export type UserReferralStatusType = {
|
|
14
|
+
/**
|
|
15
|
+
* Whether the user was referred to this merchant by someone else.
|
|
16
|
+
*
|
|
17
|
+
* `true` means a referral link exists where this user is the referee.
|
|
18
|
+
*/
|
|
19
|
+
isReferred: boolean;
|
|
20
|
+
};
|
package/src/types/rpc.ts
CHANGED
|
@@ -4,6 +4,10 @@ import type {
|
|
|
4
4
|
ModalRpcStepsInput,
|
|
5
5
|
ModalRpcStepsResultType,
|
|
6
6
|
} from "./rpc/displayModal";
|
|
7
|
+
import type {
|
|
8
|
+
DisplaySharingPageParamsType,
|
|
9
|
+
DisplaySharingPageResultType,
|
|
10
|
+
} from "./rpc/displaySharingPage";
|
|
7
11
|
import type {
|
|
8
12
|
DisplayEmbeddedWalletParamsType,
|
|
9
13
|
DisplayEmbeddedWalletResultType,
|
|
@@ -16,6 +20,7 @@ import type {
|
|
|
16
20
|
PrepareSsoParamsType,
|
|
17
21
|
PrepareSsoReturnType,
|
|
18
22
|
} from "./rpc/sso";
|
|
23
|
+
import type { UserReferralStatusType } from "./rpc/userReferralStatus";
|
|
19
24
|
import type { WalletStatusReturnType } from "./rpc/walletStatus";
|
|
20
25
|
|
|
21
26
|
/**
|
|
@@ -38,7 +43,7 @@ import type { WalletStatusReturnType } from "./rpc/walletStatus";
|
|
|
38
43
|
* - Response Type: stream (emits updates when wallet status changes)
|
|
39
44
|
*
|
|
40
45
|
* #### frak_displayModal
|
|
41
|
-
* - Params: [requests: {@link ModalRpcStepsInput}, metadata?: {@link ModalRpcMetadata}, configMetadata: {@link FrakWalletSdkConfig}["metadata"]]
|
|
46
|
+
* - Params: [requests: {@link ModalRpcStepsInput}, metadata?: {@link ModalRpcMetadata}, configMetadata: {@link FrakWalletSdkConfig}["metadata"], placement?: string]
|
|
42
47
|
* - Returns: {@link ModalRpcStepsResultType}
|
|
43
48
|
* - Response Type: promise (one-shot)
|
|
44
49
|
*
|
|
@@ -53,9 +58,14 @@ import type { WalletStatusReturnType } from "./rpc/walletStatus";
|
|
|
53
58
|
* - Response Type: promise (one-shot)
|
|
54
59
|
*
|
|
55
60
|
* #### frak_displayEmbeddedWallet
|
|
56
|
-
* - Params: [request: {@link DisplayEmbeddedWalletParamsType}, metadata: {@link FrakWalletSdkConfig}["metadata"]]
|
|
61
|
+
* - Params: [request: {@link DisplayEmbeddedWalletParamsType}, metadata: {@link FrakWalletSdkConfig}["metadata"], placement?: string]
|
|
57
62
|
* - Returns: {@link DisplayEmbeddedWalletResultType}
|
|
58
63
|
* - Response Type: promise (one-shot)
|
|
64
|
+
*
|
|
65
|
+
* #### frak_displaySharingPage
|
|
66
|
+
* - Params: [request: {@link DisplaySharingPageParamsType}, configMetadata: {@link FrakWalletSdkConfig}["metadata"], placement?: string]
|
|
67
|
+
* - Returns: {@link DisplaySharingPageResultType}
|
|
68
|
+
* - Response Type: promise (one-shot)
|
|
59
69
|
*/
|
|
60
70
|
export type IFrameRpcSchema = [
|
|
61
71
|
/**
|
|
@@ -77,6 +87,7 @@ export type IFrameRpcSchema = [
|
|
|
77
87
|
requests: ModalRpcStepsInput,
|
|
78
88
|
metadata: ModalRpcMetadata | undefined,
|
|
79
89
|
configMetadata: FrakWalletSdkConfig["metadata"],
|
|
90
|
+
placement?: string,
|
|
80
91
|
];
|
|
81
92
|
ReturnType: ModalRpcStepsResultType;
|
|
82
93
|
},
|
|
@@ -89,7 +100,7 @@ export type IFrameRpcSchema = [
|
|
|
89
100
|
Method: "frak_prepareSso";
|
|
90
101
|
Parameters: [
|
|
91
102
|
params: PrepareSsoParamsType,
|
|
92
|
-
name
|
|
103
|
+
name?: string,
|
|
93
104
|
customCss?: string,
|
|
94
105
|
];
|
|
95
106
|
ReturnType: PrepareSsoReturnType;
|
|
@@ -104,7 +115,7 @@ export type IFrameRpcSchema = [
|
|
|
104
115
|
Method: "frak_openSso";
|
|
105
116
|
Parameters: [
|
|
106
117
|
params: OpenSsoParamsType,
|
|
107
|
-
name
|
|
118
|
+
name?: string,
|
|
108
119
|
customCss?: string,
|
|
109
120
|
];
|
|
110
121
|
ReturnType: OpenSsoReturnType;
|
|
@@ -130,6 +141,7 @@ export type IFrameRpcSchema = [
|
|
|
130
141
|
Parameters: [
|
|
131
142
|
request: DisplayEmbeddedWalletParamsType,
|
|
132
143
|
metadata: FrakWalletSdkConfig["metadata"],
|
|
144
|
+
placement?: string,
|
|
133
145
|
];
|
|
134
146
|
ReturnType: DisplayEmbeddedWalletResultType;
|
|
135
147
|
},
|
|
@@ -137,7 +149,7 @@ export type IFrameRpcSchema = [
|
|
|
137
149
|
* Method to send interactions (arrival, sharing, custom events)
|
|
138
150
|
* Fire-and-forget method - no return value expected
|
|
139
151
|
* merchantId is resolved from context
|
|
140
|
-
* clientId is passed via metadata as safeguard against
|
|
152
|
+
* clientId is passed via metadata as safeguard against race conditions
|
|
141
153
|
*/
|
|
142
154
|
{
|
|
143
155
|
Method: "frak_sendInteraction";
|
|
@@ -147,4 +159,29 @@ export type IFrameRpcSchema = [
|
|
|
147
159
|
];
|
|
148
160
|
ReturnType: undefined;
|
|
149
161
|
},
|
|
162
|
+
/**
|
|
163
|
+
* Method to get the current user's referral status on this merchant.
|
|
164
|
+
* Returns whether the user was referred (has a referral link as referee).
|
|
165
|
+
* Returns null when the user's identity cannot be resolved.
|
|
166
|
+
* This is a one-shot request.
|
|
167
|
+
*/
|
|
168
|
+
{
|
|
169
|
+
Method: "frak_getUserReferralStatus";
|
|
170
|
+
Parameters?: undefined;
|
|
171
|
+
ReturnType: UserReferralStatusType | null;
|
|
172
|
+
},
|
|
173
|
+
/**
|
|
174
|
+
* Method to display a sharing page with product info and sharing buttons
|
|
175
|
+
* Resolves on first user action (share/copy) but the page stays visible
|
|
176
|
+
* This is a one-shot request
|
|
177
|
+
*/
|
|
178
|
+
{
|
|
179
|
+
Method: "frak_displaySharingPage";
|
|
180
|
+
Parameters: [
|
|
181
|
+
request: DisplaySharingPageParamsType,
|
|
182
|
+
configMetadata: FrakWalletSdkConfig["metadata"],
|
|
183
|
+
placement?: string,
|
|
184
|
+
];
|
|
185
|
+
ReturnType: DisplaySharingPageResultType;
|
|
186
|
+
},
|
|
150
187
|
];
|
|
@@ -15,13 +15,13 @@ describe("getBackendUrl", () => {
|
|
|
15
15
|
describe("with explicit walletUrl", () => {
|
|
16
16
|
test("should return localhost backend for localhost:3000", () => {
|
|
17
17
|
expect(getBackendUrl("https://localhost:3000")).toBe(
|
|
18
|
-
"
|
|
18
|
+
"https://localhost:3030"
|
|
19
19
|
);
|
|
20
20
|
});
|
|
21
21
|
|
|
22
22
|
test("should return localhost backend for localhost:3010", () => {
|
|
23
23
|
expect(getBackendUrl("https://localhost:3010")).toBe(
|
|
24
|
-
"
|
|
24
|
+
"https://localhost:3030"
|
|
25
25
|
);
|
|
26
26
|
});
|
|
27
27
|
|
package/src/utils/backendUrl.ts
CHANGED
|
@@ -19,7 +19,7 @@ function isLocalDevelopment(walletUrl: string): boolean {
|
|
|
19
19
|
*/
|
|
20
20
|
function deriveBackendUrl(walletUrl: string): string {
|
|
21
21
|
if (isLocalDevelopment(walletUrl)) {
|
|
22
|
-
return "
|
|
22
|
+
return "https://localhost:3030";
|
|
23
23
|
}
|
|
24
24
|
// Dev environment
|
|
25
25
|
if (
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { describe, expect, it } from "../../../tests/vitest-fixtures";
|
|
2
|
+
import { LruMap } from "./lruMap";
|
|
3
|
+
|
|
4
|
+
describe("LruMap", () => {
|
|
5
|
+
it("should store and retrieve values", () => {
|
|
6
|
+
const map = new LruMap<number>(3);
|
|
7
|
+
map.set("a", 1);
|
|
8
|
+
map.set("b", 2);
|
|
9
|
+
|
|
10
|
+
expect(map.get("a")).toBe(1);
|
|
11
|
+
expect(map.get("b")).toBe(2);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("should evict least recently used when exceeding max size", () => {
|
|
15
|
+
const map = new LruMap<number>(2);
|
|
16
|
+
map.set("a", 1);
|
|
17
|
+
map.set("b", 2);
|
|
18
|
+
map.set("c", 3); // Should evict "a"
|
|
19
|
+
|
|
20
|
+
expect(map.get("a")).toBeUndefined();
|
|
21
|
+
expect(map.get("b")).toBe(2);
|
|
22
|
+
expect(map.get("c")).toBe(3);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("should promote accessed keys to most recently used", () => {
|
|
26
|
+
const map = new LruMap<number>(2);
|
|
27
|
+
map.set("a", 1);
|
|
28
|
+
map.set("b", 2);
|
|
29
|
+
|
|
30
|
+
// Access "a" to promote it
|
|
31
|
+
map.get("a");
|
|
32
|
+
|
|
33
|
+
// "b" is now least recently used, should be evicted
|
|
34
|
+
map.set("c", 3);
|
|
35
|
+
|
|
36
|
+
expect(map.get("a")).toBe(1);
|
|
37
|
+
expect(map.get("b")).toBeUndefined();
|
|
38
|
+
expect(map.get("c")).toBe(3);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("should overwrite existing keys without increasing size", () => {
|
|
42
|
+
const map = new LruMap<number>(2);
|
|
43
|
+
map.set("a", 1);
|
|
44
|
+
map.set("b", 2);
|
|
45
|
+
map.set("a", 10); // Overwrite, not a new entry
|
|
46
|
+
|
|
47
|
+
expect(map.size).toBe(2);
|
|
48
|
+
expect(map.get("a")).toBe(10);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("should return undefined for missing keys", () => {
|
|
52
|
+
const map = new LruMap<number>(2);
|
|
53
|
+
expect(map.get("missing")).toBeUndefined();
|
|
54
|
+
});
|
|
55
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Map with a LRU (Least Recently Used) eviction policy.
|
|
3
|
+
*
|
|
4
|
+
* When the map exceeds `maxSize`, the least recently accessed entry is removed.
|
|
5
|
+
* Accessing a key via `get()` promotes it to "most recently used".
|
|
6
|
+
*
|
|
7
|
+
* Adapted from viem's LruMap utility.
|
|
8
|
+
* @link https://en.wikipedia.org/wiki/Cache_replacement_policies#LRU
|
|
9
|
+
*/
|
|
10
|
+
export class LruMap<TValue = unknown> extends Map<string, TValue> {
|
|
11
|
+
maxSize: number;
|
|
12
|
+
|
|
13
|
+
constructor(size: number) {
|
|
14
|
+
super();
|
|
15
|
+
this.maxSize = size;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
override get(key: string) {
|
|
19
|
+
const value = super.get(key);
|
|
20
|
+
if (super.has(key)) {
|
|
21
|
+
// Move to end (most recently used)
|
|
22
|
+
super.delete(key);
|
|
23
|
+
super.set(key, value as TValue);
|
|
24
|
+
}
|
|
25
|
+
return value;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
override set(key: string, value: TValue) {
|
|
29
|
+
if (super.has(key)) super.delete(key);
|
|
30
|
+
super.set(key, value);
|
|
31
|
+
// Evict least recently used if over capacity
|
|
32
|
+
if (this.maxSize && this.size > this.maxSize) {
|
|
33
|
+
const firstKey = super.keys().next().value;
|
|
34
|
+
if (firstKey !== undefined) super.delete(firstKey);
|
|
35
|
+
}
|
|
36
|
+
return this;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import {
|
|
2
|
+
afterEach,
|
|
3
|
+
beforeEach,
|
|
4
|
+
describe,
|
|
5
|
+
expect,
|
|
6
|
+
it,
|
|
7
|
+
vi,
|
|
8
|
+
} from "../../../tests/vitest-fixtures";
|
|
9
|
+
import { clearAllCache, withCache } from "./withCache";
|
|
10
|
+
|
|
11
|
+
describe("withCache", () => {
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
clearAllCache();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
vi.restoreAllMocks();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
describe("caching behavior", () => {
|
|
21
|
+
it("should call fn on first invocation", async () => {
|
|
22
|
+
const fn = vi.fn().mockResolvedValue("result");
|
|
23
|
+
|
|
24
|
+
const result = await withCache(fn, {
|
|
25
|
+
cacheKey: "test-key",
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
expect(fn).toHaveBeenCalledOnce();
|
|
29
|
+
expect(result).toBe("result");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("should return cached result on subsequent calls within TTL", async () => {
|
|
33
|
+
const fn = vi.fn().mockResolvedValue("result");
|
|
34
|
+
|
|
35
|
+
await withCache(fn, {
|
|
36
|
+
cacheKey: "test-key",
|
|
37
|
+
cacheTime: 10_000,
|
|
38
|
+
});
|
|
39
|
+
const result = await withCache(fn, {
|
|
40
|
+
cacheKey: "test-key",
|
|
41
|
+
cacheTime: 10_000,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
expect(fn).toHaveBeenCalledOnce();
|
|
45
|
+
expect(result).toBe("result");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("should re-fetch after cache expires", async () => {
|
|
49
|
+
vi.useFakeTimers();
|
|
50
|
+
|
|
51
|
+
const fn = vi
|
|
52
|
+
.fn()
|
|
53
|
+
.mockResolvedValueOnce("first")
|
|
54
|
+
.mockResolvedValueOnce("second");
|
|
55
|
+
|
|
56
|
+
const first = await withCache(fn, {
|
|
57
|
+
cacheKey: "test-key",
|
|
58
|
+
cacheTime: 100,
|
|
59
|
+
});
|
|
60
|
+
expect(first).toBe("first");
|
|
61
|
+
|
|
62
|
+
// Advance past TTL
|
|
63
|
+
vi.advanceTimersByTime(200);
|
|
64
|
+
|
|
65
|
+
const second = await withCache(fn, {
|
|
66
|
+
cacheKey: "test-key",
|
|
67
|
+
cacheTime: 100,
|
|
68
|
+
});
|
|
69
|
+
expect(second).toBe("second");
|
|
70
|
+
expect(fn).toHaveBeenCalledTimes(2);
|
|
71
|
+
|
|
72
|
+
vi.useRealTimers();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("should not cache when cacheTime is 0", async () => {
|
|
76
|
+
const fn = vi.fn().mockResolvedValue("result");
|
|
77
|
+
|
|
78
|
+
await withCache(fn, { cacheKey: "test-key", cacheTime: 0 });
|
|
79
|
+
await withCache(fn, { cacheKey: "test-key", cacheTime: 0 });
|
|
80
|
+
|
|
81
|
+
expect(fn).toHaveBeenCalledTimes(2);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("should use different caches for different keys", async () => {
|
|
85
|
+
const fnA = vi.fn().mockResolvedValue("a");
|
|
86
|
+
const fnB = vi.fn().mockResolvedValue("b");
|
|
87
|
+
|
|
88
|
+
const a = await withCache(fnA, { cacheKey: "key-a" });
|
|
89
|
+
const b = await withCache(fnB, { cacheKey: "key-b" });
|
|
90
|
+
|
|
91
|
+
expect(a).toBe("a");
|
|
92
|
+
expect(b).toBe("b");
|
|
93
|
+
expect(fnA).toHaveBeenCalledOnce();
|
|
94
|
+
expect(fnB).toHaveBeenCalledOnce();
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe("deduplication", () => {
|
|
99
|
+
it("should deduplicate concurrent calls with the same key", async () => {
|
|
100
|
+
let resolvePromise: (value: string) => void;
|
|
101
|
+
const fn = vi.fn().mockImplementation(
|
|
102
|
+
() =>
|
|
103
|
+
new Promise<string>((resolve) => {
|
|
104
|
+
resolvePromise = resolve;
|
|
105
|
+
})
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
const promise1 = withCache(fn, { cacheKey: "dedup-key" });
|
|
109
|
+
const promise2 = withCache(fn, { cacheKey: "dedup-key" });
|
|
110
|
+
|
|
111
|
+
// fn should only be called once
|
|
112
|
+
expect(fn).toHaveBeenCalledOnce();
|
|
113
|
+
|
|
114
|
+
// Both should resolve to the same value
|
|
115
|
+
resolvePromise!("shared");
|
|
116
|
+
const [result1, result2] = await Promise.all([promise1, promise2]);
|
|
117
|
+
expect(result1).toBe("shared");
|
|
118
|
+
expect(result2).toBe("shared");
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
describe("error handling", () => {
|
|
123
|
+
it("should propagate errors from fn", async () => {
|
|
124
|
+
const fn = vi.fn().mockRejectedValue(new Error("fetch failed"));
|
|
125
|
+
|
|
126
|
+
await expect(
|
|
127
|
+
withCache(fn, { cacheKey: "error-key" })
|
|
128
|
+
).rejects.toThrow("fetch failed");
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("should not cache errors — subsequent call retries", async () => {
|
|
132
|
+
const fn = vi
|
|
133
|
+
.fn()
|
|
134
|
+
.mockRejectedValueOnce(new Error("fail"))
|
|
135
|
+
.mockResolvedValueOnce("recovered");
|
|
136
|
+
|
|
137
|
+
await expect(
|
|
138
|
+
withCache(fn, { cacheKey: "retry-key" })
|
|
139
|
+
).rejects.toThrow("fail");
|
|
140
|
+
|
|
141
|
+
const result = await withCache(fn, { cacheKey: "retry-key" });
|
|
142
|
+
expect(result).toBe("recovered");
|
|
143
|
+
expect(fn).toHaveBeenCalledTimes(2);
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
describe("clearAllCache", () => {
|
|
148
|
+
it("should clear all cached data", async () => {
|
|
149
|
+
const fn = vi
|
|
150
|
+
.fn()
|
|
151
|
+
.mockResolvedValueOnce("first")
|
|
152
|
+
.mockResolvedValueOnce("second");
|
|
153
|
+
|
|
154
|
+
await withCache(fn, { cacheKey: "clear-key" });
|
|
155
|
+
clearAllCache();
|
|
156
|
+
const result = await withCache(fn, { cacheKey: "clear-key" });
|
|
157
|
+
|
|
158
|
+
expect(result).toBe("second");
|
|
159
|
+
expect(fn).toHaveBeenCalledTimes(2);
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
});
|