Skip to content

Commit

Permalink
feat: [add syncInit method] (FF-2431) (#81)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
leoromanovsky authored Jun 21, 2024
1 parent b8ceea1 commit c8f84db
Show file tree
Hide file tree
Showing 5 changed files with 203 additions and 15 deletions.
32 changes: 28 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,6 @@ import { init } from "@eppo/js-client-sdk";
await init({ apiKey: "<SDK-KEY-FROM-DASHBOARD>" });
```


#### Assign anywhere

```javascript
Expand Down Expand Up @@ -85,7 +84,35 @@ The `init` function accepts the following optional configuration arguments.
| **`throwOnFailedInitialization`** | boolean | Throw an error (reject the promise) if unable to fetch initial configurations during initialization. | `true` |
| **`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` |

## Off-line initialization

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.
This function is synchronous and ready to handle assignments after it returns.
```javascript
import { offlineInit, Flag, ObfuscatedFlag } from "@eppo/js-client-sdk";
// configuration from your server SDK
const configurationJsonString: string = getConfigurationFromServer();
// The configuration will be not-obfuscated from your server SDK. If you have obfuscated flag values, you can use the `ObfuscatedFlag` type.
const flagsConfiguration: Record<string, Flag | ObfuscatedFlag> = JSON.parse(configurationJsonString);
offlineInit({
flagsConfiguration,
// If you have obfuscated flag values, you can use the `ObfuscatedFlag` type.
isObfuscated: true,
});
```
The `offlineInit` function accepts the following optional configuration arguments.
| Option | Type | Description | Default |
| ------ | ----- | ----- | ----- |
| **`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` |
| **`flagsConfiguration`** | Record<string, Flag \| ObfuscatedFlag> | The flags configuration to use for the SDK. | `null` |
| **`isObfuscated`** | boolean | Whether the flag values are obfuscated. | `false` |
| **`throwOnFailedInitialization`** | boolean | Throw an error if an error occurs during initialization. | `true` |
## Assignment logger
Expand Down Expand Up @@ -119,6 +146,3 @@ Eppo's SDKs are built for simplicity, speed and reliability. Flag configurations
## React

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.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@eppo/js-client-sdk",
"version": "3.1.5",
"version": "3.2.0",
"description": "Eppo SDK for client-side JavaScript applications",
"main": "dist/index.js",
"files": [
Expand Down
10 changes: 8 additions & 2 deletions src/cache/assignment-cache-factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,19 +8,25 @@ import { LocalStorageAssignmentCache } from './local-storage-assignment-cache';
import SimpleAssignmentCache from './simple-assignment-cache';

export function assignmentCacheFactory({
forceMemoryOnly = false,
chromeStorage,
storageKeySuffix,
}: {
forceMemoryOnly?: boolean;
storageKeySuffix: string;
chromeStorage?: chrome.storage.StorageArea;
}): AssignmentCache {
const hasLocalStorage = hasWindowLocalStorage();
const simpleCache = new SimpleAssignmentCache();

if (forceMemoryOnly) {
return simpleCache;
}

if (chromeStorage) {
const chromeStorageCache = new ChromeStorageAssignmentCache(chromeStorage);
return new HybridAssignmentCache(simpleCache, chromeStorageCache);
} else {
if (hasLocalStorage) {
if (hasWindowLocalStorage()) {
const localStorageCache = new LocalStorageAssignmentCache(storageKeySuffix);
return new HybridAssignmentCache(simpleCache, localStorageCache);
} else {
Expand Down
102 changes: 94 additions & 8 deletions src/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
import { ServingStoreUpdateStrategy } from './isolatable-hybrid.store';

import {
offlineInit,
IAssignmentLogger,
IEppoClient,
getInstance,
Expand All @@ -54,7 +55,64 @@ const obfuscatedFlagKey = md5Hash(flagKey);
const allocationKey = 'traffic-split';
const obfuscatedAllocationKey = base64Encode(allocationKey);

const mockUfcFlagConfig: Flag = {
const mockNotObfuscatedFlagConfig: Flag = {
key: flagKey,
enabled: true,
variationType: VariationType.STRING,
variations: {
['control']: {
key: 'control',
value: 'control',
},
['variant-1']: {
key: 'variant-1',
value: 'variant-1',
},
['variant-2']: {
key: 'variant-2',
value: 'variant-2',
},
},
allocations: [
{
key: obfuscatedAllocationKey,
rules: [],
splits: [
{
variationKey: 'control',
shards: [
{
salt: 'some-salt',
ranges: [{ start: 0, end: 3400 }],
},
],
},
{
variationKey: 'variant-1',
shards: [
{
salt: 'some-salt',
ranges: [{ start: 3400, end: 6700 }],
},
],
},
{
variationKey: 'variant-2',
shards: [
{
salt: 'some-salt',
ranges: [{ start: 6700, end: 10000 }],
},
],
},
],
doLog: true,
},
],
totalShards: 10000,
};

const mockObfuscatedUfcFlagConfig: Flag = {
key: obfuscatedFlagKey,
enabled: true,
variationType: VariationType.STRING,
Expand Down Expand Up @@ -163,7 +221,7 @@ describe('EppoJSClient E2E test', () => {
throw new Error('Unexpected key ' + key);
}

return mockUfcFlagConfig;
return mockObfuscatedUfcFlagConfig;
});

const subjectAttributes = { foo: 3 };
Expand Down Expand Up @@ -194,7 +252,7 @@ describe('EppoJSClient E2E test', () => {
if (key !== obfuscatedFlagKey) {
throw new Error('Unexpected key ' + key);
}
return mockUfcFlagConfig;
return mockObfuscatedUfcFlagConfig;
});
const subjectAttributes = { foo: 3 };
globalClient.setLogger(mockLogger);
Expand All @@ -215,10 +273,10 @@ describe('EppoJSClient E2E test', () => {

// Modified flag with a single rule.
return {
...mockUfcFlagConfig,
...mockObfuscatedUfcFlagConfig,
allocations: [
{
...mockUfcFlagConfig.allocations[0],
...mockObfuscatedUfcFlagConfig.allocations[0],
rules: [
{
conditions: [
Expand Down Expand Up @@ -306,14 +364,42 @@ describe('EppoJSClient E2E test', () => {
});
});

describe('sync init', () => {
it('initializes with flags in obfuscated mode', () => {
const client = offlineInit({
isObfuscated: true,
flagsConfiguration: {
[obfuscatedFlagKey]: mockObfuscatedUfcFlagConfig,
},
});

expect(client.getStringAssignment(flagKey, 'subject-10', {}, 'default-value')).toEqual(
'variant-1',
);
});

it('initializes with flags in not-obfuscated mode', () => {
const client = offlineInit({
isObfuscated: false,
flagsConfiguration: {
[flagKey]: mockNotObfuscatedFlagConfig,
},
});

expect(client.getStringAssignment(flagKey, 'subject-10', {}, 'default-value')).toEqual(
'variant-1',
);
});
});

describe('initialization options', () => {
let mockLogger: IAssignmentLogger;
let returnUfc = readMockUfcResponse; // function so it can be overridden per-test

const maxRetryDelay = POLL_INTERVAL_MS * POLL_JITTER_PCT;
const mockConfigResponse = {
flags: {
[obfuscatedFlagKey]: mockUfcFlagConfig,
[obfuscatedFlagKey]: mockObfuscatedUfcFlagConfig,
},
} as unknown as Record<'flags', Record<string, Flag>>;

Expand Down Expand Up @@ -542,7 +628,7 @@ describe('initialization options', () => {
},
async getEntries() {
return {
'old-key': mockUfcFlagConfig,
'old-key': mockObfuscatedUfcFlagConfig,
};
},
async setEntries(entries) {
Expand Down Expand Up @@ -746,7 +832,7 @@ describe('initialization options', () => {
json: () =>
Promise.resolve({
flags: {
[md5Hash(flagKey)]: mockUfcFlagConfig,
[md5Hash(flagKey)]: mockObfuscatedUfcFlagConfig,
},
}),
});
Expand Down
72 changes: 72 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
Flag,
IAsyncStore,
AttributeType,
ObfuscatedFlag,
ApiEndpoints,
} from '@eppo/js-client-sdk-common';

Expand Down Expand Up @@ -111,12 +112,24 @@ export interface IClientConfig {
persistentStore?: IAsyncStore<Flag>;
}

export interface IClientConfigSync {
flagsConfiguration: Record<string, Flag | ObfuscatedFlag>;

assignmentLogger?: IAssignmentLogger;

isObfuscated?: boolean;

throwOnFailedInitialization?: boolean;
}

// Export the common types and classes from the SDK.
export {
IAssignmentLogger,
IAssignmentEvent,
IEppoClient,
IAsyncStore,
Flag,
ObfuscatedFlag,
} from '@eppo/js-client-sdk-common';
export { ChromeStorageEngine } from './chrome-storage-engine';

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

/**
* Initializes the Eppo client with configuration parameters.
*
* The purpose is for use-cases where the configuration is available from an external process
* that can bootstrap the SDK.
*
* This method should be called once on application startup.
*
* @param config - client configuration
* @returns a singleton client instance
* @public
*/
export function offlineInit(config: IClientConfigSync): IEppoClient {
const isObfuscated = config.isObfuscated ?? false;
const throwOnFailedInitialization = config.throwOnFailedInitialization ?? true;

try {
const memoryOnlyConfigurationStore = configurationStorageFactory({
forceMemoryOnly: true,
});
memoryOnlyConfigurationStore.setEntries(config.flagsConfiguration);
EppoJSClient.instance.setConfigurationStore(memoryOnlyConfigurationStore);

// Allow the caller to override the default obfuscated mode, which is false
// since the purpose of this method is to bootstrap the SDK from an external source,
// which is likely a server that has not-obfuscated flag values.
EppoJSClient.instance.setIsObfuscated(isObfuscated);

if (config.assignmentLogger) {
EppoJSClient.instance.setLogger(config.assignmentLogger);
}

// There is no SDK key in the offline context.
const storageKeySuffix = 'offline';

// As this is a synchronous initialization,
// we are unable to call the async `init` method on the assignment cache
// which loads the assignment cache from the browser's storage.
// Therefore there is no purpose trying to use a persistent assignment cache.
const assignmentCache = assignmentCacheFactory({
storageKeySuffix,
forceMemoryOnly: true,
});
EppoJSClient.instance.useCustomAssignmentCache(assignmentCache);
} catch (error) {
console.warn(
'Eppo SDK encountered an error initializing, assignment calls will return the default value and not be logged',
);
if (throwOnFailedInitialization) {
throw error;
}
}

EppoJSClient.initialized = true;
return EppoJSClient.instance;
}

/**
* Initializes the Eppo client with configuration parameters.
* This method should be called once on application startup.
Expand All @@ -226,6 +296,8 @@ export async function init(config: IClientConfig): Promise<IEppoClient> {
instance.stopPolling();
// Set up assignment logger and cache
instance.setLogger(config.assignmentLogger);
// Default to obfuscated mode when requesting configuration from the server.
instance.setIsObfuscated(true);

const storageKeySuffix = buildStorageKeySuffix(apiKey);

Expand Down

0 comments on commit c8f84db

Please sign in to comment.