Skip to content
Draft
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions docs/js-client-sdk.iclientconfigsync.configfetchedat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->

[Home](./index.md) &gt; [@eppo/js-client-sdk](./js-client-sdk.md) &gt; [IClientConfigSync](./js-client-sdk.iclientconfigsync.md) &gt; [configFetchedAt](./js-client-sdk.iclientconfigsync.configfetchedat.md)

## IClientConfigSync.configFetchedAt property

**Signature:**

```typescript
configFetchedAt?: string;
```
11 changes: 11 additions & 0 deletions docs/js-client-sdk.iclientconfigsync.configpublishedat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->

[Home](./index.md) &gt; [@eppo/js-client-sdk](./js-client-sdk.md) &gt; [IClientConfigSync](./js-client-sdk.iclientconfigsync.md) &gt; [configPublishedAt](./js-client-sdk.iclientconfigsync.configpublishedat.md)

## IClientConfigSync.configPublishedAt property

**Signature:**

```typescript
configPublishedAt?: string;
```
38 changes: 38 additions & 0 deletions docs/js-client-sdk.iclientconfigsync.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,44 @@ IBanditLogger
_(Optional)_


</td></tr>
<tr><td>

[configFetchedAt?](./js-client-sdk.iclientconfigsync.configfetchedat.md)


</td><td>


</td><td>

string


</td><td>

_(Optional)_


</td></tr>
<tr><td>

[configPublishedAt?](./js-client-sdk.iclientconfigsync.configpublishedat.md)


</td><td>


</td><td>

string


</td><td>

_(Optional)_


</td></tr>
<tr><td>

Expand Down
4 changes: 4 additions & 0 deletions js-client-sdk.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,10 @@ export interface IClientConfigSync {
// (undocumented)
banditLogger?: IBanditLogger;
// (undocumented)
configFetchedAt?: string;
// (undocumented)
configPublishedAt?: string;
// (undocumented)
enableOverrides?: boolean;
// (undocumented)
flagsConfiguration: Record<string, Flag | ObfuscatedFlag>;
Expand Down
6 changes: 5 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -62,5 +62,9 @@
"@types/chrome": "^0.0.313",
"@eppo/js-client-sdk-common": "4.15.1"
},
"packageManager": "[email protected]+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
"packageManager": "[email protected]+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e",
"volta": {
"node": "22.18.0",
"yarn": "1.22.22"
}
}
62 changes: 62 additions & 0 deletions src/configuration-factory.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,37 @@ describe('configurationStorageFactory', () => {
expect(result).toBeInstanceOf(HybridConfigurationStore);
});

it('is a HybridConfigurationStore with Web Cache API when hasWebCacheAPI is true', () => {
// Mock caches API
const mockCache = {
match: jest.fn(),
put: jest.fn(),
};
const mockCaches = {
open: jest.fn().mockResolvedValue(mockCache),
delete: jest.fn(),
};
global.caches = mockCaches;

const mockLocalStorage = {
clear: jest.fn(),
getItem: jest.fn(),
setItem: jest.fn(),
removeItem: jest.fn(),
key: jest.fn(),
length: 0,
};

const result = configurationStorageFactory(
{ hasWebCacheAPI: true },
{ windowLocalStorage: mockLocalStorage },
);
expect(result).toBeInstanceOf(HybridConfigurationStore);

// Clean up
delete global.caches;
});

