diff --git a/docs/js-client-sdk.iclientconfigsync.configfetchedat.md b/docs/js-client-sdk.iclientconfigsync.configfetchedat.md
new file mode 100644
index 0000000..780d523
--- /dev/null
+++ b/docs/js-client-sdk.iclientconfigsync.configfetchedat.md
@@ -0,0 +1,11 @@
+
+
+[Home](./index.md) > [@eppo/js-client-sdk](./js-client-sdk.md) > [IClientConfigSync](./js-client-sdk.iclientconfigsync.md) > [configFetchedAt](./js-client-sdk.iclientconfigsync.configfetchedat.md)
+
+## IClientConfigSync.configFetchedAt property
+
+**Signature:**
+
+```typescript
+configFetchedAt?: string;
+```
diff --git a/docs/js-client-sdk.iclientconfigsync.configpublishedat.md b/docs/js-client-sdk.iclientconfigsync.configpublishedat.md
new file mode 100644
index 0000000..b100f91
--- /dev/null
+++ b/docs/js-client-sdk.iclientconfigsync.configpublishedat.md
@@ -0,0 +1,11 @@
+
+
+[Home](./index.md) > [@eppo/js-client-sdk](./js-client-sdk.md) > [IClientConfigSync](./js-client-sdk.iclientconfigsync.md) > [configPublishedAt](./js-client-sdk.iclientconfigsync.configpublishedat.md)
+
+## IClientConfigSync.configPublishedAt property
+
+**Signature:**
+
+```typescript
+configPublishedAt?: string;
+```
diff --git a/docs/js-client-sdk.iclientconfigsync.md b/docs/js-client-sdk.iclientconfigsync.md
index 9b2eac1..92b81c2 100644
--- a/docs/js-client-sdk.iclientconfigsync.md
+++ b/docs/js-client-sdk.iclientconfigsync.md
@@ -72,6 +72,44 @@ IBanditLogger
_(Optional)_
+
+
diff --git a/js-client-sdk.api.md b/js-client-sdk.api.md
index 3b9af0e..05762c4 100644
--- a/js-client-sdk.api.md
+++ b/js-client-sdk.api.md
@@ -172,6 +172,10 @@ export interface IClientConfigSync {
// (undocumented)
banditLogger?: IBanditLogger;
// (undocumented)
+ configFetchedAt?: string;
+ // (undocumented)
+ configPublishedAt?: string;
+ // (undocumented)
enableOverrides?: boolean;
// (undocumented)
flagsConfiguration: Record;
diff --git a/package.json b/package.json
index 8990128..e259647 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@eppo/js-client-sdk",
- "version": "3.16.1",
+ "version": "3.17.0-alpha.1",
"description": "Eppo SDK for client-side JavaScript applications",
"main": "dist/index.js",
"files": [
@@ -62,5 +62,9 @@
"@types/chrome": "^0.0.313",
"@eppo/js-client-sdk-common": "4.15.1"
},
- "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
+ "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e",
+ "volta": {
+ "node": "22.18.0",
+ "yarn": "1.22.22"
+ }
}
diff --git a/src/configuration-factory.spec.ts b/src/configuration-factory.spec.ts
index fa50157..c5b3b61 100644
--- a/src/configuration-factory.spec.ts
+++ b/src/configuration-factory.spec.ts
@@ -55,6 +55,45 @@ 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(),
+ add: jest.fn(),
+ addAll: jest.fn(),
+ delete: jest.fn(),
+ keys: jest.fn(),
+ };
+ const mockCaches = {
+ open: jest.fn().mockResolvedValue(mockCache),
+ delete: jest.fn(),
+ has: jest.fn(),
+ keys: jest.fn(),
+ match: jest.fn(),
+ };
+ const originalCaches = (global as any).caches;
+ (global as any).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
+ (global as any).caches = originalCaches;
+ });
+
it('is a HybridConfigurationStore with a LocalStorageBackedAsyncStore persistentStore when window local storage is available', () => {
const mockLocalStorage = {
clear: jest.fn(),
@@ -72,6 +111,45 @@ 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(),
+ add: jest.fn(),
+ addAll: jest.fn(),
+ delete: jest.fn(),
+ keys: jest.fn(),
+ };
+ const mockCaches = {
+ open: jest.fn().mockResolvedValue(mockCache),
+ delete: jest.fn(),
+ has: jest.fn(),
+ keys: jest.fn(),
+ match: jest.fn(),
+ };
+ const originalCaches = (global as any).caches;
+ (global as any).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
+ (global as any).caches = originalCaches;
+ });
+
it('falls back to MemoryOnlyConfigurationStore when no persistence options are available', () => {
const result = configurationStorageFactory({});
expect(result).toBeInstanceOf(MemoryOnlyConfigurationStore);
diff --git a/src/configuration-factory.ts b/src/configuration-factory.ts
index bc41b7c..a543d04 100644
--- a/src/configuration-factory.ts
+++ b/src/configuration-factory.ts
@@ -12,13 +12,16 @@ import {
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 { MigrationManager } from './migrations';
import { OVERRIDES_KEY } from './storage-key-constants';
import { StringValuedAsyncStore } from './string-valued.store';
+import { WebCacheStorageEngine } from './web-cache-storage-engine';
export function precomputedFlagsStorageFactory(): IConfigurationStore {
return new MemoryOnlyConfigurationStore();
@@ -33,6 +36,7 @@ export function configurationStorageFactory(
maxAgeSeconds = 0,
servingStoreUpdateStrategy = 'always',
hasChromeStorage = false,
+ hasWebCacheAPI = false,
hasWindowLocalStorage = false,
persistentStore = undefined,
forceMemoryOnly = false,
@@ -40,6 +44,7 @@ export function configurationStorageFactory(
maxAgeSeconds?: number;
servingStoreUpdateStrategy?: ServingStoreUpdateStrategy;
hasChromeStorage?: boolean;
+ hasWebCacheAPI?: boolean;
hasWindowLocalStorage?: boolean;
persistentStore?: IAsyncStore;
forceMemoryOnly?: boolean;
@@ -73,6 +78,22 @@ export function configurationStorageFactory(
new StringValuedAsyncStore(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 migrationManager = new MigrationManager(windowLocalStorage);
+ migrationManager
+ .runPendingMigrations()
+ .catch((error) => console.warn('Storage migration failed:', error));
+ }
+
+ const webCacheEngine = new WebCacheStorageEngine(storageKeySuffix ?? '');
+ return new IsolatableHybridConfigurationStore(
+ new MemoryStore(),
+ new ConfigurationAsyncStore(webCacheEngine, maxAgeSeconds),
+ servingStoreUpdateStrategy,
+ );
} else if (hasWindowLocalStorage && windowLocalStorage) {
// window.localStorage is available, use it as a fallback
const localStorageEngine = new LocalStorageEngine(windowLocalStorage, storageKeySuffix ?? '');
@@ -135,3 +156,13 @@ export function hasWindowLocalStorage(): boolean {
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;
+ }
+}
diff --git a/src/configuration-store.ts b/src/configuration-store.ts
new file mode 100644
index 0000000..8788c2e
--- /dev/null
+++ b/src/configuration-store.ts
@@ -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;
+ setContentsJsonString: (configurationJsonString: string) => Promise;
+}
+
+/**
+ * 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 implements IAsyncStore {
+ private initialized = false;
+
+ public constructor(
+ private storageEngine: IConfigurationStorageEngine,
+ private cooldownSeconds = 0,
+ ) {}
+
+ public isInitialized(): boolean {
+ return this.initialized;
+ }
+
+ public async isExpired(): Promise {
+ if (!this.cooldownSeconds) {
+ return true;
+ }
+
+ try {
+ const contentsJsonString = await this.storageEngine.getContentsJsonString();
+ if (!contentsJsonString) {
+ return true;
+ }
+
+ const contents = JSON.parse(contentsJsonString);
+
+ // 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> {
+ const contentsJsonString = await this.storageEngine.getContentsJsonString();
+ return contentsJsonString ? JSON.parse(contentsJsonString) : {};
+ }
+
+ public async setEntries(entries: Record): Promise {
+ // Store the entire configuration response as-is
+ await this.storageEngine.setContentsJsonString(JSON.stringify(entries));
+ this.initialized = true;
+ }
+}
\ No newline at end of file
diff --git a/src/index.ts b/src/index.ts
index daac6db..b84048a 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -37,6 +37,7 @@ import {
chromeStorageIfAvailable,
configurationStorageFactory,
hasChromeStorage,
+ hasWebCacheAPI,
hasWindowLocalStorage,
localStorageIfAvailable,
overrideStorageFactory,
@@ -330,6 +331,7 @@ export class EppoJSClient extends EppoClient {
servingStoreUpdateStrategy: updateOnFetch,
persistentStore,
hasChromeStorage: hasChromeStorage(),
+ hasWebCacheAPI: hasWebCacheAPI(),
hasWindowLocalStorage: hasWindowLocalStorage(),
},
{
diff --git a/src/migrations/README.md b/src/migrations/README.md
new file mode 100644
index 0000000..7de7806
--- /dev/null
+++ b/src/migrations/README.md
@@ -0,0 +1,64 @@
+# Storage Migrations
+
+This directory contains versioned storage migrations for the Eppo SDK.
+
+## Migration Versioning
+
+Each migration is versioned using the SDK version when it was introduced, following this pattern:
+- `v{major}.{minor}.{patch}-{description}`
+- Example: `v3.17.0-localStorage-to-cache`
+
+## Current Migrations
+
+### v3.17.0-localStorage-to-cache
+- **Purpose**: Migrate from localStorage to Web Cache API for better storage limits
+- **What it does**: Clears all `eppo-configuration*` keys from localStorage when upgrading to Cache API storage
+- **Completion tracking**: Uses `eppo-migration-v3.17.0-localStorage-to-cache-completed` localStorage flag
+
+## Adding New Migrations
+
+1. Create a new file: `v{version}-{description}.ts`
+2. Implement the `Migration` interface
+3. Add the migration to `MigrationManager.constructor()`
+4. Export from `index.ts`
+
+Example:
+```typescript
+// v3.18.0-example-migration.ts
+export class ExampleMigration implements Migration {
+ public readonly version = 'v3.18.0-example-migration';
+
+ public isMigrationCompleted(): boolean {
+ // Check if migration was already run
+ }
+
+ public async migrate(): Promise {
+ // Perform migration logic
+ }
+}
+```
+
+## Migration Manager
+
+The `MigrationManager` handles:
+- Running all pending migrations in order
+- Tracking completion status
+- Error handling (continues other migrations if one fails)
+- Logging migration progress
+
+## Usage
+
+```typescript
+import { MigrationManager } from './migrations';
+
+const migrationManager = new MigrationManager(localStorage);
+await migrationManager.runPendingMigrations();
+```
+
+## Best Practices
+
+1. **Idempotent**: Migrations should be safe to run multiple times
+2. **Backward Compatible**: Don't break existing functionality during migration
+3. **Error Tolerant**: Handle errors gracefully and continue other migrations
+4. **Logged**: Provide clear logging for debugging
+5. **Versioned**: Always version migrations with the SDK release they're introduced in
\ No newline at end of file
diff --git a/src/migrations/index.ts b/src/migrations/index.ts
new file mode 100644
index 0000000..d187f5c
--- /dev/null
+++ b/src/migrations/index.ts
@@ -0,0 +1,12 @@
+/**
+ * Storage Migrations
+ *
+ * This module handles versioned storage migrations for the Eppo SDK.
+ * Each migration is versioned and tracked to ensure it only runs once.
+ */
+
+export { LocalStorageToCacheMigration } from './v3.17.0-localStorage-to-cache';
+export { MigrationManager, type Migration } from './migration-manager';
+
+// Convenience function for the current migration
+export { MigrationManager as StorageMigration } from './migration-manager';
diff --git a/src/migrations/migration-manager.spec.ts b/src/migrations/migration-manager.spec.ts
new file mode 100644
index 0000000..e43363a
--- /dev/null
+++ b/src/migrations/migration-manager.spec.ts
@@ -0,0 +1,237 @@
+import { type Migration, MigrationManager } from './migration-manager';
+
+// Mock migration for testing
+class MockMigrationV1 implements Migration {
+ public readonly version = 'v1.0.0-test-migration';
+ private completed = false;
+ private shouldFail = false;
+ public migrateCalled = false;
+
+ constructor(completed = false, shouldFail = false) {
+ this.completed = completed;
+ this.shouldFail = shouldFail;
+ }
+
+ public isMigrationCompleted(): boolean {
+ return this.completed;
+ }
+
+ public async migrate(): Promise<{
+ migrationNeeded: boolean;
+ clearedKeys: string[];
+ errors: string[];
+ version: string;
+ }> {
+ this.migrateCalled = true;
+
+ if (this.shouldFail) {
+ throw new Error('Mock migration failed');
+ }
+
+ this.completed = true;
+ return {
+ migrationNeeded: true,
+ clearedKeys: ['test-key-1', 'test-key-2'],
+ errors: [],
+ version: this.version,
+ };
+ }
+}
+
+class MockMigrationV2 implements Migration {
+ public readonly version = 'v2.0.0-another-migration';
+ private completed = false;
+ public migrateCalled = false;
+
+ constructor(completed = false) {
+ this.completed = completed;
+ }
+
+ public isMigrationCompleted(): boolean {
+ return this.completed;
+ }
+
+ public async migrate(): Promise<{
+ migrationNeeded: boolean;
+ clearedKeys: string[];
+ errors: string[];
+ version: string;
+ }> {
+ this.migrateCalled = true;
+ this.completed = true;
+ return {
+ migrationNeeded: true,
+ clearedKeys: ['test-key-3'],
+ errors: [],
+ version: this.version,
+ };
+ }
+}
+
+describe('MigrationManager', () => {
+ let mockLocalStorage: Storage;
+ let migrationManager: MigrationManager;
+ let mockMigrationV1: MockMigrationV1;
+ let mockMigrationV2: MockMigrationV2;
+
+ beforeEach(() => {
+ mockLocalStorage = {
+ getItem: jest.fn(),
+ setItem: jest.fn(),
+ removeItem: jest.fn(),
+ clear: jest.fn(),
+ key: jest.fn(),
+ length: 0,
+ };
+
+ // Create a test migration manager with mock migrations
+ mockMigrationV1 = new MockMigrationV1();
+ mockMigrationV2 = new MockMigrationV2();
+
+ migrationManager = new MigrationManager(mockLocalStorage);
+ // Override migrations with our mocks for testing
+ (migrationManager as any).migrations = [mockMigrationV1, mockMigrationV2];
+
+ // Mock console methods to avoid noise in tests
+ jest.spyOn(console, 'log').mockImplementation();
+ jest.spyOn(console, 'error').mockImplementation();
+ });
+
+ afterEach(() => {
+ jest.restoreAllMocks();
+ });
+
+ describe('constructor', () => {
+ it('should initialize with localStorage', () => {
+ const manager = new MigrationManager(mockLocalStorage);
+ expect(manager).toBeInstanceOf(MigrationManager);
+ });
+
+ it('should handle missing window.localStorage gracefully', () => {
+ const manager = new MigrationManager();
+ expect(manager).toBeInstanceOf(MigrationManager);
+ });
+ });
+
+ describe('getAllMigrationVersions', () => {
+ it('should return all registered migration versions', () => {
+ const versions = migrationManager.getAllMigrationVersions();
+ expect(versions).toEqual(['v1.0.0-test-migration', 'v2.0.0-another-migration']);
+ });
+ });
+
+ describe('getPendingMigrationVersions', () => {
+ it('should return pending migration versions', () => {
+ // Both migrations are pending initially
+ const pending = migrationManager.getPendingMigrationVersions();
+ expect(pending).toEqual(['v1.0.0-test-migration', 'v2.0.0-another-migration']);
+ });
+
+ it('should return only uncompleted migrations', () => {
+ // Mark first migration as completed
+ mockMigrationV1 = new MockMigrationV1(true);
+ (migrationManager as any).migrations = [mockMigrationV1, mockMigrationV2];
+
+ const pending = migrationManager.getPendingMigrationVersions();
+ expect(pending).toEqual(['v2.0.0-another-migration']);
+ });
+
+ it('should return empty array when all migrations are completed', () => {
+ // Mark both migrations as completed
+ mockMigrationV1 = new MockMigrationV1(true);
+ mockMigrationV2 = new MockMigrationV2(true);
+ (migrationManager as any).migrations = [mockMigrationV1, mockMigrationV2];
+
+ const pending = migrationManager.getPendingMigrationVersions();
+ expect(pending).toEqual([]);
+ });
+ });
+
+ describe('isMigrationCompleted', () => {
+ it('should return true for completed migration', () => {
+ mockMigrationV1 = new MockMigrationV1(true);
+ (migrationManager as any).migrations = [mockMigrationV1, mockMigrationV2];
+
+ expect(migrationManager.isMigrationCompleted('v1.0.0-test-migration')).toBe(true);
+ });
+
+ it('should return false for uncompleted migration', () => {
+ expect(migrationManager.isMigrationCompleted('v1.0.0-test-migration')).toBe(false);
+ });
+
+ it('should return false for non-existent migration', () => {
+ expect(migrationManager.isMigrationCompleted('v999.0.0-does-not-exist')).toBe(false);
+ });
+ });
+
+ describe('runPendingMigrations', () => {
+ it('should run all pending migrations', async () => {
+ await migrationManager.runPendingMigrations();
+
+ expect(mockMigrationV1.migrateCalled).toBe(true);
+ expect(mockMigrationV2.migrateCalled).toBe(true);
+ expect(console.log).toHaveBeenCalledWith('Checking for pending storage migrations...');
+ expect(console.log).toHaveBeenCalledWith('Running pending migration: v1.0.0-test-migration');
+ expect(console.log).toHaveBeenCalledWith(
+ 'Running pending migration: v2.0.0-another-migration',
+ );
+ });
+
+ it('should skip completed migrations', async () => {
+ // Mark first migration as completed
+ mockMigrationV1 = new MockMigrationV1(true);
+ (migrationManager as any).migrations = [mockMigrationV1, mockMigrationV2];
+
+ await migrationManager.runPendingMigrations();
+
+ expect(mockMigrationV1.migrateCalled).toBe(false);
+ expect(mockMigrationV2.migrateCalled).toBe(true);
+ expect(console.log).not.toHaveBeenCalledWith(
+ 'Running pending migration: v1.0.0-test-migration',
+ );
+ expect(console.log).toHaveBeenCalledWith(
+ 'Running pending migration: v2.0.0-another-migration',
+ );
+ });
+
+ it('should continue other migrations if one fails', async () => {
+ // Make first migration fail
+ mockMigrationV1 = new MockMigrationV1(false, true);
+ (migrationManager as any).migrations = [mockMigrationV1, mockMigrationV2];
+
+ await migrationManager.runPendingMigrations();
+
+ expect(mockMigrationV1.migrateCalled).toBe(true);
+ expect(mockMigrationV2.migrateCalled).toBe(true);
+ expect(console.error).toHaveBeenCalledWith(
+ 'Migration v1.0.0-test-migration failed:',
+ expect.any(Error),
+ );
+ });
+
+ it('should handle empty migrations array', async () => {
+ (migrationManager as any).migrations = [];
+
+ await migrationManager.runPendingMigrations();
+
+ expect(console.log).toHaveBeenCalledWith('Checking for pending storage migrations...');
+ // Should not throw or cause issues
+ });
+
+ it('should not run migrations if all are completed', async () => {
+ // Mark both migrations as completed
+ mockMigrationV1 = new MockMigrationV1(true);
+ mockMigrationV2 = new MockMigrationV2(true);
+ (migrationManager as any).migrations = [mockMigrationV1, mockMigrationV2];
+
+ await migrationManager.runPendingMigrations();
+
+ expect(mockMigrationV1.migrateCalled).toBe(false);
+ expect(mockMigrationV2.migrateCalled).toBe(false);
+ expect(console.log).toHaveBeenCalledWith('Checking for pending storage migrations...');
+ expect(console.log).not.toHaveBeenCalledWith(
+ expect.stringContaining('Running pending migration:'),
+ );
+ });
+ });
+});
diff --git a/src/migrations/migration-manager.ts b/src/migrations/migration-manager.ts
new file mode 100644
index 0000000..f263b5a
--- /dev/null
+++ b/src/migrations/migration-manager.ts
@@ -0,0 +1,77 @@
+/**
+ * Migration Manager for handling versioned storage migrations
+ */
+
+import { localStorageIfAvailable } from '../configuration-factory';
+
+import { LocalStorageToCacheMigration } from './v3.17.0-localStorage-to-cache';
+
+export interface Migration {
+ version: string;
+ isMigrationCompleted(): boolean;
+
+ migrate(): Promise<{
+ migrationNeeded: boolean;
+ clearedKeys: string[];
+ errors: string[];
+ version: string;
+ }>;
+}
+
+/**
+ * Manages all storage migrations in order
+ */
+export class MigrationManager {
+ private readonly migrations: Migration[];
+
+ constructor(localStorage?: Storage) {
+ const storageToUse = localStorage || localStorageIfAvailable();
+
+ // Register all migrations in order
+ this.migrations = [
+ new LocalStorageToCacheMigration(storageToUse),
+ // Future migrations will be added here
+ ];
+ }
+
+ /**
+ * Run all pending migrations
+ */
+ public async runPendingMigrations(): Promise {
+ console.log('Checking for pending storage migrations...');
+
+ for (const migration of this.migrations) {
+ if (!migration.isMigrationCompleted()) {
+ console.log(`Running pending migration: ${migration.version}`);
+ try {
+ await migration.migrate();
+ } catch (error) {
+ console.error(`Migration ${migration.version} failed:`, error);
+ // Continue with other migrations even if one fails
+ }
+ }
+ }
+ }
+
+ /**
+ * Check if a specific migration has been completed
+ */
+ public isMigrationCompleted(version: string): boolean {
+ const migration = this.migrations.find((m) => m.version === version);
+ return migration ? migration.isMigrationCompleted() : false;
+ }
+
+ /**
+ * Get all registered migration versions
+ */
+ public getAllMigrationVersions(): string[] {
+ return this.migrations.map((m) => m.version);
+ }
+
+ /**
+ * Get pending migration versions
+ */
+ public getPendingMigrationVersions(): string[] {
+ return this.migrations.filter((m) => !m.isMigrationCompleted()).map((m) => m.version);
+ }
+}
diff --git a/src/migrations/v3.17.0-localStorage-to-cache.spec.ts b/src/migrations/v3.17.0-localStorage-to-cache.spec.ts
new file mode 100644
index 0000000..2e8edc1
--- /dev/null
+++ b/src/migrations/v3.17.0-localStorage-to-cache.spec.ts
@@ -0,0 +1,337 @@
+import { LocalStorageToCacheMigration } from './v3.17.0-localStorage-to-cache';
+
+// Mock the configuration factory functions
+jest.mock('../configuration-factory', () => ({
+ hasWindowLocalStorage: jest.fn(() => true),
+ localStorageIfAvailable: jest.fn(),
+}));
+
+describe('LocalStorageToCacheMigration', () => {
+ let mockLocalStorage: Storage;
+ let migration: LocalStorageToCacheMigration;
+ const migrationFlagKey = 'eppo-migration-v3.17.0-localStorage-to-cache-completed';
+
+ beforeEach(() => {
+ mockLocalStorage = {
+ getItem: jest.fn(),
+ setItem: jest.fn(),
+ removeItem: jest.fn(),
+ clear: jest.fn(),
+ key: jest.fn(),
+ get length() {
+ return this._length || 0;
+ },
+ set length(value) {
+ this._length = value;
+ },
+ _length: 0,
+ } as any;
+
+ migration = new LocalStorageToCacheMigration(mockLocalStorage);
+
+ // Mock console methods to avoid noise in tests
+ jest.spyOn(console, 'log').mockImplementation();
+ jest.spyOn(console, 'warn').mockImplementation();
+ });
+
+ afterEach(() => {
+ jest.restoreAllMocks();
+ });
+
+ describe('constructor', () => {
+ it('should initialize with provided localStorage', () => {
+ expect(migration).toBeInstanceOf(LocalStorageToCacheMigration);
+ expect(migration.version).toBe('v3.17.0-localStorage-to-cache');
+ });
+
+ it('should handle missing window.localStorage gracefully', () => {
+ const { hasWindowLocalStorage, localStorageIfAvailable } = jest.requireMock(
+ '../configuration-factory',
+ );
+ hasWindowLocalStorage.mockReturnValue(false);
+ localStorageIfAvailable.mockReturnValue(undefined);
+
+ const defaultMigration = new LocalStorageToCacheMigration();
+ expect(defaultMigration).toBeInstanceOf(LocalStorageToCacheMigration);
+
+ // Should assume migration is completed when no localStorage
+ expect(defaultMigration.isMigrationCompleted()).toBe(true);
+
+ // Restore mocks
+ hasWindowLocalStorage.mockReturnValue(true);
+ localStorageIfAvailable.mockReturnValue(mockLocalStorage);
+ });
+ });
+
+ describe('isMigrationCompleted', () => {
+ it('should return true when migration flag is set', () => {
+ (mockLocalStorage.getItem as jest.Mock).mockReturnValue('true');
+
+ expect(migration.isMigrationCompleted()).toBe(true);
+ expect(mockLocalStorage.getItem).toHaveBeenCalledWith(migrationFlagKey);
+ });
+
+ it('should return false when migration flag is not set', () => {
+ (mockLocalStorage.getItem as jest.Mock).mockReturnValue(null);
+
+ expect(migration.isMigrationCompleted()).toBe(false);
+ expect(mockLocalStorage.getItem).toHaveBeenCalledWith(migrationFlagKey);
+ });
+
+ it('should return false when migration flag has wrong value', () => {
+ (mockLocalStorage.getItem as jest.Mock).mockReturnValue('false');
+
+ expect(migration.isMigrationCompleted()).toBe(false);
+ });
+
+ it('should handle localStorage errors gracefully', () => {
+ (mockLocalStorage.getItem as jest.Mock).mockImplementation(() => {
+ throw new Error('localStorage error');
+ });
+
+ expect(migration.isMigrationCompleted()).toBe(true);
+ expect(console.warn).toHaveBeenCalledWith(
+ 'Failed to check migration status for v3.17.0-localStorage-to-cache:',
+ expect.any(Error),
+ );
+ });
+ });
+
+ describe('markMigrationCompleted', () => {
+ it('should set migration flag to true', () => {
+ migration.markMigrationCompleted();
+
+ expect(mockLocalStorage.setItem).toHaveBeenCalledWith(migrationFlagKey, 'true');
+ });
+
+ it('should handle localStorage errors gracefully', () => {
+ (mockLocalStorage.setItem as jest.Mock).mockImplementation(() => {
+ throw new Error('localStorage error');
+ });
+
+ migration.markMigrationCompleted();
+
+ expect(console.warn).toHaveBeenCalledWith(
+ 'Failed to mark migration as completed for v3.17.0-localStorage-to-cache:',
+ expect.any(Error),
+ );
+ });
+ });
+
+ describe('getConfigurationKeys', () => {
+ it('should return keys that start with eppo-configuration', () => {
+ (mockLocalStorage as any)._length = 5;
+ (mockLocalStorage.key as jest.Mock)
+ .mockReturnValueOnce('eppo-configuration-key1')
+ .mockReturnValueOnce('other-key')
+ .mockReturnValueOnce('eppo-configuration-key2')
+ .mockReturnValueOnce('eppo-configuration-meta')
+ .mockReturnValueOnce('random-key');
+
+ const keys = migration.getConfigurationKeys();
+
+ expect(keys).toEqual([
+ 'eppo-configuration-key1',
+ 'eppo-configuration-key2',
+ 'eppo-configuration-meta',
+ ]);
+ expect(mockLocalStorage.key).toHaveBeenCalledTimes(5);
+ });
+
+ it('should return empty array when no configuration keys exist', () => {
+ (mockLocalStorage as any)._length = 2;
+ (mockLocalStorage.key as jest.Mock)
+ .mockReturnValueOnce('other-key')
+ .mockReturnValueOnce('random-key');
+
+ const keys = migration.getConfigurationKeys();
+
+ expect(keys).toEqual([]);
+ });
+
+ it('should handle null keys from localStorage', () => {
+ (mockLocalStorage as any)._length = 3;
+ (mockLocalStorage.key as jest.Mock)
+ .mockReturnValueOnce('eppo-configuration-key1')
+ .mockReturnValueOnce(null)
+ .mockReturnValueOnce('eppo-configuration-key2');
+
+ const keys = migration.getConfigurationKeys();
+
+ expect(keys).toEqual(['eppo-configuration-key1', 'eppo-configuration-key2']);
+ });
+
+ it('should handle localStorage errors gracefully', () => {
+ (mockLocalStorage as any)._length = 1;
+ (mockLocalStorage.key as jest.Mock).mockImplementation(() => {
+ throw new Error('localStorage error');
+ });
+
+ const keys = migration.getConfigurationKeys();
+
+ expect(keys).toEqual([]);
+ expect(console.warn).toHaveBeenCalledWith(
+ 'Failed to enumerate localStorage keys for v3.17.0-localStorage-to-cache:',
+ expect.any(Error),
+ );
+ });
+ });
+
+ describe('clearConfigurationKeys', () => {
+ beforeEach(() => {
+ // Mock isMigrationCompleted to return false initially
+ jest.spyOn(migration, 'isMigrationCompleted').mockReturnValue(false);
+ jest.spyOn(migration, 'markMigrationCompleted').mockImplementation();
+ });
+
+ it('should return early if migration already completed', () => {
+ jest.spyOn(migration, 'isMigrationCompleted').mockReturnValue(true);
+
+ const result = migration.clearConfigurationKeys();
+
+ expect(result).toEqual({
+ migrationNeeded: false,
+ clearedKeys: [],
+ errors: [],
+ version: 'v3.17.0-localStorage-to-cache',
+ });
+ expect(migration.markMigrationCompleted).not.toHaveBeenCalled();
+ });
+
+ it('should clear configuration keys successfully', () => {
+ jest
+ .spyOn(migration, 'getConfigurationKeys')
+ .mockReturnValue(['eppo-configuration-key1', 'eppo-configuration-key2']);
+
+ const result = migration.clearConfigurationKeys();
+
+ expect(mockLocalStorage.removeItem).toHaveBeenCalledWith('eppo-configuration-key1');
+ expect(mockLocalStorage.removeItem).toHaveBeenCalledWith('eppo-configuration-key2');
+ expect(migration.markMigrationCompleted).toHaveBeenCalled();
+ expect(result).toEqual({
+ migrationNeeded: true,
+ clearedKeys: ['eppo-configuration-key1', 'eppo-configuration-key2'],
+ errors: [],
+ version: 'v3.17.0-localStorage-to-cache',
+ });
+ });
+
+ it('should handle removal errors but continue with other keys', () => {
+ jest
+ .spyOn(migration, 'getConfigurationKeys')
+ .mockReturnValue([
+ 'eppo-configuration-key1',
+ 'eppo-configuration-key2',
+ 'eppo-configuration-key3',
+ ]);
+
+ (mockLocalStorage.removeItem as jest.Mock).mockImplementation((key) => {
+ if (key === 'eppo-configuration-key2') {
+ throw new Error('Remove failed');
+ }
+ });
+
+ const result = migration.clearConfigurationKeys();
+
+ expect(result.migrationNeeded).toBe(true);
+ expect(result.clearedKeys).toEqual(['eppo-configuration-key1', 'eppo-configuration-key3']);
+ expect(result.errors).toEqual([
+ 'Failed to remove key eppo-configuration-key2: Error: Remove failed',
+ ]);
+ expect(migration.markMigrationCompleted).toHaveBeenCalled();
+ });
+
+ it('should return no migration needed when no configuration keys exist', () => {
+ jest.spyOn(migration, 'getConfigurationKeys').mockReturnValue([]);
+
+ const result = migration.clearConfigurationKeys();
+
+ expect(result).toEqual({
+ migrationNeeded: false,
+ clearedKeys: [],
+ errors: [],
+ version: 'v3.17.0-localStorage-to-cache',
+ });
+ expect(migration.markMigrationCompleted).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('migrate', () => {
+ it('should run migration successfully', async () => {
+ jest.spyOn(migration, 'clearConfigurationKeys').mockReturnValue({
+ migrationNeeded: true,
+ clearedKeys: ['eppo-configuration-key1'],
+ errors: [],
+ version: 'v3.17.0-localStorage-to-cache',
+ });
+
+ const result = await migration.migrate();
+
+ expect(console.log).toHaveBeenCalledWith(
+ 'Starting migration v3.17.0-localStorage-to-cache...',
+ );
+ expect(console.log).toHaveBeenCalledWith(
+ 'Migration v3.17.0-localStorage-to-cache completed: cleared 1 keys',
+ expect.objectContaining({
+ clearedKeys: ['eppo-configuration-key1'],
+ errors: [],
+ }),
+ );
+ expect(result.migrationNeeded).toBe(true);
+ });
+
+ it('should handle case when no migration is needed', async () => {
+ jest.spyOn(migration, 'clearConfigurationKeys').mockReturnValue({
+ migrationNeeded: false,
+ clearedKeys: [],
+ errors: [],
+ version: 'v3.17.0-localStorage-to-cache',
+ });
+
+ const result = await migration.migrate();
+
+ expect(console.log).toHaveBeenCalledWith(
+ 'Migration v3.17.0-localStorage-to-cache: No migration needed - already completed or no keys found',
+ );
+ expect(result.migrationNeeded).toBe(false);
+ });
+ });
+
+ describe('forceMigrate', () => {
+ it('should clear migration flag and run migration', async () => {
+ jest.spyOn(migration, 'migrate').mockResolvedValue({
+ migrationNeeded: true,
+ clearedKeys: ['test-key'],
+ errors: [],
+ version: 'v3.17.0-localStorage-to-cache',
+ });
+
+ const result = await migration.forceMigrate();
+
+ expect(mockLocalStorage.removeItem).toHaveBeenCalledWith(migrationFlagKey);
+ expect(migration.migrate).toHaveBeenCalled();
+ expect(result.migrationNeeded).toBe(true);
+ });
+
+ it('should handle errors when clearing migration flag', async () => {
+ (mockLocalStorage.removeItem as jest.Mock).mockImplementation(() => {
+ throw new Error('Remove failed');
+ });
+
+ jest.spyOn(migration, 'migrate').mockResolvedValue({
+ migrationNeeded: false,
+ clearedKeys: [],
+ errors: [],
+ version: 'v3.17.0-localStorage-to-cache',
+ });
+
+ await migration.forceMigrate();
+
+ expect(console.warn).toHaveBeenCalledWith(
+ 'Failed to clear migration flag for v3.17.0-localStorage-to-cache:',
+ expect.any(Error),
+ );
+ expect(migration.migrate).toHaveBeenCalled();
+ });
+ });
+});
diff --git a/src/migrations/v3.17.0-localStorage-to-cache.ts b/src/migrations/v3.17.0-localStorage-to-cache.ts
new file mode 100644
index 0000000..0e4f9df
--- /dev/null
+++ b/src/migrations/v3.17.0-localStorage-to-cache.ts
@@ -0,0 +1,166 @@
+import { localStorageIfAvailable } from '../configuration-factory';
+
+/**
+ * Migration v3.17.0: localStorage to Web Cache API
+ *
+ * This migration clears localStorage keys with 'eppo-configuration' prefix
+ * when upgrading to Web Cache API storage.
+ */
+
+const MIGRATION_VERSION = 'v3.17.0-localStorage-to-cache';
+const MIGRATION_FLAG_KEY = `eppo-migration-${MIGRATION_VERSION}-completed`;
+const CONFIGURATION_PREFIX = 'eppo-configuration';
+
+export interface MigrationResult {
+ migrationNeeded: boolean;
+ clearedKeys: string[];
+ errors: string[];
+ version: string;
+}
+
+/**
+ * Migration v3.17.0: Move from localStorage to Web Cache API
+ */
+export class LocalStorageToCacheMigration {
+ private readonly localStorage?: Storage;
+ public readonly version = MIGRATION_VERSION;
+
+ constructor(localStorage?: Storage) {
+ this.localStorage = localStorage || localStorageIfAvailable();
+ }
+
+ /**
+ * Check if this specific migration has been completed
+ */
+ public isMigrationCompleted(): boolean {
+ // If no localStorage available, assume migration is completed
+ if (!this.localStorage) {
+ return true;
+ }
+
+ try {
+ return this.localStorage.getItem(MIGRATION_FLAG_KEY) === 'true';
+ } catch (error) {
+ console.warn(`Failed to check migration status for ${this.version}:`, error);
+ return true; // Assume completed on error
+ }
+ }
+
+ /**
+ * Mark this migration as completed
+ */
+ public markMigrationCompleted(): void {
+ if (!this.localStorage) {
+ return; // Can't persist without localStorage
+ }
+
+ try {
+ this.localStorage.setItem(MIGRATION_FLAG_KEY, 'true');
+ } catch (error) {
+ console.warn(`Failed to mark migration as completed for ${this.version}:`, error);
+ }
+ }
+
+ /**
+ * Get all localStorage keys that match the eppo-configuration prefix
+ */
+ public getConfigurationKeys(): string[] {
+ const keys: string[] = [];
+
+ if (!this.localStorage) {
+ return keys;
+ }
+
+ try {
+ for (let i = 0; i < this.localStorage.length; i++) {
+ const key = this.localStorage.key(i);
+ if (key && key.startsWith(CONFIGURATION_PREFIX)) {
+ keys.push(key);
+ }
+ }
+ } catch (error) {
+ console.warn(`Failed to enumerate localStorage keys for ${this.version}:`, error);
+ }
+
+ return keys;
+ }
+
+ /**
+ * Clear all localStorage keys with eppo-configuration prefix
+ */
+ public clearConfigurationKeys(): MigrationResult {
+ const result: MigrationResult = {
+ migrationNeeded: false,
+ clearedKeys: [],
+ errors: [],
+ version: this.version,
+ };
+
+ // Check if migration already completed
+ if (this.isMigrationCompleted()) {
+ return result;
+ }
+
+ const keysToRemove = this.getConfigurationKeys();
+ result.migrationNeeded = keysToRemove.length > 0;
+
+ // Remove each configuration key
+ for (const key of keysToRemove) {
+ try {
+ this.localStorage?.removeItem(key);
+ result.clearedKeys.push(key);
+ } catch (error) {
+ const errorMsg = `Failed to remove key ${key}: ${error}`;
+ console.warn(errorMsg);
+ result.errors.push(errorMsg);
+ }
+ }
+
+ // Mark migration as completed if we processed any keys (even with errors)
+ if (result.migrationNeeded) {
+ this.markMigrationCompleted();
+ }
+
+ return result;
+ }
+
+ /**
+ * Run the complete migration process
+ */
+ public async migrate(): Promise {
+ console.log(`Starting migration ${this.version}...`);
+
+ const result = this.clearConfigurationKeys();
+
+ if (result.migrationNeeded) {
+ console.log(
+ `Migration ${this.version} completed: cleared ${result.clearedKeys.length} keys`,
+ {
+ clearedKeys: result.clearedKeys,
+ errors: result.errors,
+ },
+ );
+ } else {
+ console.log(
+ `Migration ${this.version}: No migration needed - already completed or no keys found`,
+ );
+ }
+
+ return result;
+ }
+
+ /**
+ * Force re-run migration (clears migration flag first)
+ */
+ public async forceMigrate(): Promise {
+ if (this.localStorage) {
+ try {
+ this.localStorage.removeItem(MIGRATION_FLAG_KEY);
+ } catch (error) {
+ console.warn(`Failed to clear migration flag for ${this.version}:`, error);
+ }
+ }
+
+ return this.migrate();
+ }
+}
diff --git a/src/web-cache-storage-engine.spec.ts b/src/web-cache-storage-engine.spec.ts
new file mode 100644
index 0000000..dc95147
--- /dev/null
+++ b/src/web-cache-storage-engine.spec.ts
@@ -0,0 +1,251 @@
+import { WebCacheStorageEngine } from './web-cache-storage-engine';
+
+describe('WebCacheStorageEngine', () => {
+ let engine: WebCacheStorageEngine;
+ let mockCache: jest.Mocked;
+ let mockCaches: jest.Mocked;
+ let originalCaches: CacheStorage | undefined;
+
+ beforeEach(() => {
+ // Create mock Cache object
+ mockCache = {
+ match: jest.fn(),
+ matchAll: jest.fn(),
+ put: jest.fn(),
+ add: jest.fn(),
+ addAll: jest.fn(),
+ delete: jest.fn(),
+ keys: jest.fn(),
+ } as jest.Mocked;
+
+ // Create mock CacheStorage object
+ mockCaches = {
+ open: jest.fn().mockResolvedValue(mockCache),
+ delete: jest.fn(),
+ has: jest.fn(),
+ keys: jest.fn(),
+ match: jest.fn(),
+ } as jest.Mocked;
+
+ // Store original caches and replace with mock
+ originalCaches = (global as any).caches;
+ (global as any).caches = mockCaches;
+
+ engine = new WebCacheStorageEngine('test-suffix');
+
+ // Mock console methods to avoid noise in tests
+ jest.spyOn(console, 'warn').mockImplementation();
+ jest.spyOn(console, 'error').mockImplementation();
+ });
+
+ afterEach(() => {
+ // Restore original caches
+ (global as any).caches = originalCaches;
+ jest.restoreAllMocks();
+ });
+
+ describe('constructor', () => {
+ it('should initialize with cache name and key', () => {
+ expect(engine).toBeInstanceOf(WebCacheStorageEngine);
+ });
+
+ it('should handle empty storage key suffix', () => {
+ const defaultEngine = new WebCacheStorageEngine();
+ expect(defaultEngine).toBeInstanceOf(WebCacheStorageEngine);
+ });
+ });
+
+ describe('getContentsJsonString', () => {
+ it('should return cached content successfully', async () => {
+ const testData = '{"test": "data"}';
+ const mockResponse = new Response(testData);
+ mockCache.match.mockResolvedValue(mockResponse);
+
+ const result = await engine.getContentsJsonString();
+
+ expect(mockCaches.open).toHaveBeenCalledWith('eppo-sdk-test-suffix');
+ expect(mockCache.match).toHaveBeenCalledWith('eppo-configuration-test-suffix');
+ expect(result).toBe(testData);
+ });
+
+ it('should return null when no cached data exists', async () => {
+ mockCache.match.mockResolvedValue(undefined);
+
+ const result = await engine.getContentsJsonString();
+
+ expect(result).toBeNull();
+ });
+
+ it('should return null when Cache API is not supported', async () => {
+ (global as any).caches = undefined;
+
+ const result = await engine.getContentsJsonString();
+
+ expect(result).toBeNull();
+ });
+
+ it('should handle cache errors gracefully', async () => {
+ mockCache.match.mockRejectedValue(new Error('Cache error'));
+
+ const result = await engine.getContentsJsonString();
+
+ expect(result).toBeNull();
+ expect(console.warn).toHaveBeenCalledWith('Failed to read from Web Cache API:', expect.any(Error));
+ });
+ });
+
+ describe('setContentsJsonString', () => {
+ it('should store content successfully', async () => {
+ const testData = '{"test": "data"}';
+
+ await engine.setContentsJsonString(testData);
+
+ expect(mockCaches.open).toHaveBeenCalledWith('eppo-sdk-test-suffix');
+ expect(mockCache.put).toHaveBeenCalledWith(
+ 'eppo-configuration-test-suffix',
+ expect.any(Response)
+ );
+
+ // Verify the Response object has correct properties
+ const putCall = mockCache.put.mock.calls[0];
+ const response = putCall[1] as Response;
+ expect(await response.text()).toBe(testData);
+ expect(response.headers.get('Content-Type')).toBe('application/json');
+ expect(response.headers.get('Cache-Stored-At')).toBeTruthy();
+ });
+
+ it('should throw error when Cache API is not supported', async () => {
+ (global as any).caches = undefined;
+
+ await expect(engine.setContentsJsonString('test')).rejects.toThrow('Cache API not supported');
+ });
+
+ it('should handle cache errors and log them', async () => {
+ mockCache.put.mockRejectedValue(new Error('Cache write error'));
+
+ await expect(engine.setContentsJsonString('test')).rejects.toThrow('Cache write error');
+ expect(console.error).toHaveBeenCalledWith('Failed to write to Web Cache API:', expect.any(Error));
+ });
+ });
+
+ describe('isExpired', () => {
+ it('should return true when cooldown is 0', async () => {
+ const result = await engine.isExpired(0);
+ expect(result).toBe(true);
+ });
+
+ it('should return false for non-expired cache', async () => {
+ const recentTime = new Date(Date.now() - 30000).toISOString(); // 30 seconds ago
+ const testData = JSON.stringify({ createdAt: recentTime });
+ const mockResponse = new Response(testData);
+ mockCache.match.mockResolvedValue(mockResponse);
+
+ const result = await engine.isExpired(60); // 60 second cooldown
+
+ expect(result).toBe(false);
+ });
+
+ it('should return true for expired cache', async () => {
+ const oldTime = new Date(Date.now() - 120000).toISOString(); // 2 minutes ago
+ const testData = JSON.stringify({ createdAt: oldTime });
+ const mockResponse = new Response(testData);
+ mockCache.match.mockResolvedValue(mockResponse);
+
+ const result = await engine.isExpired(60); // 60 second cooldown
+
+ expect(result).toBe(true);
+ });
+
+ it('should return true when no cached data exists', async () => {
+ mockCache.match.mockResolvedValue(undefined);
+
+ const result = await engine.isExpired(60);
+
+ expect(result).toBe(true);
+ });
+
+ it('should return true when cached data has no createdAt', async () => {
+ const testData = JSON.stringify({ someOtherField: 'value' });
+ const mockResponse = new Response(testData);
+ mockCache.match.mockResolvedValue(mockResponse);
+
+ const result = await engine.isExpired(60);
+
+ expect(result).toBe(true);
+ });
+
+ it('should return true when cached data is invalid JSON', async () => {
+ const mockResponse = new Response('invalid json');
+ mockCache.match.mockResolvedValue(mockResponse);
+
+ const result = await engine.isExpired(60);
+
+ expect(result).toBe(true);
+ expect(console.warn).toHaveBeenCalledWith('Failed to check cache expiration:', expect.any(Error));
+ });
+
+ it('should handle cache read errors gracefully', async () => {
+ jest.spyOn(engine, 'getContentsJsonString').mockRejectedValue(new Error('Read error'));
+
+ const result = await engine.isExpired(60);
+
+ expect(result).toBe(true);
+ expect(console.warn).toHaveBeenCalledWith('Failed to check cache expiration:', expect.any(Error));
+ });
+ });
+
+ describe('clear', () => {
+ it('should clear cache successfully', async () => {
+ await engine.clear();
+
+ expect(mockCaches.delete).toHaveBeenCalledWith('eppo-sdk-test-suffix');
+ });
+
+ it('should handle missing Cache API gracefully', async () => {
+ (global as any).caches = undefined;
+
+ await engine.clear();
+
+ // Should not throw error
+ });
+
+ it('should handle cache deletion errors gracefully', async () => {
+ mockCaches.delete.mockRejectedValue(new Error('Delete error'));
+
+ await engine.clear();
+
+ expect(console.warn).toHaveBeenCalledWith('Failed to clear Web Cache API:', expect.any(Error));
+ });
+ });
+
+ describe('integration scenarios', () => {
+ it('should handle full read-write-expire cycle', async () => {
+ const testData = JSON.stringify({
+ createdAt: new Date().toISOString(),
+ flags: { testFlag: true }
+ });
+
+ // Write data
+ await engine.setContentsJsonString(testData);
+
+ // Read data back
+ const mockResponse = new Response(testData);
+ mockCache.match.mockResolvedValue(mockResponse);
+ const readData = await engine.getContentsJsonString();
+ expect(readData).toBe(testData);
+
+ // Check expiration (should not be expired with short cooldown)
+ const isExpired = await engine.isExpired(3600); // 1 hour
+ expect(isExpired).toBe(false);
+ });
+
+ it('should work with different storage key suffixes', () => {
+ const engine1 = new WebCacheStorageEngine('suffix1');
+ const engine2 = new WebCacheStorageEngine('suffix2');
+
+ // Engines should be independent (different cache names)
+ expect(engine1).toBeInstanceOf(WebCacheStorageEngine);
+ expect(engine2).toBeInstanceOf(WebCacheStorageEngine);
+ });
+ });
+});
\ No newline at end of file
diff --git a/src/web-cache-storage-engine.ts b/src/web-cache-storage-engine.ts
new file mode 100644
index 0000000..adf85ea
--- /dev/null
+++ b/src/web-cache-storage-engine.ts
@@ -0,0 +1,108 @@
+import { IConfigurationStorageEngine } from './configuration-store';
+import { CONFIGURATION_KEY } from './storage-key-constants';
+
+/**
+ * Web Cache API implementation for storing configuration data.
+ *
+ * Uses the Cache API for better storage limits and performance compared to localStorage.
+ * Since the configuration response includes a createdAt timestamp, no separate metadata storage is needed.
+ */
+export class WebCacheStorageEngine implements IConfigurationStorageEngine {
+ private readonly cacheKey: string;
+ private readonly cacheName: string;
+
+ public constructor(storageKeySuffix = '') {
+ const keySuffix = storageKeySuffix ? `-${storageKeySuffix}` : '';
+ this.cacheName = `eppo-sdk${keySuffix}`;
+ this.cacheKey = CONFIGURATION_KEY + keySuffix;
+ }
+
+ public getContentsJsonString = async (): Promise => {
+ try {
+ // Check if Cache API is supported
+ if (typeof caches === 'undefined') {
+ return null;
+ }
+
+ const cache = await caches.open(this.cacheName);
+ const response = await cache.match(this.cacheKey);
+
+ if (!response) {
+ return null;
+ }
+
+ return await response.text();
+ } catch (error) {
+ console.warn('Failed to read from Web Cache API:', error);
+ return null;
+ }
+ };
+
+ public setContentsJsonString = async (configurationJsonString: string): Promise => {
+ try {
+ // Check if Cache API is supported
+ if (typeof caches === 'undefined') {
+ throw new Error('Cache API not supported');
+ }
+
+ const cache = await caches.open(this.cacheName);
+
+ // Create a Response object to store in cache
+ const response = new Response(configurationJsonString, {
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Cache-Stored-At': new Date().toISOString(),
+ },
+ });
+
+ await cache.put(this.cacheKey, response);
+ } catch (error) {
+ console.error('Failed to write to Web Cache API:', error);
+ throw error;
+ }
+ };
+
+ /**
+ * Check if the cached configuration has expired based on its createdAt timestamp
+ */
+ public async isExpired(cooldownSeconds: number): Promise {
+ if (!cooldownSeconds) {
+ return true;
+ }
+
+ try {
+ const configString = await this.getContentsJsonString();
+ if (!configString) {
+ return true;
+ }
+
+ const config = JSON.parse(configString);
+ const createdAt = config.createdAt;
+
+ if (!createdAt) {
+ return true;
+ }
+
+ const createdAtMs = new Date(createdAt).getTime();
+ return Date.now() - createdAtMs > cooldownSeconds * 1000;
+ } catch (error) {
+ console.warn('Failed to check cache expiration:', error);
+ return true;
+ }
+ }
+
+ /**
+ * Clear all cached data for this storage engine
+ */
+ public async clear(): Promise {
+ try {
+ if (typeof caches === 'undefined') {
+ return;
+ }
+
+ await caches.delete(this.cacheName);
+ } catch (error) {
+ console.warn('Failed to clear Web Cache API:', error);
+ }
+ }
+}
|