Skip to content

Commit c8f84db

Browse files
feat: [add syncInit method] (FF-2431) (#81)
* feat: [Add syncInit method] (FF-2431) * add correct imports to readme * docs * fix lint imports * offlineSync * static suffix * offlineInit * no docs * clarify readme * add table of options to readme
1 parent b8ceea1 commit c8f84db

File tree

5 files changed

+203
-15
lines changed

5 files changed

+203
-15
lines changed

README.md

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,6 @@ import { init } from "@eppo/js-client-sdk";
3333
await init({ apiKey: "<SDK-KEY-FROM-DASHBOARD>" });
3434
```
3535

36-
3736
#### Assign anywhere
3837

3938
```javascript
@@ -85,7 +84,35 @@ The `init` function accepts the following optional configuration arguments.
8584
| **`throwOnFailedInitialization`** | boolean | Throw an error (reject the promise) if unable to fetch initial configurations during initialization. | `true` |
8685
| **`numPollRequestRetries`** | number | If polling for updated configurations after initialization, the number of additional times a request will be attempted before giving up. Subsequent attempts are done using an exponential backoff. | `7` |
8786

87+
## Off-line initialization
88+
89+
The SDK supports off-line initialization if you want to initialize the SDK with a configuration from your server SDK or other external process. In this mode the SDK will not attempt to fetch a configuration from Eppo's CDN, instead only using the provided values.
8890

91+
This function is synchronous and ready to handle assignments after it returns.
92+
93+
```javascript
94+
import { offlineInit, Flag, ObfuscatedFlag } from "@eppo/js-client-sdk";
95+
96+
// configuration from your server SDK
97+
const configurationJsonString: string = getConfigurationFromServer();
98+
// The configuration will be not-obfuscated from your server SDK. If you have obfuscated flag values, you can use the `ObfuscatedFlag` type.
99+
const flagsConfiguration: Record<string, Flag | ObfuscatedFlag> = JSON.parse(configurationJsonString);
100+
101+
offlineInit({
102+
flagsConfiguration,
103+
// If you have obfuscated flag values, you can use the `ObfuscatedFlag` type.
104+
isObfuscated: true,
105+
});
106+
```
107+
108+
The `offlineInit` function accepts the following optional configuration arguments.
109+
110+
| Option | Type | Description | Default |
111+
| ------ | ----- | ----- | ----- |
112+
| **`assignmentLogger`** | [IAssignmentLogger](https://github.com/Eppo-exp/js-client-sdk-common/blob/75c2ea1d91101d579138d07d46fca4c6ea4aafaf/src/assignment-logger.ts#L55-L62) | A callback that sends each assignment to your data warehouse. Required only for experiment analysis. See [example](#assignment-logger) below. | `null` |
113+
| **`flagsConfiguration`** | Record<string, Flag \| ObfuscatedFlag> | The flags configuration to use for the SDK. | `null` |
114+
| **`isObfuscated`** | boolean | Whether the flag values are obfuscated. | `false` |
115+
| **`throwOnFailedInitialization`** | boolean | Throw an error if an error occurs during initialization. | `true` |
89116

90117
## Assignment logger
91118

@@ -119,6 +146,3 @@ Eppo's SDKs are built for simplicity, speed and reliability. Flag configurations
119146
## React
120147

121148
Visit the [Eppo docs](https://docs.geteppo.com/sdks/client-sdks/javascript#usage-in-react) for best practices when using this SDK within a React context.
122-
123-
124-

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@eppo/js-client-sdk",
3-
"version": "3.1.5",
3+
"version": "3.2.0",
44
"description": "Eppo SDK for client-side JavaScript applications",
55
"main": "dist/index.js",
66
"files": [

src/cache/assignment-cache-factory.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,19 +8,25 @@ import { LocalStorageAssignmentCache } from './local-storage-assignment-cache';
88
import SimpleAssignmentCache from './simple-assignment-cache';
99

1010
export function assignmentCacheFactory({
11+
forceMemoryOnly = false,
1112
chromeStorage,
1213
storageKeySuffix,
1314
}: {
15+
forceMemoryOnly?: boolean;
1416
storageKeySuffix: string;
1517
chromeStorage?: chrome.storage.StorageArea;
1618
}): AssignmentCache {
17-
const hasLocalStorage = hasWindowLocalStorage();
1819
const simpleCache = new SimpleAssignmentCache();
20+
21+
if (forceMemoryOnly) {
22+
return simpleCache;
23+
}
24+
1925
if (chromeStorage) {
2026
const chromeStorageCache = new ChromeStorageAssignmentCache(chromeStorage);
2127
return new HybridAssignmentCache(simpleCache, chromeStorageCache);
2228
} else {
23-
if (hasLocalStorage) {
29+
if (hasWindowLocalStorage()) {
2430
const localStorageCache = new LocalStorageAssignmentCache(storageKeySuffix);
2531
return new HybridAssignmentCache(simpleCache, localStorageCache);
2632
} else {

src/index.spec.ts

Lines changed: 94 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import {
2828
import { ServingStoreUpdateStrategy } from './isolatable-hybrid.store';
2929

3030
import {
31+
offlineInit,
3132
IAssignmentLogger,
3233
IEppoClient,
3334
getInstance,
@@ -54,7 +55,64 @@ const obfuscatedFlagKey = md5Hash(flagKey);
5455
const allocationKey = 'traffic-split';
5556
const obfuscatedAllocationKey = base64Encode(allocationKey);
5657

57-
const mockUfcFlagConfig: Flag = {
58+
const mockNotObfuscatedFlagConfig: Flag = {
59+
key: flagKey,
60+
enabled: true,
61+
variationType: VariationType.STRING,
62+
variations: {
63+
['control']: {
64+
key: 'control',
65+
value: 'control',
66+
},
67+
['variant-1']: {
68+
key: 'variant-1',
69+
value: 'variant-1',
70+
},
71+
['variant-2']: {
72+
key: 'variant-2',
73+
value: 'variant-2',
74+
},
75+
},
76+
allocations: [
77+
{
78+
key: obfuscatedAllocationKey,
79+
rules: [],
80+
splits: [
81+
{
82+
variationKey: 'control',
83+
shards: [
84+
{
85+
salt: 'some-salt',
86+
ranges: [{ start: 0, end: 3400 }],
87+
},
88+
],
89+
},
90+
{
91+
variationKey: 'variant-1',
92+
shards: [
93+
{
94+
salt: 'some-salt',
95+
ranges: [{ start: 3400, end: 6700 }],
96+
},
97+
],
98+
},
99+
{
100+
variationKey: 'variant-2',
101+
shards: [
102+
{
103+
salt: 'some-salt',
104+
ranges: [{ start: 6700, end: 10000 }],
105+
},
106+
],
107+
},
108+
],
109+
doLog: true,
110+
},
111+
],
112+
totalShards: 10000,
113+
};
114+
115+
const mockObfuscatedUfcFlagConfig: Flag = {
58116
key: obfuscatedFlagKey,
59117
enabled: true,
60118
variationType: VariationType.STRING,
@@ -163,7 +221,7 @@ describe('EppoJSClient E2E test', () => {
163221
throw new Error('Unexpected key ' + key);
164222
}
165223

166-
return mockUfcFlagConfig;
224+
return mockObfuscatedUfcFlagConfig;
167225
});
168226

169227
const subjectAttributes = { foo: 3 };
@@ -194,7 +252,7 @@ describe('EppoJSClient E2E test', () => {
194252
if (key !== obfuscatedFlagKey) {
195253
throw new Error('Unexpected key ' + key);
196254
}
197-
return mockUfcFlagConfig;
255+
return mockObfuscatedUfcFlagConfig;
198256
});
199257
const subjectAttributes = { foo: 3 };
200258
globalClient.setLogger(mockLogger);
@@ -215,10 +273,10 @@ describe('EppoJSClient E2E test', () => {
215273

216274
// Modified flag with a single rule.
217275
return {
218-
...mockUfcFlagConfig,
276+
...mockObfuscatedUfcFlagConfig,
219277
allocations: [
220278
{
221-
...mockUfcFlagConfig.allocations[0],
279+
...mockObfuscatedUfcFlagConfig.allocations[0],
222280
rules: [
223281
{
224282
conditions: [
@@ -306,14 +364,42 @@ describe('EppoJSClient E2E test', () => {
306364
});
307365
});
308366

367+
describe('sync init', () => {
368+
it('initializes with flags in obfuscated mode', () => {
369+
const client = offlineInit({
370+
isObfuscated: true,
371+
flagsConfiguration: {
372+
[obfuscatedFlagKey]: mockObfuscatedUfcFlagConfig,
373+
},
374+
});
375+
376+
expect(client.getStringAssignment(flagKey, 'subject-10', {}, 'default-value')).toEqual(
377+
'variant-1',
378+
);
379+
});
380+
381+
it('initializes with flags in not-obfuscated mode', () => {
382+
const client = offlineInit({
383+
isObfuscated: false,
384+
flagsConfiguration: {
385+
[flagKey]: mockNotObfuscatedFlagConfig,
386+
},
387+
});
388+
389+
expect(client.getStringAssignment(flagKey, 'subject-10', {}, 'default-value')).toEqual(
390+
'variant-1',
391+
);
392+
});
393+
});
394+
309395
describe('initialization options', () => {
310396
let mockLogger: IAssignmentLogger;
311397
let returnUfc = readMockUfcResponse; // function so it can be overridden per-test
312398

313399
const maxRetryDelay = POLL_INTERVAL_MS * POLL_JITTER_PCT;
314400
const mockConfigResponse = {
315401
flags: {
316-
[obfuscatedFlagKey]: mockUfcFlagConfig,
402+
[obfuscatedFlagKey]: mockObfuscatedUfcFlagConfig,
317403
},
318404
} as unknown as Record<'flags', Record<string, Flag>>;
319405

@@ -542,7 +628,7 @@ describe('initialization options', () => {
542628
},
543629
async getEntries() {
544630
return {
545-
'old-key': mockUfcFlagConfig,
631+
'old-key': mockObfuscatedUfcFlagConfig,
546632
};
547633
},
548634
async setEntries(entries) {
@@ -746,7 +832,7 @@ describe('initialization options', () => {
746832
json: () =>
747833
Promise.resolve({
748834
flags: {
749-
[md5Hash(flagKey)]: mockUfcFlagConfig,
835+
[md5Hash(flagKey)]: mockObfuscatedUfcFlagConfig,
750836
},
751837
}),
752838
});

src/index.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
Flag,
88
IAsyncStore,
99
AttributeType,
10+
ObfuscatedFlag,
1011
ApiEndpoints,
1112
} from '@eppo/js-client-sdk-common';
1213

@@ -111,12 +112,24 @@ export interface IClientConfig {
111112
persistentStore?: IAsyncStore<Flag>;
112113
}
113114

115+
export interface IClientConfigSync {
116+
flagsConfiguration: Record<string, Flag | ObfuscatedFlag>;
117+
118+
assignmentLogger?: IAssignmentLogger;
119+
120+
isObfuscated?: boolean;
121+
122+
throwOnFailedInitialization?: boolean;
123+
}
124+
114125
// Export the common types and classes from the SDK.
115126
export {
116127
IAssignmentLogger,
117128
IAssignmentEvent,
118129
IEppoClient,
119130
IAsyncStore,
131+
Flag,
132+
ObfuscatedFlag,
120133
} from '@eppo/js-client-sdk-common';
121134
export { ChromeStorageEngine } from './chrome-storage-engine';
122135

@@ -210,6 +223,63 @@ export function buildStorageKeySuffix(apiKey: string): string {
210223
return apiKey.replace(/\W/g, '').substring(0, 8);
211224
}
212225

226+
/**
227+
* Initializes the Eppo client with configuration parameters.
228+
*
229+
* The purpose is for use-cases where the configuration is available from an external process
230+
* that can bootstrap the SDK.
231+
*
232+
* This method should be called once on application startup.
233+
*
234+
* @param config - client configuration
235+
* @returns a singleton client instance
236+
* @public
237+
*/
238+
export function offlineInit(config: IClientConfigSync): IEppoClient {
239+
const isObfuscated = config.isObfuscated ?? false;
240+
const throwOnFailedInitialization = config.throwOnFailedInitialization ?? true;
241+
242+
try {
243+
const memoryOnlyConfigurationStore = configurationStorageFactory({
244+
forceMemoryOnly: true,
245+
});
246+
memoryOnlyConfigurationStore.setEntries(config.flagsConfiguration);
247+
EppoJSClient.instance.setConfigurationStore(memoryOnlyConfigurationStore);
248+
249+
// Allow the caller to override the default obfuscated mode, which is false
250+
// since the purpose of this method is to bootstrap the SDK from an external source,
251+
// which is likely a server that has not-obfuscated flag values.
252+
EppoJSClient.instance.setIsObfuscated(isObfuscated);
253+
254+
if (config.assignmentLogger) {
255+
EppoJSClient.instance.setLogger(config.assignmentLogger);
256+
}
257+
258+
// There is no SDK key in the offline context.
259+
const storageKeySuffix = 'offline';
260+
261+
// As this is a synchronous initialization,
262+
// we are unable to call the async `init` method on the assignment cache
263+
// which loads the assignment cache from the browser's storage.
264+
// Therefore there is no purpose trying to use a persistent assignment cache.
265+
const assignmentCache = assignmentCacheFactory({
266+
storageKeySuffix,
267+
forceMemoryOnly: true,
268+
});
269+
EppoJSClient.instance.useCustomAssignmentCache(assignmentCache);
270+
} catch (error) {
271+
console.warn(
272+
'Eppo SDK encountered an error initializing, assignment calls will return the default value and not be logged',
273+
);
274+
if (throwOnFailedInitialization) {
275+
throw error;
276+
}
277+
}
278+
279+
EppoJSClient.initialized = true;
280+
return EppoJSClient.instance;
281+
}
282+
213283
/**
214284
* Initializes the Eppo client with configuration parameters.
215285
* This method should be called once on application startup.
@@ -226,6 +296,8 @@ export async function init(config: IClientConfig): Promise<IEppoClient> {
226296
instance.stopPolling();
227297
// Set up assignment logger and cache
228298
instance.setLogger(config.assignmentLogger);
299+
// Default to obfuscated mode when requesting configuration from the server.
300+
instance.setIsObfuscated(true);
229301

230302
const storageKeySuffix = buildStorageKeySuffix(apiKey);
231303

0 commit comments

Comments
 (0)