Skip to content

Commit c961b14

Browse files
Add support for chrome.storage backed configuration store. (#63)
* bump to commons 3.0.6; add LocalStorageBackedAsyncStore (FF-1980) * Add support for chrome.storage backed configuration store. * comment * chain * Attempt to provide a unit test for configuration factory.
1 parent fd3c3a7 commit c961b14

7 files changed

+253
-10
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
"devDependencies": {
3333
"@microsoft/api-documenter": "^7.23.9",
3434
"@microsoft/api-extractor": "^7.38.0",
35+
"@types/chrome": "^0.0.268",
3536
"@types/jest": "^29.5.11",
3637
"@types/md5": "^2.3.2",
3738
"@typescript-eslint/eslint-plugin": "^5.13.0",
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { ChromeStorageAsyncStore } from './chrome.configuration-store';
2+
3+
describe('ChromeStore', () => {
4+
const mockEntries: Record<string, string> = { key1: 'value1', key2: 'value2' };
5+
let chromeStore: ChromeStorageAsyncStore<string>;
6+
let extendedStorageLocal: chrome.storage.StorageArea;
7+
8+
beforeEach(() => {
9+
const get = jest.fn();
10+
const set = jest.fn();
11+
extendedStorageLocal = {
12+
set,
13+
get,
14+
clear: jest.fn(),
15+
remove: jest.fn(),
16+
getBytesInUse: jest.fn(),
17+
setAccessLevel: jest.fn(),
18+
onChanged: {
19+
addListener: jest.fn(),
20+
removeListener: jest.fn(),
21+
getRules: jest.fn(),
22+
hasListener: jest.fn(),
23+
removeRules: jest.fn(),
24+
addRules: jest.fn(),
25+
hasListeners: jest.fn(),
26+
},
27+
};
28+
29+
chromeStore = new ChromeStorageAsyncStore(extendedStorageLocal);
30+
});
31+
32+
afterEach(() => {
33+
jest.clearAllMocks();
34+
});
35+
36+
it('is always expired', async () => {
37+
expect(await chromeStore.isExpired()).toBe(true);
38+
});
39+
40+
it('should return null when no entries are found', async () => {
41+
(extendedStorageLocal.get as jest.Mock).mockImplementation(() => {
42+
return Promise.resolve({});
43+
});
44+
45+
const entries = await chromeStore.getEntries();
46+
expect(entries).toBeNull();
47+
});
48+
49+
it('should be initialized after setting entries', async () => {
50+
await chromeStore.setEntries(mockEntries);
51+
expect(chromeStore.isInitialized()).toBe(true);
52+
});
53+
54+
it('should get entries', async () => {
55+
(extendedStorageLocal.get as jest.Mock).mockImplementation(() => {
56+
return Promise.resolve({ ['eppo-configuration']: JSON.stringify(mockEntries) });
57+
});
58+
59+
const entries = await chromeStore.getEntries();
60+
expect(entries).toEqual(mockEntries);
61+
});
62+
});

src/chrome.configuration-store.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { IAsyncStore } from '@eppo/js-client-sdk-common';
2+
3+
export class ChromeStorageAsyncStore<T> implements IAsyncStore<T> {
4+
private chromeStorageKey = 'eppo-configuration';
5+
private _isInitialized = false;
6+
7+
constructor(private storageArea: chrome.storage.StorageArea) {}
8+
9+
public isInitialized(): boolean {
10+
return this._isInitialized;
11+
}
12+
13+
public isExpired(): Promise<boolean> {
14+
return Promise.resolve(true);
15+
}
16+
17+
public async getEntries(): Promise<Record<string, T> | null> {
18+
const configuration = await this.storageArea.get(this.chromeStorageKey);
19+
if (configuration?.[this.chromeStorageKey]) {
20+
return Promise.resolve(JSON.parse(configuration[this.chromeStorageKey]));
21+
}
22+
return Promise.resolve(null);
23+
}
24+
25+
public async setEntries(entries: Record<string, T>): Promise<void> {
26+
// chrome.storage.set takes a dictionary of key-value pairs,
27+
// so we need to pass it an object with a single property.
28+
// writes the entire configuration to a single location.
29+
await this.storageArea.set({ [this.chromeStorageKey]: JSON.stringify(entries) });
30+
this._isInitialized = true;
31+
return Promise.resolve();
32+
}
33+
}

src/configuration-factory.spec.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { MemoryOnlyConfigurationStore, HybridConfigurationStore } from '@eppo/js-client-sdk-common';
2+
3+
import { ChromeStorageAsyncStore } from './chrome.configuration-store';
4+
import { configurationStorageFactory } from './configuration-factory';
5+
import { LocalStorageBackedAsyncStore } from './local-storage';
6+
7+
describe('configurationStorageFactory', () => {
8+
afterEach(() => {
9+
jest.restoreAllMocks();
10+
});
11+
12+
it('forces a MemoryOnlyConfigurationStore', () => {
13+
const result = configurationStorageFactory({ forceMemoryOnly: true });
14+
expect(result).toBeInstanceOf(MemoryOnlyConfigurationStore);
15+
});
16+
17+
it('is a provided persistentStore', () => {
18+
const mockPersistentStore = {
19+
get: jest.fn(),
20+
set: jest.fn(),
21+
isInitialized: jest.fn().mockReturnValue(true),
22+
isExpired: jest.fn().mockReturnValue(false),
23+
getEntries: jest.fn().mockReturnValue({}),
24+
setEntries: jest.fn(),
25+
};
26+
const result = configurationStorageFactory({
27+
persistentStore: mockPersistentStore,
28+
});
29+
expect(result).toBeInstanceOf(HybridConfigurationStore);
30+
expect(result.persistentStore).toBe(mockPersistentStore);
31+
});
32+
33+
it('is a HybridConfigurationStore with a ChromeStorageAsyncStore persistentStore when chrome storage is available', () => {
34+
const mockChromeStorageLocal = {
35+
get: jest.fn(),
36+
set: jest.fn(),
37+
remove: jest.fn(),
38+
clear: jest.fn(),
39+
getBytesInUse: jest.fn(),
40+
setAccessLevel: jest.fn(),
41+
onChanged: {
42+
addListener: jest.fn(),
43+
removeListener: jest.fn(),
44+
hasListener: jest.fn(),
45+
dispatch: jest.fn(),
46+
getRules: jest.fn(),
47+
removeRules: jest.fn(),
48+
addRules: jest.fn(),
49+
hasListeners: jest.fn(),
50+
},
51+
};
52+
53+
const result = configurationStorageFactory(
54+
{ hasChromeStorage: true },
55+
{ chromeStorage: mockChromeStorageLocal },
56+
);
57+
expect(result).toBeInstanceOf(HybridConfigurationStore);
58+
expect(result.persistentStore).toBeInstanceOf(ChromeStorageAsyncStore);
59+
});
60+
61+
it('is a HybridConfigurationStore with a LocalStorageBackedAsyncStore persistentStore when window local storage is available', () => {
62+
const mockLocalStorage = {
63+
clear: jest.fn(),
64+
getItem: jest.fn(),
65+
setItem: jest.fn(),
66+
removeItem: jest.fn(),
67+
key: jest.fn(),
68+
length: 0,
69+
};
70+
71+
const result = configurationStorageFactory(
72+
{ hasWindowLocalStorage: true },
73+
{ windowLocalStorage: mockLocalStorage },
74+
);
75+
expect(result).toBeInstanceOf(HybridConfigurationStore);
76+
expect(result.persistentStore).toBeInstanceOf(LocalStorageBackedAsyncStore);
77+
});
78+
79+
it('falls back to MemoryOnlyConfigurationStore when no persistence options are available', () => {
80+
const result = configurationStorageFactory({});
81+
expect(result).toBeInstanceOf(MemoryOnlyConfigurationStore);
82+
});
83+
});

src/configuration-factory.ts

Lines changed: 32 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,27 +7,52 @@ import {
77
MemoryStore,
88
} from '@eppo/js-client-sdk-common';
99

10+
import { ChromeStorageAsyncStore } from './chrome.configuration-store';
1011
import { LocalStorageBackedAsyncStore } from './local-storage';
1112

1213
export function configurationStorageFactory(
13-
persistenceStore?: IAsyncStore<Flag>,
14-
forceMemoryOnly = false,
14+
{
15+
hasChromeStorage = false,
16+
hasWindowLocalStorage = false,
17+
persistentStore = undefined,
18+
forceMemoryOnly = false,
19+
}: {
20+
hasChromeStorage?: boolean;
21+
hasWindowLocalStorage?: boolean;
22+
persistentStore?: IAsyncStore<Flag>;
23+
forceMemoryOnly?: boolean;
24+
},
25+
{
26+
chromeStorage,
27+
windowLocalStorage,
28+
}: { chromeStorage?: chrome.storage.StorageArea; windowLocalStorage?: Storage } = {},
1529
): IConfigurationStore<Flag> {
1630
if (forceMemoryOnly) {
1731
return new MemoryOnlyConfigurationStore();
18-
} else if (persistenceStore) {
19-
return new HybridConfigurationStore(new MemoryStore<Flag>(), persistenceStore);
20-
} else if (hasWindowLocalStorage()) {
21-
// fallback to window.localStorage if available
32+
} else if (persistentStore) {
33+
return new HybridConfigurationStore(new MemoryStore<Flag>(), persistentStore);
34+
} else if (hasChromeStorage) {
35+
// Chrome storage is available, use it as a fallback
2236
return new HybridConfigurationStore(
2337
new MemoryStore<Flag>(),
24-
new LocalStorageBackedAsyncStore<Flag>(window.localStorage),
38+
new ChromeStorageAsyncStore<Flag>(chromeStorage),
39+
);
40+
} else if (hasWindowLocalStorage) {
41+
// window.localStorage is available, use it as a fallback
42+
return new HybridConfigurationStore(
43+
new MemoryStore<Flag>(),
44+
new LocalStorageBackedAsyncStore<Flag>(windowLocalStorage),
2545
);
2646
}
2747

48+
// No persistence store available, use memory only
2849
return new MemoryOnlyConfigurationStore();
2950
}
3051

52+
export function hasChromeStorage(): boolean {
53+
return typeof chrome !== 'undefined' && !!chrome.storage;
54+
}
55+
3156
export function hasWindowLocalStorage(): boolean {
3257
try {
3358
return typeof window !== 'undefined' && !!window.localStorage;

src/index.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,11 @@ import {
88
IAsyncStore,
99
} from '@eppo/js-client-sdk-common';
1010

11-
import { configurationStorageFactory } from './configuration-factory';
11+
import {
12+
configurationStorageFactory,
13+
hasChromeStorage,
14+
hasWindowLocalStorage,
15+
} from './configuration-factory';
1216
import { LocalStorageAssignmentCache } from './local-storage-assignment-cache';
1317
import { sdkName, sdkVersion } from './sdk-data';
1418

@@ -83,7 +87,7 @@ export interface IClientConfig {
8387
export { IAssignmentLogger, IAssignmentEvent, IEppoClient } from '@eppo/js-client-sdk-common';
8488

8589
// Instantiate the configuration store with memory-only implementation.
86-
const configurationStore = configurationStorageFactory(undefined, true);
90+
const configurationStore = configurationStorageFactory({ forceMemoryOnly: true });
8791

8892
/**
8993
* Client for assigning experiment variations.
@@ -169,7 +173,17 @@ export async function init(config: IClientConfig): Promise<IEppoClient> {
169173

170174
// Set the configuration store to the desired persistent store, if provided.
171175
// Otherwise the factory method will detect the current environment and instantiate the correct store.
172-
const configurationStore = configurationStorageFactory(config.persistentStore, false);
176+
const configurationStore = configurationStorageFactory(
177+
{
178+
persistentStore: config.persistentStore,
179+
hasChromeStorage: hasChromeStorage(),
180+
hasWindowLocalStorage: hasWindowLocalStorage(),
181+
},
182+
{
183+
chromeStorage: hasChromeStorage() && chrome.storage.local,
184+
windowLocalStorage: hasWindowLocalStorage() && window.localStorage,
185+
},
186+
);
173187
EppoJSClient.instance.setConfigurationStore(configurationStore);
174188

175189
const requestConfiguration: FlagConfigurationRequestParameters = {

yarn.lock

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -909,6 +909,14 @@
909909
dependencies:
910910
"@babel/types" "^7.3.0"
911911

912+
"@types/chrome@^0.0.268":
913+
version "0.0.268"
914+
resolved "https://registry.yarnpkg.com/@types/chrome/-/chrome-0.0.268.tgz#d5855546f30c83e181cadd77127a162c25b480d2"
915+
integrity sha512-7N1QH9buudSJ7sI8Pe4mBHJr5oZ48s0hcanI9w3wgijAlv1OZNUZve9JR4x42dn5lJ5Sm87V1JNfnoh10EnQlA==
916+
dependencies:
917+
"@types/filesystem" "*"
918+
"@types/har-format" "*"
919+
912920
"@types/eslint-scope@^3.7.3":
913921
version "3.7.4"
914922
resolved "https://registry.yarnpkg.com/@types/eslint-scope/-/eslint-scope-3.7.4.tgz#37fc1223f0786c39627068a12e94d6e6fc61de16"
@@ -935,13 +943,30 @@
935943
resolved "https://registry.yarnpkg.com/@types/estree/-/estree-0.0.51.tgz#cfd70924a25a3fd32b218e5e420e6897e1ac4f40"
936944
integrity sha512-CuPgU6f3eT/XgKKPqKd/gLZV1Xmvf1a2R5POBOGQa6uv82xpls89HU5zKeVoyR8XzHd1RGNOlQlvUe3CFkjWNQ==
937945

946+
"@types/filesystem@*":
947+
version "0.0.36"
948+
resolved "https://registry.yarnpkg.com/@types/filesystem/-/filesystem-0.0.36.tgz#7227c2d76bfed1b21819db310816c7821d303857"
949+
integrity sha512-vPDXOZuannb9FZdxgHnqSwAG/jvdGM8Wq+6N4D/d80z+D4HWH+bItqsZaVRQykAn6WEVeEkLm2oQigyHtgb0RA==
950+
dependencies:
951+
"@types/filewriter" "*"
952+
953+
"@types/filewriter@*":
954+
version "0.0.33"
955+
resolved "https://registry.yarnpkg.com/@types/filewriter/-/filewriter-0.0.33.tgz#d9d611db9d9cd99ae4e458de420eeb64ad604ea8"
956+
integrity sha512-xFU8ZXTw4gd358lb2jw25nxY9QAgqn2+bKKjKOYfNCzN4DKCFetK7sPtrlpg66Ywe3vWY9FNxprZawAh9wfJ3g==
957+
938958
"@types/graceful-fs@^4.1.3":
939959
version "4.1.5"
940960
resolved "https://registry.yarnpkg.com/@types/graceful-fs/-/graceful-fs-4.1.5.tgz#21ffba0d98da4350db64891f92a9e5db3cdb4e15"
941961
integrity sha512-anKkLmZZ+xm4p8JWBf4hElkM4XR+EZeA2M9BAkkTldmcyDY4mbdIJnRghDJH3Ov5ooY7/UAoENtmdMSkaAd7Cw==
942962
dependencies:
943963
"@types/node" "*"
944964

965+
"@types/har-format@*":
966+
version "1.2.15"
967+
resolved "https://registry.yarnpkg.com/@types/har-format/-/har-format-1.2.15.tgz#f352493638c2f89d706438a19a9eb300b493b506"
968+
integrity sha512-RpQH4rXLuvTXKR0zqHq3go0RVXYv/YVqv4TnPH95VbwUxZdQlK1EtcMvQvMpDngHbt13Csh9Z4qT9AbkiQH5BA==
969+
945970
"@types/istanbul-lib-coverage@*", "@types/istanbul-lib-coverage@^2.0.0", "@types/istanbul-lib-coverage@^2.0.1":
946971
version "2.0.4"
947972
resolved "https://registry.yarnpkg.com/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.4.tgz#8467d4b3c087805d63580480890791277ce35c44"

0 commit comments

Comments
 (0)