it('is a HybridConfigurationStore with a LocalStorageBackedAsyncStore persistentStore when window local storage is available', () => {
const mockLocalStorage = {
clear: jest.fn(),
Expand All @@ -72,6 +103,37 @@ describe('configurationStorageFactory', () => {
expect(result).toBeInstanceOf(HybridConfigurationStore);
});

it('prefers Web Cache API over localStorage when both are available', () => {
// Mock caches API
const mockCache = {
match: jest.fn(),
put: jest.fn(),
};
const mockCaches = {
open: jest.fn().mockResolvedValue(mockCache),
delete: jest.fn(),
};
global.caches = mockCaches;

const mockLocalStorage = {
clear: jest.fn(),
getItem: jest.fn(),
setItem: jest.fn(),
removeItem: jest.fn(),
key: jest.fn(),
length: 0,
};

const result = configurationStorageFactory(
{ hasWebCacheAPI: true, hasWindowLocalStorage: true },
{ windowLocalStorage: mockLocalStorage },
);
expect(result).toBeInstanceOf(HybridConfigurationStore);

// Clean up
delete global.caches;
});

it('falls back to MemoryOnlyConfigurationStore when no persistence options are available', () => {
const result = configurationStorageFactory({});
expect(result).toBeInstanceOf(MemoryOnlyConfigurationStore);
Expand Down
33 changes: 33 additions & 0 deletions src/configuration-factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,16 @@

import ChromeStorageAsyncMap from './cache/chrome-storage-async-map';
import { ChromeStorageEngine } from './chrome-storage-engine';
import { ConfigurationAsyncStore } from './configuration-store';
import {
IsolatableHybridConfigurationStore,
ServingStoreUpdateStrategy,
} from './isolatable-hybrid.store';
import { LocalStorageEngine } from './local-storage-engine';
import { OVERRIDES_KEY } from './storage-key-constants';
import { StorageMigration } from './storage-migration';
import { StringValuedAsyncStore } from './string-valued.store';
import { WebCacheStorageEngine } from './web-cache-storage-engine';

export function precomputedFlagsStorageFactory(): IConfigurationStore<PrecomputedFlag> {
return new MemoryOnlyConfigurationStore();
Expand All @@ -33,13 +36,15 @@
maxAgeSeconds = 0,
servingStoreUpdateStrategy = 'always',
hasChromeStorage = false,
hasWebCacheAPI = false,
hasWindowLocalStorage = false,
persistentStore = undefined,
forceMemoryOnly = false,
}: {
maxAgeSeconds?: number;
servingStoreUpdateStrategy?: ServingStoreUpdateStrategy;
hasChromeStorage?: boolean;
hasWebCacheAPI?: boolean;
hasWindowLocalStorage?: boolean;
persistentStore?: IAsyncStore<Flag>;
forceMemoryOnly?: boolean;
Expand Down Expand Up @@ -73,6 +78,24 @@
new StringValuedAsyncStore<Flag>(chromeStorageEngine, maxAgeSeconds),
servingStoreUpdateStrategy,
);
} else if (hasWebCacheAPI) {
// Web Cache API is available and preferred for better storage limits
// Run migration from localStorage to Cache API only if localStorage exists
if (windowLocalStorage) {
const migration = new StorageMigration(windowLocalStorage);
if (!migration.isMigrationCompleted()) {
migration.migrate().catch((error) =>

Check warning on line 87 in src/configuration-factory.ts

View workflow job for this annotation

GitHub Actions / lint-test-sdk (18)

Replace `⏎··········console.warn('Storage·migration·failed:',·error),⏎········` with `·console.warn('Storage·migration·failed:',·error)`

Check warning on line 87 in src/configuration-factory.ts

View workflow job for this annotation

GitHub Actions / lint-test-sdk (23)

Replace `⏎··········console.warn('Storage·migration·failed:',·error),⏎········` with `·console.warn('Storage·migration·failed:',·error)`

Check warning on line 87 in src/configuration-factory.ts

View workflow job for this annotation

GitHub Actions / lint-test-sdk (22)

Replace `⏎··········console.warn('Storage·migration·failed:',·error),⏎········` with `·console.warn('Storage·migration·failed:',·error)`

Check warning on line 87 in src/configuration-factory.ts

View workflow job for this annotation

GitHub Actions / lint-test-sdk (20)

Replace `⏎··········console.warn('Storage·migration·failed:',·error),⏎········` with `·console.warn('Storage·migration·failed:',·error)`
console.warn('Storage migration failed:', error),
);
}
}

const webCacheEngine = new WebCacheStorageEngine(storageKeySuffix ?? '');
return new IsolatableHybridConfigurationStore(
new MemoryStore<Flag>(),
new ConfigurationAsyncStore<Flag>(webCacheEngine, maxAgeSeconds),
servingStoreUpdateStrategy,
);
} else if (hasWindowLocalStorage && windowLocalStorage) {
// window.localStorage is available, use it as a fallback
const localStorageEngine = new LocalStorageEngine(windowLocalStorage, storageKeySuffix ?? '');
Expand Down Expand Up @@ -135,3 +158,13 @@
export function localStorageIfAvailable(): Storage | undefined {
return hasWindowLocalStorage() ? window.localStorage : undefined;
}

/** Returns whether Web Cache API is available */
export function hasWebCacheAPI(): boolean {
try {
return typeof caches !== 'undefined' && typeof caches.open === 'function';
} catch {
// Some environments may throw when accessing caches
return false;
}
}
67 changes: 67 additions & 0 deletions src/configuration-store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { IAsyncStore } from '@eppo/js-client-sdk-common';

/**
* Simplified storage engine interface for configuration data with built-in timestamps.
* Unlike IStringStorageEngine, this doesn't require separate metadata storage since
* configuration responses include createdAt timestamps.
*/
export interface IConfigurationStorageEngine {
getContentsJsonString: () => Promise<string | null>;
setContentsJsonString: (configurationJsonString: string) => Promise<void>;
}

/**
* Configuration store that works with storage engines optimized for configuration data.
* Uses the configuration response's built-in createdAt timestamp for expiration logic
* instead of maintaining separate metadata.
*/
export class ConfigurationAsyncStore<T> implements IAsyncStore<T> {
private initialized = false;

public constructor(
private storageEngine: IConfigurationStorageEngine,
private cooldownSeconds = 0,
) {}

public isInitialized(): boolean {
return this.initialized;
}

public async isExpired(): Promise<boolean> {
if (!this.cooldownSeconds) {
return true;
}

try {
const contentsJsonString = await this.storageEngine.getContentsJsonString();
if (!contentsJsonString) {
return true;
}

const contents = JSON.parse(contentsJsonString);

Check warning on line 42 in src/configuration-store.ts

View workflow job for this annotation

GitHub Actions / lint-test-sdk (18)

Delete `Β·Β·Β·Β·Β·Β·`

Check warning on line 42 in src/configuration-store.ts

View workflow job for this annotation

GitHub Actions / lint-test-sdk (23)

Delete `Β·Β·Β·Β·Β·Β·`

Check warning on line 42 in src/configuration-store.ts

View workflow job for this annotation

GitHub Actions / lint-test-sdk (22)

Delete `Β·Β·Β·Β·Β·Β·`

Check warning on line 42 in src/configuration-store.ts

View workflow job for this annotation

GitHub Actions / lint-test-sdk (20)

Delete `Β·Β·Β·Β·Β·Β·`
// Check if this is a configuration response with createdAt
if (contents.createdAt) {
const createdAtMs = new Date(contents.createdAt).getTime();
return Date.now() - createdAtMs > this.cooldownSeconds * 1000;
}

// Fallback: if no createdAt, consider expired
return true;
} catch (error) {
console.warn('Failed to check expiration:', error);
return true;
}
}

public async entries(): Promise<Record<string, T>> {
const contentsJsonString = await this.storageEngine.getContentsJsonString();
return contentsJsonString ? JSON.parse(contentsJsonString) : {};
}

public async setEntries(entries: Record<string, T>): Promise<void> {
// Store the entire configuration response as-is
await this.storageEngine.setContentsJsonString(JSON.stringify(entries));
this.initialized = true;
}
}

Check warning on line 67 in src/configuration-store.ts

View workflow job for this annotation

GitHub Actions / lint-test-sdk (18)

Insert `⏎`

Check warning on line 67 in src/configuration-store.ts

View workflow job for this annotation

GitHub Actions / lint-test-sdk (23)

Insert `⏎`

Check warning on line 67 in src/configuration-store.ts

View workflow job for this annotation

GitHub Actions / lint-test-sdk (22)

Insert `⏎`

Check warning on line 67 in src/configuration-store.ts

View workflow job for this annotation

GitHub Actions / lint-test-sdk (20)

Insert `⏎`
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import {
chromeStorageIfAvailable,
configurationStorageFactory,
hasChromeStorage,
hasWebCacheAPI,
hasWindowLocalStorage,
localStorageIfAvailable,
overrideStorageFactory,
Expand Down Expand Up @@ -330,6 +331,7 @@ export class EppoJSClient extends EppoClient {
servingStoreUpdateStrategy: updateOnFetch,
persistentStore,
hasChromeStorage: hasChromeStorage(),
hasWebCacheAPI: hasWebCacheAPI(),
hasWindowLocalStorage: hasWindowLocalStorage(),
},
{
Expand Down
Loading
Loading