From 7d231b0733215e7e59ce81a8f834828082df5087 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Je=CC=81re=CC=81mie=20Richardeau?= Date: Tue, 30 Apr 2024 17:30:33 +0200 Subject: [PATCH 01/38] to remove: change main file --- package.json | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/package.json b/package.json index e131512..04d020f 100644 --- a/package.json +++ b/package.json @@ -3,19 +3,7 @@ "version": "3.3.2", "description": "A browser client that can be used together with the unleash-proxy.", "type": "module", - "main": "./build/index.cjs", - "types": "./build/cjs/index.d.ts", - "browser": "./build/main.min.js", - "module": "./build/main.esm.js", - "exports": { - ".": { - "import": "./build/main.esm.js", - "node": "./build/index.cjs", - "require": "./build/index.cjs", - "types": "./build/cjs/index.d.ts", - "default": "./build/main.min.js" - } - }, + "main": "./src/index.ts", "files": [ "build", "examples", From 2fdf1e0c9aff26b8fb2d180b7f8f16ce2070a1d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Je=CC=81re=CC=81mie=20Richardeau?= Date: Tue, 30 Apr 2024 17:31:00 +0200 Subject: [PATCH 02/38] save last update key after fetch --- src/index.ts | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/src/index.ts b/src/index.ts index 431bed7..f49dd25 100644 --- a/src/index.ts +++ b/src/index.ts @@ -52,6 +52,7 @@ interface IConfig extends IStaticContext { customHeaders?: Record; impressionDataAll?: boolean; usePOSTrequests?: boolean; + refreshOnlyIfExpired?: boolean; } interface IVariant { @@ -92,6 +93,7 @@ const defaultVariant: IVariant = { feature_enabled: false, }; const storeKey = 'repo'; +const lastUpdateKey = 'lastUpdate'; type SdkState = 'initializing' | 'healthy' | 'error'; @@ -165,6 +167,7 @@ export class UnleashClient extends TinyEmitter { customHeaders = {}, impressionDataAll = false, usePOSTrequests = false, + refreshOnlyIfExpired = false, }: IConfig) { super(); // Validations @@ -457,19 +460,22 @@ export class UnleashClient extends TinyEmitter { this.emit(EVENTS.RECOVERED); } - if (response.ok && response.status !== 304) { - this.etag = response.headers.get('ETag') || ''; - const data = await response.json(); - await this.storeToggles(data.toggles); + if (response.ok) { + if (response.status !== 304) { + this.etag = response.headers.get('ETag') || ''; + const data = await response.json(); + await this.storeToggles(data.toggles); - if (this.sdkState !== 'healthy') { - this.sdkState = 'healthy'; - } + if (this.sdkState !== 'healthy') { + this.sdkState = 'healthy'; + } - if (!this.bootstrap && !this.readyEventEmitted) { - this.emit(EVENTS.READY); - this.readyEventEmitted = true; + if (!this.bootstrap && !this.readyEventEmitted) { + this.emit(EVENTS.READY); + this.readyEventEmitted = true; + } } + this.storage.save(lastUpdateKey, Date.now()); } else if (!response.ok && response.status !== 304) { console.error( 'Unleash: Fetching feature toggles did not have an ok response' From 4f42346a0ba00acb522bb86546a394487996571c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Je=CC=81re=CC=81mie=20Richardeau?= Date: Thu, 2 May 2024 10:16:16 +0200 Subject: [PATCH 03/38] refactor to handle 304 status --- src/index.ts | 36 ++++++++++++++++++------------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/src/index.ts b/src/index.ts index f49dd25..ae1b314 100644 --- a/src/index.ts +++ b/src/index.ts @@ -460,23 +460,7 @@ export class UnleashClient extends TinyEmitter { this.emit(EVENTS.RECOVERED); } - if (response.ok) { - if (response.status !== 304) { - this.etag = response.headers.get('ETag') || ''; - const data = await response.json(); - await this.storeToggles(data.toggles); - - if (this.sdkState !== 'healthy') { - this.sdkState = 'healthy'; - } - - if (!this.bootstrap && !this.readyEventEmitted) { - this.emit(EVENTS.READY); - this.readyEventEmitted = true; - } - } - this.storage.save(lastUpdateKey, Date.now()); - } else if (!response.ok && response.status !== 304) { + if (!response.ok && response.status !== 304) { console.error( 'Unleash: Fetching feature toggles did not have an ok response' ); @@ -485,8 +469,24 @@ export class UnleashClient extends TinyEmitter { type: 'HttpError', code: response.status, }); + return; + } + if (response.status !== 304) { + this.etag = response.headers.get('ETag') || ''; + const data = await response.json(); + await this.storeToggles(data.toggles); + + if (this.sdkState !== 'healthy') { + this.sdkState = 'healthy'; + } + + if (!this.bootstrap && !this.readyEventEmitted) { + this.emit(EVENTS.READY); + this.readyEventEmitted = true; + } } - } catch (e) { + this.storage.save(lastUpdateKey, Date.now()); + } catch (e) { console.error('Unleash: unable to fetch feature toggles', e); this.sdkState = 'error'; this.emit(EVENTS.ERROR, e); From ce45d896373f486ec5355a7d0e279e909be3b10f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Je=CC=81re=CC=81mie=20Richardeau?= Date: Thu, 2 May 2024 10:18:00 +0200 Subject: [PATCH 04/38] unit test last update flag --- src/index.test.ts | 68 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/src/index.test.ts b/src/index.test.ts index 6b98866..27ffccf 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -553,6 +553,74 @@ test('Should publish ready when initial fetch completed', (done) => { }); }); +describe('handling last update flag storage', () => { + let storageProvider: IStorageProvider; + let saveSpy: jest.SpyInstance; + class Store implements IStorageProvider { + public async save() { + return Promise.resolve(); + } + + public async get() { + return Promise.resolve([]); + } + } + + beforeEach(() => { + storageProvider = new Store(); + saveSpy = jest.spyOn(storageProvider, 'save'); + }); + + test('Should store last update flag when fetch is successful', async () => { + const startTime = Date.now(); + fetchMock.mockResponseOnce(JSON.stringify(data)); + + const config: IConfig = { + url: 'http://localhost/test', + clientKey: '12', + appName: 'web', + storageProvider, + }; + + const client = new UnleashClient(config); + await client.start(); + expect(saveSpy).toHaveBeenCalledWith('lastUpdate', expect.any(Number)); + expect(saveSpy.mock.lastCall?.at(1)).toBeGreaterThanOrEqual(startTime); + }); + + test('Should store last update flag when fetch is successful with 304 status', async () => { + const startTime = Date.now(); + fetchMock.mockResponseOnce(JSON.stringify(data), { status: 304 }); + + const config: IConfig = { + url: 'http://localhost/test', + clientKey: '12', + appName: 'web', + storageProvider, + }; + + const client = new UnleashClient(config); + await client.start(); + expect(saveSpy).toHaveBeenCalledWith('lastUpdate', expect.any(Number)); + expect(saveSpy.mock.lastCall?.at(1)).toBeGreaterThanOrEqual(startTime); + }); + + test('Should not store last update flag when fetch is not successful', async () => { + fetchMock.mockResponseOnce('', { status: 500 }); + + const config: IConfig = { + url: 'http://localhost/test', + clientKey: '12', + appName: 'web', + storageProvider, + }; + + const client = new UnleashClient(config); + await client.start(); + expect(saveSpy).not.toHaveBeenCalledWith('lastUpdate', expect.any(Number)); + }); +}); + test('Should publish error when initial init fails', (done) => { const givenError = 'Error'; From bab500040f2fef334c1bbdf22a2cc89216b01c3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Je=CC=81re=CC=81mie=20Richardeau?= Date: Thu, 2 May 2024 17:10:10 +0200 Subject: [PATCH 05/38] handling isUpToDate --- src/index.ts | 27 ++++++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/src/index.ts b/src/index.ts index ae1b314..711707b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -52,7 +52,7 @@ interface IConfig extends IStaticContext { customHeaders?: Record; impressionDataAll?: boolean; usePOSTrequests?: boolean; - refreshOnlyIfExpired?: boolean; + toggleStorageTTL?: number; } interface IVariant { @@ -147,6 +147,7 @@ export class UnleashClient extends TinyEmitter { private usePOSTrequests = false; private started = false; private sdkState: SdkState; + private toggleStorageTTL: number; constructor({ storageProvider, @@ -167,7 +168,7 @@ export class UnleashClient extends TinyEmitter { customHeaders = {}, impressionDataAll = false, usePOSTrequests = false, - refreshOnlyIfExpired = false, + toggleStorageTTL = 0, }: IConfig) { super(); // Validations @@ -196,6 +197,7 @@ export class UnleashClient extends TinyEmitter { this.context = { appName, environment, ...context }; this.usePOSTrequests = usePOSTrequests; this.sdkState = 'initializing'; + this.toggleStorageTTL = toggleStorageTTL; this.ready = new Promise((resolve) => { this.init() .then(resolve) @@ -284,7 +286,7 @@ export class UnleashClient extends TinyEmitter { return { ...variant, feature_enabled: enabled }; } - private async updateToggles() { + public async updateToggles() { if (this.timerRef || this.readyEventEmitted) { await this.fetchToggles(); } else if (this.started) { @@ -426,8 +428,27 @@ export class UnleashClient extends TinyEmitter { await this.storage.save(storeKey, toggles); } + private async isUpToDate() { + if (this.toggleStorageTTL <= 0 || this.toggles.length === 0) { + return false; + } + const timestamp = Date.now(); + const unleashRefreshIntervalMs = this.toggleStorageTTL * 60 * 1000; + + const lastRefresh = await this.storage.get(lastUpdateKey); + + return !!(lastRefresh && timestamp - lastRefresh <= unleashRefreshIntervalMs); + } + private async fetchToggles() { if (this.fetch) { + if (await this.isUpToDate()) { + if (!this.bootstrap && !this.readyEventEmitted) { + this.emit(EVENTS.READY); + this.readyEventEmitted = true; + } + return; + } if (this.abortController) { this.abortController.abort(); } From a181bd5c4639aa01a3ec2000b6bb825a0453ebaf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Je=CC=81re=CC=81mie=20Richardeau?= Date: Thu, 2 May 2024 17:10:23 +0200 Subject: [PATCH 06/38] update test --- src/index.test.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/index.test.ts b/src/index.test.ts index 27ffccf..643f7ce 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -1571,9 +1571,21 @@ test('Should publish ready only when the first fetch was successful', async () = expect(readyCount).toEqual(1); }); + let fetchCount = 0; + + const fetchedPromise = new Promise((resolve) => { + client.on(EVENTS.UPDATE, () => { + fetchCount++; + if (fetchCount === 2) { + resolve(); + } + }); + }); + jest.advanceTimersByTime(1001); jest.advanceTimersByTime(1001); + await fetchedPromise; expect(fetchMock).toHaveBeenCalledTimes(3); }); From c9a1677abb93f45deae59d7f12868ba18deff7c0 Mon Sep 17 00:00:00 2001 From: Florent Date: Fri, 3 May 2024 11:37:29 +0200 Subject: [PATCH 07/38] fix existing test not working --- src/index.test.ts | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/src/index.test.ts b/src/index.test.ts index 643f7ce..9d152a2 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -570,11 +570,11 @@ describe('handling last update flag storage', () => { storageProvider = new Store(); saveSpy = jest.spyOn(storageProvider, 'save'); }); - + test('Should store last update flag when fetch is successful', async () => { const startTime = Date.now(); fetchMock.mockResponseOnce(JSON.stringify(data)); - + const config: IConfig = { url: 'http://localhost/test', clientKey: '12', @@ -591,14 +591,14 @@ describe('handling last update flag storage', () => { test('Should store last update flag when fetch is successful with 304 status', async () => { const startTime = Date.now(); fetchMock.mockResponseOnce(JSON.stringify(data), { status: 304 }); - + const config: IConfig = { url: 'http://localhost/test', clientKey: '12', appName: 'web', storageProvider, }; - + const client = new UnleashClient(config); await client.start(); expect(saveSpy).toHaveBeenCalledWith('lastUpdate', expect.any(Number)); @@ -607,14 +607,14 @@ describe('handling last update flag storage', () => { test('Should not store last update flag when fetch is not successful', async () => { fetchMock.mockResponseOnce('', { status: 500 }); - + const config: IConfig = { url: 'http://localhost/test', clientKey: '12', appName: 'web', storageProvider, }; - + const client = new UnleashClient(config); await client.start(); expect(saveSpy).not.toHaveBeenCalledWith('lastUpdate', expect.any(Number)); @@ -1551,15 +1551,16 @@ test('Should emit impression events on getVariant calls when impressionData is f }); test('Should publish ready only when the first fetch was successful', async () => { + expect.assertions(3); fetchMock.mockResponse(JSON.stringify(data)); const config: IConfig = { url: 'http://localhost/test', clientKey: '12', appName: 'web', refreshInterval: 1, + disableMetrics: true, }; const client = new UnleashClient(config); - await client.start(); let readyCount = 0; @@ -1581,6 +1582,7 @@ test('Should publish ready only when the first fetch was successful', async () = } }); }); + await client.start(); jest.advanceTimersByTime(1001); jest.advanceTimersByTime(1001); From 3683bed7f67a9c88d3eea733268c6c3c111b6601 Mon Sep 17 00:00:00 2001 From: Florent Date: Fri, 3 May 2024 12:18:08 +0200 Subject: [PATCH 08/38] Store lastRefreshTimestamp to prevent async --- src/index.test.ts | 13 +------------ src/index.ts | 22 +++++++++++++++------- 2 files changed, 16 insertions(+), 19 deletions(-) diff --git a/src/index.test.ts b/src/index.test.ts index 9d152a2..fa88787 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -581,7 +581,7 @@ describe('handling last update flag storage', () => { appName: 'web', storageProvider, }; - + const client = new UnleashClient(config); await client.start(); expect(saveSpy).toHaveBeenCalledWith('lastUpdate', expect.any(Number)); @@ -1572,22 +1572,11 @@ test('Should publish ready only when the first fetch was successful', async () = expect(readyCount).toEqual(1); }); - let fetchCount = 0; - - const fetchedPromise = new Promise((resolve) => { - client.on(EVENTS.UPDATE, () => { - fetchCount++; - if (fetchCount === 2) { - resolve(); - } - }); - }); await client.start(); jest.advanceTimersByTime(1001); jest.advanceTimersByTime(1001); - await fetchedPromise; expect(fetchMock).toHaveBeenCalledTimes(3); }); diff --git a/src/index.ts b/src/index.ts index 711707b..4061fd0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -148,6 +148,7 @@ export class UnleashClient extends TinyEmitter { private started = false; private sdkState: SdkState; private toggleStorageTTL: number; + private lastRefreshTimestamp: number; constructor({ storageProvider, @@ -198,6 +199,8 @@ export class UnleashClient extends TinyEmitter { this.usePOSTrequests = usePOSTrequests; this.sdkState = 'initializing'; this.toggleStorageTTL = toggleStorageTTL; + this.lastRefreshTimestamp = 0; + this.ready = new Promise((resolve) => { this.init() .then(resolve) @@ -350,6 +353,7 @@ export class UnleashClient extends TinyEmitter { this.context = { sessionId, ...this.context }; this.toggles = (await this.storage.get(storeKey)) || []; + this.lastRefreshTimestamp = await this.storage.get(lastUpdateKey); if ( this.bootstrap && @@ -428,21 +432,24 @@ export class UnleashClient extends TinyEmitter { await this.storage.save(storeKey, toggles); } - private async isUpToDate() { + private isUpToDate() { if (this.toggleStorageTTL <= 0 || this.toggles.length === 0) { return false; } const timestamp = Date.now(); const unleashRefreshIntervalMs = this.toggleStorageTTL * 60 * 1000; - - const lastRefresh = await this.storage.get(lastUpdateKey); - return !!(lastRefresh && timestamp - lastRefresh <= unleashRefreshIntervalMs); - } + return !!(this.lastRefreshTimestamp && timestamp - this.lastRefreshTimestamp <= unleashRefreshIntervalMs); + } + + private async updateLastRefresh() { + this.lastRefreshTimestamp = Date.now(); + this.storage.save(lastUpdateKey, this.lastRefreshTimestamp); + } private async fetchToggles() { if (this.fetch) { - if (await this.isUpToDate()) { + if (this.isUpToDate()) { if (!this.bootstrap && !this.readyEventEmitted) { this.emit(EVENTS.READY); this.readyEventEmitted = true; @@ -506,7 +513,8 @@ export class UnleashClient extends TinyEmitter { this.readyEventEmitted = true; } } - this.storage.save(lastUpdateKey, Date.now()); + + this.updateLastRefresh(); } catch (e) { console.error('Unleash: unable to fetch feature toggles', e); this.sdkState = 'error'; From 195c4385ad9a88991104bc2b191107f608d2513c Mon Sep 17 00:00:00 2001 From: Florent Date: Fri, 3 May 2024 12:27:08 +0200 Subject: [PATCH 09/38] fix toggleStorageTTL unit --- src/index.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/index.ts b/src/index.ts index 4061fd0..d527e56 100644 --- a/src/index.ts +++ b/src/index.ts @@ -198,7 +198,7 @@ export class UnleashClient extends TinyEmitter { this.context = { appName, environment, ...context }; this.usePOSTrequests = usePOSTrequests; this.sdkState = 'initializing'; - this.toggleStorageTTL = toggleStorageTTL; + this.toggleStorageTTL = toggleStorageTTL * 1000; this.lastRefreshTimestamp = 0; this.ready = new Promise((resolve) => { @@ -437,9 +437,8 @@ export class UnleashClient extends TinyEmitter { return false; } const timestamp = Date.now(); - const unleashRefreshIntervalMs = this.toggleStorageTTL * 60 * 1000; - return !!(this.lastRefreshTimestamp && timestamp - this.lastRefreshTimestamp <= unleashRefreshIntervalMs); + return !!(this.lastRefreshTimestamp && timestamp - this.lastRefreshTimestamp <= this.toggleStorageTTL); } private async updateLastRefresh() { From ebcea498fd550a1249451040e7d14bfbf6b24778 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Je=CC=81re=CC=81mie=20Richardeau?= Date: Fri, 3 May 2024 14:59:01 +0200 Subject: [PATCH 10/38] refactor: rename option --- src/index.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/index.ts b/src/index.ts index d527e56..2caa618 100644 --- a/src/index.ts +++ b/src/index.ts @@ -52,7 +52,7 @@ interface IConfig extends IStaticContext { customHeaders?: Record; impressionDataAll?: boolean; usePOSTrequests?: boolean; - toggleStorageTTL?: number; + togglesStorageTTL?: number; } interface IVariant { @@ -147,7 +147,7 @@ export class UnleashClient extends TinyEmitter { private usePOSTrequests = false; private started = false; private sdkState: SdkState; - private toggleStorageTTL: number; + private togglesStorageTTL: number; private lastRefreshTimestamp: number; constructor({ @@ -169,7 +169,7 @@ export class UnleashClient extends TinyEmitter { customHeaders = {}, impressionDataAll = false, usePOSTrequests = false, - toggleStorageTTL = 0, + togglesStorageTTL = 0, }: IConfig) { super(); // Validations @@ -198,7 +198,7 @@ export class UnleashClient extends TinyEmitter { this.context = { appName, environment, ...context }; this.usePOSTrequests = usePOSTrequests; this.sdkState = 'initializing'; - this.toggleStorageTTL = toggleStorageTTL * 1000; + this.togglesStorageTTL = togglesStorageTTL * 1000; this.lastRefreshTimestamp = 0; this.ready = new Promise((resolve) => { @@ -433,12 +433,12 @@ export class UnleashClient extends TinyEmitter { } private isUpToDate() { - if (this.toggleStorageTTL <= 0 || this.toggles.length === 0) { + if (this.togglesStorageTTL <= 0 || this.toggles.length === 0) { return false; } const timestamp = Date.now(); - return !!(this.lastRefreshTimestamp && timestamp - this.lastRefreshTimestamp <= this.toggleStorageTTL); + return !!(this.lastRefreshTimestamp && timestamp - this.lastRefreshTimestamp <= this.togglesStorageTTL); } private async updateLastRefresh() { From 2f46c6ba6a193b657eb42918aed6579193944566 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Je=CC=81re=CC=81mie=20Richardeau?= Date: Fri, 3 May 2024 15:38:54 +0200 Subject: [PATCH 11/38] unit test togglesStorage initial fetch --- src/index.test.ts | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/src/index.test.ts b/src/index.test.ts index fa88787..350c5c2 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -7,6 +7,7 @@ import { IConfig, IMutableContext, IToggle, + InMemoryStorageProvider, UnleashClient, } from './index'; import { getTypeSafeRequest, getTypeSafeRequestUrl } from './test'; @@ -1737,3 +1738,41 @@ test('Should set sdkState to healthy when client is started', (done) => { done(); }); }); + +describe('handling togglesStorageTTL', () => { + let storage: IStorageProvider; + beforeEach(async () => { + storage = new InMemoryStorageProvider(); + + fetchMock.mockResponseOnce(JSON.stringify(data)); + const config: IConfig = { + url: 'http://localhost/test', + clientKey: '12', + appName: 'web', + storageProvider: storage, + }; + // performing an initial fetch to populate the toggles and lastUpdate timestamp + const client = new UnleashClient(config); + await client.start(); + client.stop(); + + expect(fetchMock).toHaveBeenCalledTimes(1); + fetchMock.mockReset(); + }); + + test('Should not perform an initial fetch when toggles are up to date and togglesStorageTTL > 0', async () => { + const config: IConfig = { + url: 'http://localhost/test', + clientKey: '12', + appName: 'web', + storageProvider: storage, + togglesStorageTTL: 60, + }; + const client = new UnleashClient(config); + await client.start(); + const isEnabled = client.isEnabled('simpleToggle'); + expect(isEnabled).toBe(true); + client.stop(); + expect(fetchMock).toHaveBeenCalledTimes(0); + }); +}); \ No newline at end of file From ca8ebb9617fd04b5a7dd6017fe43ecc22fe3613b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Je=CC=81re=CC=81mie=20Richardeau?= Date: Fri, 3 May 2024 15:46:07 +0200 Subject: [PATCH 12/38] refactor: emit ready method --- src/index.ts | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/src/index.ts b/src/index.ts index 2caa618..b028533 100644 --- a/src/index.ts +++ b/src/index.ts @@ -449,10 +449,7 @@ export class UnleashClient extends TinyEmitter { private async fetchToggles() { if (this.fetch) { if (this.isUpToDate()) { - if (!this.bootstrap && !this.readyEventEmitted) { - this.emit(EVENTS.READY); - this.readyEventEmitted = true; - } + this.emitReady(); return; } if (this.abortController) { @@ -507,10 +504,7 @@ export class UnleashClient extends TinyEmitter { this.sdkState = 'healthy'; } - if (!this.bootstrap && !this.readyEventEmitted) { - this.emit(EVENTS.READY); - this.readyEventEmitted = true; - } + this.emitReady(); } this.updateLastRefresh(); @@ -523,6 +517,13 @@ export class UnleashClient extends TinyEmitter { } } } + + private emitReady() { + if (!this.bootstrap && !this.readyEventEmitted) { + this.emit(EVENTS.READY); + this.readyEventEmitted = true; + } + } } // export storage providers from root module From 94634750811f20bf130cc18436defe1159165beb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Je=CC=81re=CC=81mie=20Richardeau?= Date: Fri, 3 May 2024 15:46:41 +0200 Subject: [PATCH 13/38] unit test togglesStorage ready --- src/index.test.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/index.test.ts b/src/index.test.ts index 350c5c2..b1a8042 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -1775,4 +1775,20 @@ describe('handling togglesStorageTTL', () => { client.stop(); expect(fetchMock).toHaveBeenCalledTimes(0); }); + + test('Should send ready event when toggles are up to date and togglesStorageTTL > 0', async () => { + const config: IConfig = { + url: 'http://localhost/test', + clientKey: '12', + appName: 'web', + storageProvider: storage, + togglesStorageTTL: 60, + }; + const client = new UnleashClient(config); + + const readySpy = jest.fn(); + client.on('ready', readySpy); + await client.start(); + expect(readySpy).toHaveBeenCalledTimes(1); + }); }); \ No newline at end of file From 9f6a10c09098b427df38aad96835bcae7a551b7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Je=CC=81re=CC=81mie=20Richardeau?= Date: Fri, 3 May 2024 15:59:41 +0200 Subject: [PATCH 14/38] unit test ready not sent with bootstrap option --- src/index.test.ts | 40 ++++++++++++++++++++++++++++++++++++---- 1 file changed, 36 insertions(+), 4 deletions(-) diff --git a/src/index.test.ts b/src/index.test.ts index b1a8042..cd37ac3 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -1739,7 +1739,7 @@ test('Should set sdkState to healthy when client is started', (done) => { }); }); -describe('handling togglesStorageTTL', () => { +describe('handling togglesStorageTTL > 0', () => { let storage: IStorageProvider; beforeEach(async () => { storage = new InMemoryStorageProvider(); @@ -1760,7 +1760,7 @@ describe('handling togglesStorageTTL', () => { fetchMock.mockReset(); }); - test('Should not perform an initial fetch when toggles are up to date and togglesStorageTTL > 0', async () => { + test('Should not perform an initial fetch when toggles are up to date', async () => { const config: IConfig = { url: 'http://localhost/test', clientKey: '12', @@ -1776,7 +1776,7 @@ describe('handling togglesStorageTTL', () => { expect(fetchMock).toHaveBeenCalledTimes(0); }); - test('Should send ready event when toggles are up to date and togglesStorageTTL > 0', async () => { + test('Should send ready event when toggles are up to date', async () => { const config: IConfig = { url: 'http://localhost/test', clientKey: '12', @@ -1787,8 +1787,40 @@ describe('handling togglesStorageTTL', () => { const client = new UnleashClient(config); const readySpy = jest.fn(); - client.on('ready', readySpy); + client.on(EVENTS.READY, readySpy); + client.on(EVENTS.INIT, () => readySpy.mockClear()); await client.start(); expect(readySpy).toHaveBeenCalledTimes(1); }); + + test('Should not send ready event when bootstrap option is defined', async () => { + const bootstrap = [ + { + name: 'toggles', + enabled: true, + variant: { + name: 'disabled', + enabled: false, + feature_enabled: true, + }, + impressionData: true, + } + ]; + const config: IConfig = { + url: 'http://localhost/test', + clientKey: '12', + appName: 'web', + storageProvider: storage, + bootstrap, + togglesStorageTTL: 60, + }; + const client = new UnleashClient(config); + + const readySpy = jest.fn(); + // clearing "ready" sent by init() when bootstrap is defined + client.on(EVENTS.INIT, () => readySpy.mockClear()); + client.on(EVENTS.READY, readySpy); + await client.start(); + expect(readySpy).not.toHaveBeenCalled(); + }); }); \ No newline at end of file From cee9f512caa08765df098421a97982ea3cae80c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Je=CC=81re=CC=81mie=20Richardeau?= Date: Fri, 3 May 2024 16:13:25 +0200 Subject: [PATCH 15/38] unit test fetch when expired --- src/index.test.ts | 42 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 40 insertions(+), 2 deletions(-) diff --git a/src/index.test.ts b/src/index.test.ts index cd37ac3..6daeebe 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -1741,10 +1741,25 @@ test('Should set sdkState to healthy when client is started', (done) => { describe('handling togglesStorageTTL > 0', () => { let storage: IStorageProvider; + let fakeNow: number; beforeEach(async () => { + fakeNow = new Date('2024-01-01').getTime(); + jest.useFakeTimers(); + jest.setSystemTime(fakeNow); storage = new InMemoryStorageProvider(); - fetchMock.mockResponseOnce(JSON.stringify(data)); + fetchMock + .mockResponseOnce(JSON.stringify(data)) + .mockResponseOnce(JSON.stringify({ + "toggles": [ + { + "name": "simpleToggle", + "enabled": false, + "impressionData": true + } + ] + })); + const config: IConfig = { url: 'http://localhost/test', clientKey: '12', @@ -1757,10 +1772,15 @@ describe('handling togglesStorageTTL > 0', () => { client.stop(); expect(fetchMock).toHaveBeenCalledTimes(1); - fetchMock.mockReset(); + fetchMock.mockClear(); + }); + + afterEach(() => { + jest.useRealTimers(); }); test('Should not perform an initial fetch when toggles are up to date', async () => { + jest.setSystemTime(fakeNow + 59000); const config: IConfig = { url: 'http://localhost/test', clientKey: '12', @@ -1776,6 +1796,24 @@ describe('handling togglesStorageTTL > 0', () => { expect(fetchMock).toHaveBeenCalledTimes(0); }); + test('Should perform an initial fetch when toggles are expired', async () => { + jest.setSystemTime(fakeNow + 61000); + + const config: IConfig = { + url: 'http://localhost/test', + clientKey: '12', + appName: 'web', + storageProvider: storage, + togglesStorageTTL: 60, + }; + const client = new UnleashClient(config); + await client.start(); + const isEnabled = client.isEnabled('simpleToggle'); + expect(isEnabled).toBe(false); + client.stop(); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + test('Should send ready event when toggles are up to date', async () => { const config: IConfig = { url: 'http://localhost/test', From 95f126cc6d33f03045e8e13c3b81a5f456fb7c0b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Je=CC=81re=CC=81mie=20Richardeau?= Date: Fri, 3 May 2024 16:21:22 +0200 Subject: [PATCH 16/38] documentation --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index e3a8b5d..c8468f2 100644 --- a/README.md +++ b/README.md @@ -132,6 +132,7 @@ The Unleash SDK takes the following options: | customHeaders | no| `{}` | Additional headers to use when making HTTP requests to the Unleash proxy. In case of name collisions with the default headers, the `customHeaders` value will be used if it is not `null` or `undefined`. `customHeaders` values that are `null` or `undefined` will be ignored. | | impressionDataAll | no| `false` | Allows you to trigger "impression" events for **all** `getToggle` and `getVariant` invocations. This is particularly useful for "disabled" feature toggles that are not visible to frontend SDKs. | | environment | no | `default` | Sets the `environment` option of the [Unleash context](https://docs.getunleash.io/reference/unleash-context). This does **not** affect the SDK's [Unleash environment](https://docs.getunleash.io/reference/environments). | +| togglesStorageTTL | no | `0` | How much time, in seconds, the toggles are considered valid and should not be fetched again. If set to 0 will disable checking for expiration and considered always expired | ### Listen for updates via the EventEmitter From 5ace0d987f15f9fffcf141eca6f0b93c31fa65f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Je=CC=81re=CC=81mie=20Richardeau?= Date: Mon, 6 May 2024 10:25:25 +0200 Subject: [PATCH 17/38] improve readme --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index c8468f2..017c907 100644 --- a/README.md +++ b/README.md @@ -132,7 +132,7 @@ The Unleash SDK takes the following options: | customHeaders | no| `{}` | Additional headers to use when making HTTP requests to the Unleash proxy. In case of name collisions with the default headers, the `customHeaders` value will be used if it is not `null` or `undefined`. `customHeaders` values that are `null` or `undefined` will be ignored. | | impressionDataAll | no| `false` | Allows you to trigger "impression" events for **all** `getToggle` and `getVariant` invocations. This is particularly useful for "disabled" feature toggles that are not visible to frontend SDKs. | | environment | no | `default` | Sets the `environment` option of the [Unleash context](https://docs.getunleash.io/reference/unleash-context). This does **not** affect the SDK's [Unleash environment](https://docs.getunleash.io/reference/environments). | -| togglesStorageTTL | no | `0` | How much time, in seconds, the toggles are considered valid and should not be fetched again. If set to 0 will disable checking for expiration and considered always expired | +| togglesStorageTTL | no | `0` | How much time (Time To Live), in seconds, the toggles in storage are considered valid and should not be fetched again. If set to 0 will disable checking for expiration and considered always expired | ### Listen for updates via the EventEmitter From c2f34e2ab91150776257198de9ff1ac8dc731061 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Je=CC=81re=CC=81mie=20Richardeau?= Date: Mon, 6 May 2024 10:43:52 +0200 Subject: [PATCH 18/38] restore package.json --- package.json | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 04d020f..3aa7f6b 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,19 @@ "version": "3.3.2", "description": "A browser client that can be used together with the unleash-proxy.", "type": "module", - "main": "./src/index.ts", + "main": "./build/index.cjs", + "types": "./build/cjs/index.d.ts", + "browser": "./build/main.min.js", + "module": "./build/main.esm.js", + "exports": { + ".": { + "import": "./build/main.esm.js", + "node": "./build/index.cjs", + "require": "./build/index.cjs", + "types": "./build/cjs/index.d.ts", + "default": "./build/main.min.js" + } + }, "files": [ "build", "examples", From cf12da629e94ab8b2dd4cca6a5fb2e8fcf94547c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Je=CC=81re=CC=81mie=20Richardeau?= Date: Mon, 6 May 2024 10:44:25 +0200 Subject: [PATCH 19/38] revert public method --- src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/index.ts b/src/index.ts index b028533..0f0f419 100644 --- a/src/index.ts +++ b/src/index.ts @@ -289,7 +289,7 @@ export class UnleashClient extends TinyEmitter { return { ...variant, feature_enabled: enabled }; } - public async updateToggles() { + private async updateToggles() { if (this.timerRef || this.readyEventEmitted) { await this.fetchToggles(); } else if (this.started) { From 63d22bf47b31d86ffd73371e584d43ff16039b81 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Je=CC=81re=CC=81mie=20Richardeau?= Date: Mon, 6 May 2024 12:25:33 +0200 Subject: [PATCH 20/38] restore package --- package.json | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/package.json b/package.json index 3aa7f6b..e131512 100644 --- a/package.json +++ b/package.json @@ -4,17 +4,17 @@ "description": "A browser client that can be used together with the unleash-proxy.", "type": "module", "main": "./build/index.cjs", - "types": "./build/cjs/index.d.ts", - "browser": "./build/main.min.js", - "module": "./build/main.esm.js", - "exports": { - ".": { - "import": "./build/main.esm.js", - "node": "./build/index.cjs", - "require": "./build/index.cjs", - "types": "./build/cjs/index.d.ts", - "default": "./build/main.min.js" - } + "types": "./build/cjs/index.d.ts", + "browser": "./build/main.min.js", + "module": "./build/main.esm.js", + "exports": { + ".": { + "import": "./build/main.esm.js", + "node": "./build/index.cjs", + "require": "./build/index.cjs", + "types": "./build/cjs/index.d.ts", + "default": "./build/main.min.js" + } }, "files": [ "build", From 8c921afc6459417c75025ce88bed3f68cb350b1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Je=CC=81re=CC=81mie=20Richardeau?= Date: Mon, 6 May 2024 12:28:17 +0200 Subject: [PATCH 21/38] readme --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 017c907..dffc50b 100644 --- a/README.md +++ b/README.md @@ -132,7 +132,7 @@ The Unleash SDK takes the following options: | customHeaders | no| `{}` | Additional headers to use when making HTTP requests to the Unleash proxy. In case of name collisions with the default headers, the `customHeaders` value will be used if it is not `null` or `undefined`. `customHeaders` values that are `null` or `undefined` will be ignored. | | impressionDataAll | no| `false` | Allows you to trigger "impression" events for **all** `getToggle` and `getVariant` invocations. This is particularly useful for "disabled" feature toggles that are not visible to frontend SDKs. | | environment | no | `default` | Sets the `environment` option of the [Unleash context](https://docs.getunleash.io/reference/unleash-context). This does **not** affect the SDK's [Unleash environment](https://docs.getunleash.io/reference/environments). | -| togglesStorageTTL | no | `0` | How much time (Time To Live), in seconds, the toggles in storage are considered valid and should not be fetched again. If set to 0 will disable checking for expiration and considered always expired | +| togglesStorageTTL | no | `0` | How long (Time To Live), in seconds, the toggles in storage are considered valid and should not be fetched again. If set to 0 will disable expiration checking and will be considered always expired. | ### Listen for updates via the EventEmitter From c225d21fcb14abd7410c1326dd6baa40b53012b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Je=CC=81re=CC=81mie=20Richardeau?= Date: Mon, 6 May 2024 14:37:30 +0200 Subject: [PATCH 22/38] fix: linting --- src/index.test.ts | 35 +++++++++++++++++++---------------- src/index.ts | 7 +++++-- 2 files changed, 24 insertions(+), 18 deletions(-) diff --git a/src/index.test.ts b/src/index.test.ts index 6daeebe..990aafd 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -618,7 +618,10 @@ describe('handling last update flag storage', () => { const client = new UnleashClient(config); await client.start(); - expect(saveSpy).not.toHaveBeenCalledWith('lastUpdate', expect.any(Number)); + expect(saveSpy).not.toHaveBeenCalledWith( + 'lastUpdate', + expect.any(Number) + ); }); }); @@ -1747,18 +1750,18 @@ describe('handling togglesStorageTTL > 0', () => { jest.useFakeTimers(); jest.setSystemTime(fakeNow); storage = new InMemoryStorageProvider(); - - fetchMock - .mockResponseOnce(JSON.stringify(data)) - .mockResponseOnce(JSON.stringify({ - "toggles": [ - { - "name": "simpleToggle", - "enabled": false, - "impressionData": true - } - ] - })); + + fetchMock.mockResponseOnce(JSON.stringify(data)).mockResponseOnce( + JSON.stringify({ + toggles: [ + { + name: 'simpleToggle', + enabled: false, + impressionData: true, + }, + ], + }) + ); const config: IConfig = { url: 'http://localhost/test', @@ -1770,7 +1773,7 @@ describe('handling togglesStorageTTL > 0', () => { const client = new UnleashClient(config); await client.start(); client.stop(); - + expect(fetchMock).toHaveBeenCalledTimes(1); fetchMock.mockClear(); }); @@ -1842,7 +1845,7 @@ describe('handling togglesStorageTTL > 0', () => { feature_enabled: true, }, impressionData: true, - } + }, ]; const config: IConfig = { url: 'http://localhost/test', @@ -1861,4 +1864,4 @@ describe('handling togglesStorageTTL > 0', () => { await client.start(); expect(readySpy).not.toHaveBeenCalled(); }); -}); \ No newline at end of file +}); diff --git a/src/index.ts b/src/index.ts index 0f0f419..ae9074c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -438,7 +438,10 @@ export class UnleashClient extends TinyEmitter { } const timestamp = Date.now(); - return !!(this.lastRefreshTimestamp && timestamp - this.lastRefreshTimestamp <= this.togglesStorageTTL); + return !!( + this.lastRefreshTimestamp && + timestamp - this.lastRefreshTimestamp <= this.togglesStorageTTL + ); } private async updateLastRefresh() { @@ -508,7 +511,7 @@ export class UnleashClient extends TinyEmitter { } this.updateLastRefresh(); - } catch (e) { + } catch (e) { console.error('Unleash: unable to fetch feature toggles', e); this.sdkState = 'error'; this.emit(EVENTS.ERROR, e); From ad853c20aba11cadc671cfc4221dae366df1fa09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Je=CC=81re=CC=81mie=20Richardeau?= Date: Tue, 7 May 2024 10:03:34 +0200 Subject: [PATCH 23/38] fix: lint --- src/index.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/index.test.ts b/src/index.test.ts index 2000a5d..96befdc 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -1742,7 +1742,6 @@ test('Should set sdkState to healthy when client is started', (done) => { }); }); - describe('READY event emission', () => { let client: UnleashClient; From 2d264a6dbef9343dce424f898104271080f64922 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Je=CC=81re=CC=81mie=20Richardeau?= Date: Fri, 10 May 2024 10:52:23 +0200 Subject: [PATCH 24/38] refactor: update timestamp key --- src/index.test.ts | 4 ++-- src/index.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/index.test.ts b/src/index.test.ts index 96befdc..2a80eba 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -585,7 +585,7 @@ describe('handling last update flag storage', () => { const client = new UnleashClient(config); await client.start(); - expect(saveSpy).toHaveBeenCalledWith('lastUpdate', expect.any(Number)); + expect(saveSpy).toHaveBeenCalledWith('repoLastUpdateTimestamp', expect.any(Number)); expect(saveSpy.mock.lastCall?.at(1)).toBeGreaterThanOrEqual(startTime); }); @@ -602,7 +602,7 @@ describe('handling last update flag storage', () => { const client = new UnleashClient(config); await client.start(); - expect(saveSpy).toHaveBeenCalledWith('lastUpdate', expect.any(Number)); + expect(saveSpy).toHaveBeenCalledWith('repoLastUpdateTimestamp', expect.any(Number)); expect(saveSpy.mock.lastCall?.at(1)).toBeGreaterThanOrEqual(startTime); }); diff --git a/src/index.ts b/src/index.ts index 184aca8..8d50f10 100644 --- a/src/index.ts +++ b/src/index.ts @@ -94,7 +94,7 @@ const defaultVariant: IVariant = { feature_enabled: false, }; const storeKey = 'repo'; -const lastUpdateKey = 'lastUpdate'; +const lastUpdateKey = 'repoLastUpdateTimestamp'; type SdkState = 'initializing' | 'healthy' | 'error'; From 15569000be5366094fe6a1e4e5b231c3b09fc52f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Je=CC=81re=CC=81mie=20Richardeau?= Date: Fri, 10 May 2024 10:56:14 +0200 Subject: [PATCH 25/38] test: failing test that cover the bug --- src/index.test.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/index.test.ts b/src/index.test.ts index 2a80eba..35d3039 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -1882,4 +1882,22 @@ describe('handling togglesStorageTTL > 0', () => { await client.start(); expect(readySpy).toHaveBeenCalledTimes(1); }); + + test('Should perform a fetch when context is updated, even if flags are up to date', async () => { + const config: IConfig = { + url: 'http://localhost/test', + clientKey: '12', + appName: 'web', + storageProvider: storage, + togglesStorageTTL: 60, + }; + const client = new UnleashClient(config); + await client.start(); + let isEnabled = client.isEnabled('simpleToggle'); + expect(isEnabled).toBe(true); + await client.updateContext({ userId: '123' }); + expect(fetchMock).toHaveBeenCalledTimes(1); + isEnabled = client.isEnabled('simpleToggle'); + expect(isEnabled).toBe(false); + }); }); From bff828246d7db3a07eb856e70e39527cd12bd440 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Je=CC=81re=CC=81mie=20Richardeau?= Date: Fri, 10 May 2024 10:58:05 +0200 Subject: [PATCH 26/38] fix: can be up to date even with no flags --- src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/index.ts b/src/index.ts index 8d50f10..3f1b2f4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -436,7 +436,7 @@ export class UnleashClient extends TinyEmitter { } private isUpToDate() { - if (this.togglesStorageTTL <= 0 || this.toggles.length === 0) { + if (this.togglesStorageTTL <= 0) { return false; } const timestamp = Date.now(); From b595afc2df6c611f721920ea5cb41d20293d79e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Je=CC=81re=CC=81mie=20Richardeau?= Date: Fri, 10 May 2024 11:01:51 +0200 Subject: [PATCH 27/38] refactor: create initialFetchToggles to be used on start only --- src/index.ts | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/src/index.ts b/src/index.ts index 3f1b2f4..99262de 100644 --- a/src/index.ts +++ b/src/index.ts @@ -383,7 +383,7 @@ export class UnleashClient extends TinyEmitter { this.metrics.start(); const interval = this.refreshInterval; - await this.fetchToggles(); + await this.initialFetchToggles(); if (interval > 0) { this.timerRef = setInterval(() => this.fetchToggles(), interval); @@ -447,17 +447,21 @@ export class UnleashClient extends TinyEmitter { ); } - private async updateLastRefresh() { + private async updateLastRefreshTimestamp() { this.lastRefreshTimestamp = Date.now(); this.storage.save(lastUpdateKey, this.lastRefreshTimestamp); } + private initialFetchToggles() { + if (this.isUpToDate()) { + this.emitReady(); + return; + } + return this.fetchToggles(); + } + private async fetchToggles() { if (this.fetch) { - if (this.isUpToDate()) { - this.emitReady(); - return; - } if (this.abortController) { this.abortController.abort(); } @@ -499,9 +503,8 @@ export class UnleashClient extends TinyEmitter { type: 'HttpError', code: response.status, }); - return; } - if (response.status !== 304) { + else if (response.status !== 304) { this.etag = response.headers.get('ETag') || ''; const data = await response.json(); await this.storeToggles(data.toggles); @@ -513,7 +516,7 @@ export class UnleashClient extends TinyEmitter { this.emitReady(); } - this.updateLastRefresh(); + this.updateLastRefreshTimestamp(); } catch (e) { console.error('Unleash: unable to fetch feature toggles', e); this.sdkState = 'error'; From 0fc23d1b3a3f96efc34efc4e1ad7e5acb823c613 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Je=CC=81re=CC=81mie=20Richardeau?= Date: Fri, 10 May 2024 11:38:42 +0200 Subject: [PATCH 28/38] doc: update readme --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index db00d3b..854911a 100644 --- a/README.md +++ b/README.md @@ -133,7 +133,7 @@ The Unleash SDK takes the following options: | customHeaders | no| `{}` | Additional headers to use when making HTTP requests to the Unleash proxy. In case of name collisions with the default headers, the `customHeaders` value will be used if it is not `null` or `undefined`. `customHeaders` values that are `null` or `undefined` will be ignored. | | impressionDataAll | no| `false` | Allows you to trigger "impression" events for **all** `getToggle` and `getVariant` invocations. This is particularly useful for "disabled" feature toggles that are not visible to frontend SDKs. | | environment | no | `default` | Sets the `environment` option of the [Unleash context](https://docs.getunleash.io/reference/unleash-context). This does **not** affect the SDK's [Unleash environment](https://docs.getunleash.io/reference/environments). | -| togglesStorageTTL | no | `0` | How long (Time To Live), in seconds, the toggles in storage are considered valid and should not be fetched again. If set to 0 will disable expiration checking and will be considered always expired. | +| togglesStorageTTL | no | `0` | How long (Time To Live), in seconds, the toggles in storage are considered valid and should not be fetched on start. If set to 0 will disable expiration checking and will be considered always expired. | ### Listen for updates via the EventEmitter From 5bb8c208374453b95510a8d099ac51ad9fcae3c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Je=CC=81re=CC=81mie=20Richardeau?= Date: Thu, 16 May 2024 08:55:41 +0200 Subject: [PATCH 29/38] fix: lint --- src/index.test.ts | 10 ++++++++-- src/index.ts | 3 +-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/index.test.ts b/src/index.test.ts index 35d3039..58be848 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -585,7 +585,10 @@ describe('handling last update flag storage', () => { const client = new UnleashClient(config); await client.start(); - expect(saveSpy).toHaveBeenCalledWith('repoLastUpdateTimestamp', expect.any(Number)); + expect(saveSpy).toHaveBeenCalledWith( + 'repoLastUpdateTimestamp', + expect.any(Number) + ); expect(saveSpy.mock.lastCall?.at(1)).toBeGreaterThanOrEqual(startTime); }); @@ -602,7 +605,10 @@ describe('handling last update flag storage', () => { const client = new UnleashClient(config); await client.start(); - expect(saveSpy).toHaveBeenCalledWith('repoLastUpdateTimestamp', expect.any(Number)); + expect(saveSpy).toHaveBeenCalledWith( + 'repoLastUpdateTimestamp', + expect.any(Number) + ); expect(saveSpy.mock.lastCall?.at(1)).toBeGreaterThanOrEqual(startTime); }); diff --git a/src/index.ts b/src/index.ts index 99262de..32faf45 100644 --- a/src/index.ts +++ b/src/index.ts @@ -503,8 +503,7 @@ export class UnleashClient extends TinyEmitter { type: 'HttpError', code: response.status, }); - } - else if (response.status !== 304) { + } else if (response.status !== 304) { this.etag = response.headers.get('ETag') || ''; const data = await response.json(); await this.storeToggles(data.toggles); From 526614098fee6ecb1f1e7a451a2a3b5a17581705 Mon Sep 17 00:00:00 2001 From: Florent Date: Mon, 10 Jun 2024 16:54:07 +0200 Subject: [PATCH 30/38] experimental and hash --- package.json | 2 + src/index.test.ts | 578 +++++++++++++++++++++++++++++++++------------- src/index.ts | 64 +++-- 3 files changed, 465 insertions(+), 179 deletions(-) diff --git a/package.json b/package.json index 76730ef..cb97b61 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ }, "homepage": "https://github.com/unleash/unleash-proxy-client-js#readme", "dependencies": { + "object-hash": "^3.0.0", "tiny-emitter": "^2.1.0", "uuid": "^9.0.1" }, @@ -49,6 +50,7 @@ "@rollup/plugin-terser": "^0.4.4", "@rollup/plugin-typescript": "^11.1.5", "@types/jest": "^29.5.5", + "@types/object-hash": "^3.0.6", "@types/uuid": "^9.0.5", "@typescript-eslint/eslint-plugin": "^6.7.5", "@typescript-eslint/parser": "^6.7.5", diff --git a/src/index.test.ts b/src/index.test.ts index 58be848..f8111d3 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -11,6 +11,7 @@ import { UnleashClient, } from './index'; import { getTypeSafeRequest, getTypeSafeRequestUrl } from './test'; +import * as hash from 'object-hash'; jest.useFakeTimers(); @@ -554,83 +555,6 @@ test('Should publish ready when initial fetch completed', (done) => { }); }); -describe('handling last update flag storage', () => { - let storageProvider: IStorageProvider; - let saveSpy: jest.SpyInstance; - class Store implements IStorageProvider { - public async save() { - return Promise.resolve(); - } - - public async get() { - return Promise.resolve([]); - } - } - - beforeEach(() => { - storageProvider = new Store(); - saveSpy = jest.spyOn(storageProvider, 'save'); - }); - - test('Should store last update flag when fetch is successful', async () => { - const startTime = Date.now(); - fetchMock.mockResponseOnce(JSON.stringify(data)); - - const config: IConfig = { - url: 'http://localhost/test', - clientKey: '12', - appName: 'web', - storageProvider, - }; - - const client = new UnleashClient(config); - await client.start(); - expect(saveSpy).toHaveBeenCalledWith( - 'repoLastUpdateTimestamp', - expect.any(Number) - ); - expect(saveSpy.mock.lastCall?.at(1)).toBeGreaterThanOrEqual(startTime); - }); - - test('Should store last update flag when fetch is successful with 304 status', async () => { - const startTime = Date.now(); - fetchMock.mockResponseOnce(JSON.stringify(data), { status: 304 }); - - const config: IConfig = { - url: 'http://localhost/test', - clientKey: '12', - appName: 'web', - storageProvider, - }; - - const client = new UnleashClient(config); - await client.start(); - expect(saveSpy).toHaveBeenCalledWith( - 'repoLastUpdateTimestamp', - expect.any(Number) - ); - expect(saveSpy.mock.lastCall?.at(1)).toBeGreaterThanOrEqual(startTime); - }); - - test('Should not store last update flag when fetch is not successful', async () => { - fetchMock.mockResponseOnce('', { status: 500 }); - - const config: IConfig = { - url: 'http://localhost/test', - clientKey: '12', - appName: 'web', - storageProvider, - }; - - const client = new UnleashClient(config); - await client.start(); - expect(saveSpy).not.toHaveBeenCalledWith( - 'lastUpdate', - expect.any(Number) - ); - }); -}); - test('Should publish error when initial init fails', (done) => { const givenError = 'Error'; @@ -1797,113 +1721,437 @@ describe('READY event emission', () => { }); }); -describe('handling togglesStorageTTL > 0', () => { - let storage: IStorageProvider; - let fakeNow: number; - beforeEach(async () => { - fakeNow = new Date('2024-01-01').getTime(); - jest.useFakeTimers(); - jest.setSystemTime(fakeNow); - storage = new InMemoryStorageProvider(); +describe('Experimental options togglesStorageTTL disabled', () => { + const lastUpdateStorageKey = 'repoLastUpdateTimestamp'; - fetchMock.mockResponseOnce(JSON.stringify(data)).mockResponseOnce( - JSON.stringify({ - toggles: [ - { - name: 'simpleToggle', - enabled: false, - impressionData: true, - }, - ], - }) - ); + describe('Handling last update flag storage', () => { + let storageProvider: IStorageProvider; + let saveSpy: jest.SpyInstance; - const config: IConfig = { - url: 'http://localhost/test', - clientKey: '12', - appName: 'web', - storageProvider: storage, - }; - // performing an initial fetch to populate the toggles and lastUpdate timestamp - const client = new UnleashClient(config); - await client.start(); - client.stop(); + class Store implements IStorageProvider { + public async save() { + return Promise.resolve(); + } - expect(fetchMock).toHaveBeenCalledTimes(1); - fetchMock.mockClear(); - }); + public async get() { + return Promise.resolve([]); + } + } - afterEach(() => { - jest.useRealTimers(); - }); + beforeEach(() => { + storageProvider = new Store(); + saveSpy = jest.spyOn(storageProvider, 'save'); + jest.clearAllMocks(); + }); + + test('Should not store last update flag when fetch is successful', async () => { + fetchMock.mockResponseOnce(JSON.stringify(data)); - test('Should not perform an initial fetch when toggles are up to date', async () => { - jest.setSystemTime(fakeNow + 59000); + const config: IConfig = { + url: 'http://localhost/test', + clientKey: '12', + appName: 'web', + storageProvider, + experimental: { + }, + }; + + const client = new UnleashClient(config); + await client.start(); + expect(saveSpy).not.toHaveBeenCalledWith( + lastUpdateStorageKey, + { + contextHash: expect.any(String), + timestamp: expect.any(Number) + } + ); + }); + }); + + test('Should not store last update flag when bootstrap is set', async () => { + localStorage.clear(); + const bootstrap = [ + { + name: 'toggles', + enabled: true, + variant: { + name: 'disabled', + enabled: false, + feature_enabled: true, + }, + impressionData: true, + } + ]; + const config: IConfig = { url: 'http://localhost/test', clientKey: '12', appName: 'web', - storageProvider: storage, - togglesStorageTTL: 60, + bootstrap, }; const client = new UnleashClient(config); await client.start(); - const isEnabled = client.isEnabled('simpleToggle'); - expect(isEnabled).toBe(true); - client.stop(); - expect(fetchMock).toHaveBeenCalledTimes(0); + expect(localStorage.getItem(lastUpdateStorageKey)).toBeNull(); }); +}); - test('Should perform an initial fetch when toggles are expired', async () => { - jest.setSystemTime(fakeNow + 61000); +describe('Experimental options togglesStorageTTL enabled', () => { + let storage: IStorageProvider; + let fakeNow: number; + const lastUpdateStorageKey = 'repoLastUpdateTimestamp'; - const config: IConfig = { - url: 'http://localhost/test', - clientKey: '12', - appName: 'web', - storageProvider: storage, - togglesStorageTTL: 60, - }; - const client = new UnleashClient(config); - await client.start(); - const isEnabled = client.isEnabled('simpleToggle'); - expect(isEnabled).toBe(false); - client.stop(); - expect(fetchMock).toHaveBeenCalledTimes(1); + describe('Handling last update flag storage', () => { + let storageProvider: IStorageProvider; + let saveSpy: jest.SpyInstance; + + + class Store implements IStorageProvider { + public async save() { + return Promise.resolve(); + } + + public async get() { + return Promise.resolve([]); + } + } + + beforeEach(() => { + storageProvider = new Store(); + saveSpy = jest.spyOn(storageProvider, 'save'); + jest.clearAllMocks(); + }); + + test('Should store last update flag when fetch is successful', async () => { + const startTime = Date.now(); + fetchMock.mockResponseOnce(JSON.stringify(data)); + + const config: IConfig = { + url: 'http://localhost/test', + clientKey: '12', + appName: 'web', + storageProvider, + experimental: { + togglesStorageTTL: 60, + }, + }; + + const client = new UnleashClient(config); + await client.start(); + expect(saveSpy).toHaveBeenCalledWith( + lastUpdateStorageKey, + { + contextHash: expect.any(String), + timestamp: expect.any(Number) + } + ); + expect(saveSpy.mock.lastCall?.at(1).timestamp).toBeGreaterThanOrEqual(startTime); + }); + + test('Should store last update flag when fetch is successful with 304 status', async () => { + const startTime = Date.now(); + fetchMock.mockResponseOnce(JSON.stringify(data), { status: 304 }); + + const config: IConfig = { + url: 'http://localhost/test', + clientKey: '12', + appName: 'web', + storageProvider, + experimental: { + togglesStorageTTL: 60, + }, + }; + + const client = new UnleashClient(config); + await client.start(); + expect(saveSpy).toHaveBeenCalledWith( + lastUpdateStorageKey, + { + contextHash: expect.any(String), + timestamp: expect.any(Number) + } + ); + expect(saveSpy.mock.lastCall?.at(1).timestamp).toBeGreaterThanOrEqual(startTime); + }); + + test('Should not store last update flag when fetch is not successful', async () => { + fetchMock.mockResponseOnce('', { status: 500 }); + + const config: IConfig = { + url: 'http://localhost/test', + clientKey: '12', + appName: 'web', + storageProvider, + experimental: { + togglesStorageTTL: 60, + }, + }; + + const client = new UnleashClient(config); + await client.start(); + expect(saveSpy).not.toHaveBeenCalledWith( + lastUpdateStorageKey, + expect.any(Number) + ); + }); }); - test('Should send ready event when toggles are up to date', async () => { - const config: IConfig = { - url: 'http://localhost/test', - clientKey: '12', - appName: 'web', - storageProvider: storage, - togglesStorageTTL: 60, - }; - const client = new UnleashClient(config); + describe('Handling last update flag storage hash value', () => { + let storageProvider: IStorageProvider; + let saveSpy: jest.SpyInstance; - const readySpy = jest.fn(); - client.on(EVENTS.READY, readySpy); - client.on(EVENTS.INIT, () => readySpy.mockClear()); - await client.start(); - expect(readySpy).toHaveBeenCalledTimes(1); + class Store implements IStorageProvider { + public async save() { + return Promise.resolve(); + } + + public async get() { + return Promise.resolve([]); + } + } + + beforeEach(() => { + storageProvider = new Store(); + saveSpy = jest.spyOn(storageProvider, 'save'); + jest.clearAllMocks(); + }); + + test('Should compute last update flag value with the context value', async () => { + fetchMock.mockResponse(JSON.stringify({})); + + const config: IConfig = { + url: 'http://localhost/test', + clientKey: '12', + appName: 'web', + storageProvider, + experimental: { + togglesStorageTTL: 60, + } + }; + const client = new UnleashClient(config); + await client.start(); + const clientContext = client.getContext(); + expect(saveSpy).toHaveBeenNthCalledWith(1, 'repo', undefined); + expect(saveSpy).toHaveBeenNthCalledWith(2, + lastUpdateStorageKey, + { + contextHash: hash(clientContext), + timestamp: expect.any(Number) + } + ); + + await client.updateContext({ userId: '123' }); + const clientContextUpdated = client.getContext(); + expect(saveSpy).toHaveBeenNthCalledWith(3, 'repo', undefined); + expect(saveSpy).toHaveBeenNthCalledWith(4, + lastUpdateStorageKey, + { + contextHash: hash(clientContextUpdated), + timestamp: expect.any(Number) + } + ); + + expect(clientContext).not.toMatchObject(clientContextUpdated); + }); }); - test('Should perform a fetch when context is updated, even if flags are up to date', async () => { - const config: IConfig = { - url: 'http://localhost/test', - clientKey: '12', - appName: 'web', - storageProvider: storage, - togglesStorageTTL: 60, - }; - const client = new UnleashClient(config); - await client.start(); - let isEnabled = client.isEnabled('simpleToggle'); - expect(isEnabled).toBe(true); - await client.updateContext({ userId: '123' }); - expect(fetchMock).toHaveBeenCalledTimes(1); - isEnabled = client.isEnabled('simpleToggle'); - expect(isEnabled).toBe(false); + + + describe('For bootstrap initialisation', () => { + beforeEach(async () => { + storage = new InMemoryStorageProvider(); + jest.clearAllMocks(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + test('Should store last update flag when bootstrap is set', async () => { + const bootstrap = [ + { + name: 'toggles', + enabled: true, + variant: { + name: 'disabled', + enabled: false, + feature_enabled: true, + }, + impressionData: true, + } + ]; + + const config: IConfig = { + url: 'http://localhost/test', + clientKey: '12', + appName: 'web', + bootstrap, + storageProvider: storage, + experimental: { + togglesStorageTTL: 60, + }, + }; + const client = new UnleashClient(config); + await client.start(); + expect(await storage.get(lastUpdateStorageKey)).not.toBeUndefined(); + }); + + test('Should not store last update flag when bootstrap is not set', async () => { + const config: IConfig = { + url: 'http://localhost/test', + clientKey: '12', + appName: 'web', + storageProvider: storage, + experimental: { + togglesStorageTTL: 60, + }, + }; + const client = new UnleashClient(config); + await client.start(); + expect(await storage.get(lastUpdateStorageKey)).toBeUndefined(); + }); + }); + + describe('With a previous storage initialisation', () => { + beforeEach(async () => { + fakeNow = new Date('2024-01-01').getTime(); + jest.useFakeTimers(); + jest.setSystemTime(fakeNow); + storage = new InMemoryStorageProvider(); + + fetchMock.mockResponseOnce(JSON.stringify(data)).mockResponseOnce( + JSON.stringify({ + toggles: [ + { + name: 'simpleToggle', + enabled: false, + impressionData: true, + }, + ], + }) + ); + + const config: IConfig = { + url: 'http://localhost/test', + clientKey: '12', + appName: 'web', + storageProvider: storage, + experimental: { + togglesStorageTTL: 60, + } + }; + // performing an initial fetch to populate the toggles and lastUpdate timestamp + const client = new UnleashClient(config); + await client.start(); + client.stop(); + + expect(fetchMock).toHaveBeenCalledTimes(1); + fetchMock.mockClear(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + test('Should not perform an initial fetch when toggles are up to date', async () => { + jest.setSystemTime(fakeNow + 59000); + const config: IConfig = { + url: 'http://localhost/test', + clientKey: '12', + appName: 'web', + storageProvider: storage, + experimental: { + togglesStorageTTL: 60, + } + }; + const client = new UnleashClient(config); + await client.start(); + const isEnabled = client.isEnabled('simpleToggle'); + expect(isEnabled).toBe(true); + client.stop(); + expect(fetchMock).toHaveBeenCalledTimes(0); + }); + + test('Should perform an initial fetch when toggles are expired', async () => { + jest.setSystemTime(fakeNow + 61000); + + const config: IConfig = { + url: 'http://localhost/test', + clientKey: '12', + appName: 'web', + storageProvider: storage, + experimental: { + togglesStorageTTL: 60, + } + }; + const client = new UnleashClient(config); + await client.start(); + const isEnabled = client.isEnabled('simpleToggle'); + expect(isEnabled).toBe(false); + client.stop(); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + test('Should perform an initial fetch when context has change, even if flags are up to date', async () => { + jest.setSystemTime(fakeNow + 59000); + const config: IConfig = { + url: 'http://localhost/test', + clientKey: '12', + appName: 'web', + storageProvider: storage, + experimental: { + togglesStorageTTL: 60, + }, + context: { + properties: { + newProperty: 'newProperty' + } + } + }; + const client = new UnleashClient(config); + await client.start(); + const isEnabled = client.isEnabled('simpleToggle'); + expect(isEnabled).toBe(false); + client.stop(); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + test('Should send ready event when toggles are up to date', async () => { + const config: IConfig = { + url: 'http://localhost/test', + clientKey: '12', + appName: 'web', + storageProvider: storage, + experimental: { + togglesStorageTTL: 60, + } + }; + const client = new UnleashClient(config); + + const readySpy = jest.fn(); + client.on(EVENTS.READY, readySpy); + client.on(EVENTS.INIT, () => readySpy.mockClear()); + await client.start(); + expect(readySpy).toHaveBeenCalledTimes(1); + }); + + test('Should perform a fetch when context is updated, even if flags are up to date', async () => { + const config: IConfig = { + url: 'http://localhost/test', + clientKey: '12', + appName: 'web', + storageProvider: storage, + experimental: { + togglesStorageTTL: 60, + } + }; + const client = new UnleashClient(config); + await client.start(); + let isEnabled = client.isEnabled('simpleToggle'); + expect(isEnabled).toBe(true); + await client.updateContext({ userId: '123' }); + expect(fetchMock).toHaveBeenCalledTimes(1); + isEnabled = client.isEnabled('simpleToggle'); + expect(isEnabled).toBe(false); + }); }); }); diff --git a/src/index.ts b/src/index.ts index 32faf45..088d2ae 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,6 +5,7 @@ import InMemoryStorageProvider from './storage-provider-inmemory'; import LocalStorageProvider from './storage-provider-local'; import EventsHandler from './events-handler'; import { notNullOrUndefined, urlWithContextAsQuery } from './util'; +import * as hash from 'object-hash'; const DEFINED_FIELDS = [ 'userId', @@ -53,6 +54,10 @@ interface IConfig extends IStaticContext { customHeaders?: Record; impressionDataAll?: boolean; usePOSTrequests?: boolean; + experimental?: IExperimentalConfig; +} + +interface IExperimentalConfig { togglesStorageTTL?: number; } @@ -98,6 +103,11 @@ const lastUpdateKey = 'repoLastUpdateTimestamp'; type SdkState = 'initializing' | 'healthy' | 'error'; +type LastUpdateTimestamp = { + contextHash: string, + timestamp: number +}; + export const resolveFetch = () => { try { if (typeof window !== 'undefined' && 'fetch' in window) { @@ -148,7 +158,7 @@ export class UnleashClient extends TinyEmitter { private usePOSTrequests = false; private started = false; private sdkState: SdkState; - private togglesStorageTTL: number; + private experimental?: IExperimentalConfig; private lastRefreshTimestamp: number; constructor({ @@ -171,7 +181,7 @@ export class UnleashClient extends TinyEmitter { customHeaders = {}, impressionDataAll = false, usePOSTrequests = false, - togglesStorageTTL = 0, + experimental, }: IConfig) { super(); // Validations @@ -200,7 +210,12 @@ export class UnleashClient extends TinyEmitter { this.context = { appName, environment, ...context }; this.usePOSTrequests = usePOSTrequests; this.sdkState = 'initializing'; - this.togglesStorageTTL = togglesStorageTTL * 1000; + this.experimental = { ...experimental }; + + if (experimental?.togglesStorageTTL && experimental?.togglesStorageTTL > 0) { + this.experimental.togglesStorageTTL= experimental.togglesStorageTTL * 1000; + } + this.lastRefreshTimestamp = 0; this.ready = new Promise((resolve) => { @@ -356,7 +371,7 @@ export class UnleashClient extends TinyEmitter { this.context = { sessionId, ...this.context }; this.toggles = (await this.storage.get(storeKey)) || []; - this.lastRefreshTimestamp = await this.storage.get(lastUpdateKey); + this.lastRefreshTimestamp = await this.getLastRefreshTimestamp(); if ( this.bootstrap && @@ -364,6 +379,10 @@ export class UnleashClient extends TinyEmitter { ) { await this.storage.save(storeKey, this.bootstrap); this.toggles = this.bootstrap; + + // Indicates that the bootstrap is fresh, and avoid the initial fetch + this.storeLastRefreshTimestamp(); + this.emit(EVENTS.READY); } @@ -436,20 +455,35 @@ export class UnleashClient extends TinyEmitter { } private isUpToDate() { - if (this.togglesStorageTTL <= 0) { + if (!this.experimental?.togglesStorageTTL || this.experimental?.togglesStorageTTL <= 0) { return false; } const timestamp = Date.now(); return !!( this.lastRefreshTimestamp && - timestamp - this.lastRefreshTimestamp <= this.togglesStorageTTL + timestamp - this.lastRefreshTimestamp <= this.experimental.togglesStorageTTL ); } - private async updateLastRefreshTimestamp() { - this.lastRefreshTimestamp = Date.now(); - this.storage.save(lastUpdateKey, this.lastRefreshTimestamp); + private async getLastRefreshTimestamp() { + if (this.experimental?.togglesStorageTTL && this.experimental.togglesStorageTTL > 0) { + const lastRefresh: LastUpdateTimestamp = await this.storage.get(lastUpdateKey); + return lastRefresh?.contextHash === hash(this.context) ? lastRefresh.timestamp : 0; + } + return 0; + } + + private async storeLastRefreshTimestamp() { + if (this.experimental?.togglesStorageTTL && this.experimental.togglesStorageTTL > 0) { + this.lastRefreshTimestamp = Date.now(); + + const lastUpdateValue: LastUpdateTimestamp = { + contextHash: hash(this.context), + timestamp: this.lastRefreshTimestamp, + } + this.storage.save(lastUpdateKey, lastUpdateValue); + } } private initialFetchToggles() { @@ -493,8 +527,8 @@ export class UnleashClient extends TinyEmitter { this.sdkState = 'healthy'; this.emit(EVENTS.RECOVERED); } - - if (!response.ok && response.status !== 304) { + + if (!response.ok && response.status !== 304) { // Error (but not 304) console.error( 'Unleash: Fetching feature toggles did not have an ok response' ); @@ -503,7 +537,7 @@ export class UnleashClient extends TinyEmitter { type: 'HttpError', code: response.status, }); - } else if (response.status !== 304) { + } else if (response.status !== 304) { // Not an error (like 2xx) this.etag = response.headers.get('ETag') || ''; const data = await response.json(); await this.storeToggles(data.toggles); @@ -513,9 +547,11 @@ export class UnleashClient extends TinyEmitter { } this.emitReady(); + + this.storeLastRefreshTimestamp(); + } else if (response.status === 304) { // Specific 304 + this.storeLastRefreshTimestamp(); } - - this.updateLastRefreshTimestamp(); } catch (e) { console.error('Unleash: unable to fetch feature toggles', e); this.sdkState = 'error'; From 7712d923a305b05b8915d994853192effb981a28 Mon Sep 17 00:00:00 2001 From: Florent Date: Wed, 12 Jun 2024 15:49:50 +0200 Subject: [PATCH 31/38] retour PR --- src/index.test.ts | 193 +++++++++++++++++++++++----------------------- src/index.ts | 76 +++++++++++------- 2 files changed, 144 insertions(+), 125 deletions(-) diff --git a/src/index.test.ts b/src/index.test.ts index f8111d3..73e5eea 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -9,6 +9,7 @@ import { IToggle, InMemoryStorageProvider, UnleashClient, + lastUpdateKey, } from './index'; import { getTypeSafeRequest, getTypeSafeRequestUrl } from './test'; import * as hash from 'object-hash'; @@ -1722,53 +1723,46 @@ describe('READY event emission', () => { }); describe('Experimental options togglesStorageTTL disabled', () => { - const lastUpdateStorageKey = 'repoLastUpdateTimestamp'; + let storageProvider: IStorageProvider; + let saveSpy: jest.SpyInstance; - describe('Handling last update flag storage', () => { - let storageProvider: IStorageProvider; - let saveSpy: jest.SpyInstance; - - class Store implements IStorageProvider { - public async save() { - return Promise.resolve(); - } + class Store implements IStorageProvider { + public async save() { + return Promise.resolve(); + } - public async get() { - return Promise.resolve([]); - } + public async get() { + return Promise.resolve([]); } + } - beforeEach(() => { - storageProvider = new Store(); - saveSpy = jest.spyOn(storageProvider, 'save'); - jest.clearAllMocks(); - }); + beforeEach(() => { + storageProvider = new Store(); + saveSpy = jest.spyOn(storageProvider, 'save'); + jest.clearAllMocks(); + }); - test('Should not store last update flag when fetch is successful', async () => { - fetchMock.mockResponseOnce(JSON.stringify(data)); + test('Should not store last update flag when fetch is successful', async () => { + fetchMock.mockResponseOnce(JSON.stringify(data)); - const config: IConfig = { - url: 'http://localhost/test', - clientKey: '12', - appName: 'web', - storageProvider, - experimental: { - }, - }; + const config: IConfig = { + url: 'http://localhost/test', + clientKey: '12', + appName: 'web', + storageProvider, + experimental: {}, + }; - const client = new UnleashClient(config); - await client.start(); - expect(saveSpy).not.toHaveBeenCalledWith( - lastUpdateStorageKey, - { - contextHash: expect.any(String), - timestamp: expect.any(Number) - } - ); - }); + const client = new UnleashClient(config); + await client.start(); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(saveSpy).not.toHaveBeenCalledWith( + lastUpdateKey, + expect.anything() + ); }); - - test('Should not store last update flag when bootstrap is set', async () => { + + test('Should not store last update flag even when bootstrap is set', async () => { localStorage.clear(); const bootstrap = [ { @@ -1780,31 +1774,33 @@ describe('Experimental options togglesStorageTTL disabled', () => { feature_enabled: true, }, impressionData: true, - } + }, ]; - + const config: IConfig = { url: 'http://localhost/test', clientKey: '12', appName: 'web', bootstrap, + storageProvider, }; const client = new UnleashClient(config); await client.start(); - expect(localStorage.getItem(lastUpdateStorageKey)).toBeNull(); + expect(saveSpy).not.toHaveBeenCalledWith( + lastUpdateKey, + expect.anything() + ); }); }); describe('Experimental options togglesStorageTTL enabled', () => { let storage: IStorageProvider; let fakeNow: number; - const lastUpdateStorageKey = 'repoLastUpdateTimestamp'; describe('Handling last update flag storage', () => { let storageProvider: IStorageProvider; let saveSpy: jest.SpyInstance; - class Store implements IStorageProvider { public async save() { return Promise.resolve(); @@ -1837,14 +1833,13 @@ describe('Experimental options togglesStorageTTL enabled', () => { const client = new UnleashClient(config); await client.start(); - expect(saveSpy).toHaveBeenCalledWith( - lastUpdateStorageKey, - { - contextHash: expect.any(String), - timestamp: expect.any(Number) - } - ); - expect(saveSpy.mock.lastCall?.at(1).timestamp).toBeGreaterThanOrEqual(startTime); + expect(saveSpy).toHaveBeenCalledWith(lastUpdateKey, { + contextHash: expect.any(String), + timestamp: expect.any(Number), + }); + expect( + saveSpy.mock.lastCall?.at(1).timestamp + ).toBeGreaterThanOrEqual(startTime); }); test('Should store last update flag when fetch is successful with 304 status', async () => { @@ -1863,14 +1858,13 @@ describe('Experimental options togglesStorageTTL enabled', () => { const client = new UnleashClient(config); await client.start(); - expect(saveSpy).toHaveBeenCalledWith( - lastUpdateStorageKey, - { - contextHash: expect.any(String), - timestamp: expect.any(Number) - } - ); - expect(saveSpy.mock.lastCall?.at(1).timestamp).toBeGreaterThanOrEqual(startTime); + expect(saveSpy).toHaveBeenCalledWith(lastUpdateKey, { + contextHash: expect.any(String), + timestamp: expect.any(Number), + }); + expect( + saveSpy.mock.lastCall?.at(1).timestamp + ).toBeGreaterThanOrEqual(startTime); }); test('Should not store last update flag when fetch is not successful', async () => { @@ -1889,7 +1883,7 @@ describe('Experimental options togglesStorageTTL enabled', () => { const client = new UnleashClient(config); await client.start(); expect(saveSpy).not.toHaveBeenCalledWith( - lastUpdateStorageKey, + lastUpdateKey, expect.any(Number) ); }); @@ -1915,7 +1909,7 @@ describe('Experimental options togglesStorageTTL enabled', () => { jest.clearAllMocks(); }); - test('Should compute last update flag value with the context value', async () => { + test('Hash value computed should not change when the context value not change', async () => { fetchMock.mockResponse(JSON.stringify({})); const config: IConfig = { @@ -1925,37 +1919,44 @@ describe('Experimental options togglesStorageTTL enabled', () => { storageProvider, experimental: { togglesStorageTTL: 60, - } + }, }; const client = new UnleashClient(config); await client.start(); - const clientContext = client.getContext(); - expect(saveSpy).toHaveBeenNthCalledWith(1, 'repo', undefined); - expect(saveSpy).toHaveBeenNthCalledWith(2, - lastUpdateStorageKey, - { - contextHash: hash(clientContext), - timestamp: expect.any(Number) - } - ); + + const firstHash = saveSpy.mock.lastCall?.at(1).contextHash; + await client.updateContext({}); + + const secondHash = saveSpy.mock.lastCall?.at(1).contextHash; + + expect(firstHash).toEqual(secondHash); + }); + + test('Hash value computed should change when context value change', async () => { + fetchMock.mockResponse(JSON.stringify({})); + + const config: IConfig = { + url: 'http://localhost/test', + clientKey: '12', + appName: 'web', + storageProvider, + experimental: { + togglesStorageTTL: 60, + }, + }; + const client = new UnleashClient(config); + await client.start(); + + const firstHash = saveSpy.mock.lastCall?.at(1).contextHash; await client.updateContext({ userId: '123' }); - const clientContextUpdated = client.getContext(); - expect(saveSpy).toHaveBeenNthCalledWith(3, 'repo', undefined); - expect(saveSpy).toHaveBeenNthCalledWith(4, - lastUpdateStorageKey, - { - contextHash: hash(clientContextUpdated), - timestamp: expect.any(Number) - } - ); - expect(clientContext).not.toMatchObject(clientContextUpdated); + const secondHash = saveSpy.mock.lastCall?.at(1).contextHash; + + expect(firstHash).not.toEqual(secondHash); }); }); - - describe('For bootstrap initialisation', () => { beforeEach(async () => { storage = new InMemoryStorageProvider(); @@ -1977,7 +1978,7 @@ describe('Experimental options togglesStorageTTL enabled', () => { feature_enabled: true, }, impressionData: true, - } + }, ]; const config: IConfig = { @@ -1992,7 +1993,7 @@ describe('Experimental options togglesStorageTTL enabled', () => { }; const client = new UnleashClient(config); await client.start(); - expect(await storage.get(lastUpdateStorageKey)).not.toBeUndefined(); + expect(await storage.get(lastUpdateKey)).not.toBeUndefined(); }); test('Should not store last update flag when bootstrap is not set', async () => { @@ -2007,7 +2008,7 @@ describe('Experimental options togglesStorageTTL enabled', () => { }; const client = new UnleashClient(config); await client.start(); - expect(await storage.get(lastUpdateStorageKey)).toBeUndefined(); + expect(await storage.get(lastUpdateKey)).toBeUndefined(); }); }); @@ -2037,7 +2038,7 @@ describe('Experimental options togglesStorageTTL enabled', () => { storageProvider: storage, experimental: { togglesStorageTTL: 60, - } + }, }; // performing an initial fetch to populate the toggles and lastUpdate timestamp const client = new UnleashClient(config); @@ -2061,7 +2062,7 @@ describe('Experimental options togglesStorageTTL enabled', () => { storageProvider: storage, experimental: { togglesStorageTTL: 60, - } + }, }; const client = new UnleashClient(config); await client.start(); @@ -2081,7 +2082,7 @@ describe('Experimental options togglesStorageTTL enabled', () => { storageProvider: storage, experimental: { togglesStorageTTL: 60, - } + }, }; const client = new UnleashClient(config); await client.start(); @@ -2091,7 +2092,7 @@ describe('Experimental options togglesStorageTTL enabled', () => { expect(fetchMock).toHaveBeenCalledTimes(1); }); - test('Should perform an initial fetch when context has change, even if flags are up to date', async () => { + test('Should perform an initial fetch when context has changed, even if flags are up to date', async () => { jest.setSystemTime(fakeNow + 59000); const config: IConfig = { url: 'http://localhost/test', @@ -2103,9 +2104,9 @@ describe('Experimental options togglesStorageTTL enabled', () => { }, context: { properties: { - newProperty: 'newProperty' - } - } + newProperty: 'newProperty', + }, + }, }; const client = new UnleashClient(config); await client.start(); @@ -2123,7 +2124,7 @@ describe('Experimental options togglesStorageTTL enabled', () => { storageProvider: storage, experimental: { togglesStorageTTL: 60, - } + }, }; const client = new UnleashClient(config); @@ -2142,7 +2143,7 @@ describe('Experimental options togglesStorageTTL enabled', () => { storageProvider: storage, experimental: { togglesStorageTTL: 60, - } + }, }; const client = new UnleashClient(config); await client.start(); diff --git a/src/index.ts b/src/index.ts index 088d2ae..4e7b924 100644 --- a/src/index.ts +++ b/src/index.ts @@ -99,13 +99,13 @@ const defaultVariant: IVariant = { feature_enabled: false, }; const storeKey = 'repo'; -const lastUpdateKey = 'repoLastUpdateTimestamp'; +export const lastUpdateKey = 'repoLastUpdateTimestamp'; type SdkState = 'initializing' | 'healthy' | 'error'; -type LastUpdateTimestamp = { - contextHash: string, - timestamp: number +type LastUpdateTerms = { + contextHash: string; + timestamp: number; }; export const resolveFetch = () => { @@ -158,7 +158,7 @@ export class UnleashClient extends TinyEmitter { private usePOSTrequests = false; private started = false; private sdkState: SdkState; - private experimental?: IExperimentalConfig; + private experimental: IExperimentalConfig; private lastRefreshTimestamp: number; constructor({ @@ -210,10 +210,15 @@ export class UnleashClient extends TinyEmitter { this.context = { appName, environment, ...context }; this.usePOSTrequests = usePOSTrequests; this.sdkState = 'initializing'; + this.experimental = { ...experimental }; - if (experimental?.togglesStorageTTL && experimental?.togglesStorageTTL > 0) { - this.experimental.togglesStorageTTL= experimental.togglesStorageTTL * 1000; + if ( + experimental?.togglesStorageTTL && + experimental?.togglesStorageTTL > 0 + ) { + this.experimental.togglesStorageTTL = + experimental.togglesStorageTTL * 1000; } this.lastRefreshTimestamp = 0; @@ -454,34 +459,45 @@ export class UnleashClient extends TinyEmitter { await this.storage.save(storeKey, toggles); } + private isTogglesStorageTTLEnabled() { + return ( + this.experimental?.togglesStorageTTL && + this.experimental.togglesStorageTTL > 0 + ); + } + private isUpToDate() { - if (!this.experimental?.togglesStorageTTL || this.experimental?.togglesStorageTTL <= 0) { + if (!this.isTogglesStorageTTLEnabled()) { return false; } const timestamp = Date.now(); return !!( this.lastRefreshTimestamp && - timestamp - this.lastRefreshTimestamp <= this.experimental.togglesStorageTTL + timestamp - this.lastRefreshTimestamp <= + this.experimental.togglesStorageTTL! ); } private async getLastRefreshTimestamp() { - if (this.experimental?.togglesStorageTTL && this.experimental.togglesStorageTTL > 0) { - const lastRefresh: LastUpdateTimestamp = await this.storage.get(lastUpdateKey); - return lastRefresh?.contextHash === hash(this.context) ? lastRefresh.timestamp : 0; + if (this.isTogglesStorageTTLEnabled()) { + const lastRefresh: LastUpdateTerms | undefined = + await this.storage.get(lastUpdateKey); + return lastRefresh?.contextHash === hash(this.context) + ? lastRefresh.timestamp + : 0; } return 0; } private async storeLastRefreshTimestamp() { - if (this.experimental?.togglesStorageTTL && this.experimental.togglesStorageTTL > 0) { + if (this.isTogglesStorageTTLEnabled()) { this.lastRefreshTimestamp = Date.now(); - const lastUpdateValue: LastUpdateTimestamp = { + const lastUpdateValue: LastUpdateTerms = { contextHash: hash(this.context), timestamp: this.lastRefreshTimestamp, - } + }; this.storage.save(lastUpdateKey, lastUpdateValue); } } @@ -527,17 +543,21 @@ export class UnleashClient extends TinyEmitter { this.sdkState = 'healthy'; this.emit(EVENTS.RECOVERED); } - - if (!response.ok && response.status !== 304) { // Error (but not 304) - console.error( - 'Unleash: Fetching feature toggles did not have an ok response' - ); - this.sdkState = 'error'; - this.emit(EVENTS.ERROR, { - type: 'HttpError', - code: response.status, - }); - } else if (response.status !== 304) { // Not an error (like 2xx) + + if (!response.ok) { + if (response.status === 304) { + this.storeLastRefreshTimestamp(); + } else { + console.error( + 'Unleash: Fetching feature toggles did not have an ok response' + ); + this.sdkState = 'error'; + this.emit(EVENTS.ERROR, { + type: 'HttpError', + code: response.status, + }); + } + } else { this.etag = response.headers.get('ETag') || ''; const data = await response.json(); await this.storeToggles(data.toggles); @@ -547,9 +567,7 @@ export class UnleashClient extends TinyEmitter { } this.emitReady(); - - this.storeLastRefreshTimestamp(); - } else if (response.status === 304) { // Specific 304 + this.storeLastRefreshTimestamp(); } } catch (e) { From 76843195c75040413ade3b608eccfb87dc2fabf2 Mon Sep 17 00:00:00 2001 From: Florent Date: Wed, 12 Jun 2024 16:22:09 +0200 Subject: [PATCH 32/38] rollback previous test optim --- src/index.test.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/index.test.ts b/src/index.test.ts index e8818a0..f52960c 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -1527,16 +1527,15 @@ test('Should emit impression events on getVariant calls when impressionData is f }); test('Should publish ready only when the first fetch was successful', async () => { - expect.assertions(3); fetchMock.mockResponse(JSON.stringify(data)); const config: IConfig = { url: 'http://localhost/test', clientKey: '12', appName: 'web', refreshInterval: 1, - disableMetrics: true, }; const client = new UnleashClient(config); + await client.start(); let readyCount = 0; @@ -1548,8 +1547,6 @@ test('Should publish ready only when the first fetch was successful', async () = expect(readyCount).toEqual(1); }); - await client.start(); - jest.advanceTimersByTime(1001); jest.advanceTimersByTime(1001); From 9ecd5853607c74974e95cc26c3dbce6a070a11e7 Mon Sep 17 00:00:00 2001 From: Florent Date: Wed, 26 Jun 2024 14:12:30 +0200 Subject: [PATCH 33/38] Object Hash Value --- package.json | 2 -- src/index.test.ts | 1 - src/index.ts | 12 ++++++++---- src/util.test.ts | 28 +++++++++++++++++++++++++++- src/util.ts | 19 +++++++++++++++++++ 5 files changed, 54 insertions(+), 8 deletions(-) diff --git a/package.json b/package.json index 28272ce..8137f59 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,6 @@ }, "homepage": "https://github.com/unleash/unleash-proxy-client-js#readme", "dependencies": { - "object-hash": "^3.0.0", "tiny-emitter": "^2.1.0", "uuid": "^9.0.1" }, @@ -50,7 +49,6 @@ "@rollup/plugin-terser": "^0.4.4", "@rollup/plugin-typescript": "^11.1.5", "@types/jest": "^29.5.5", - "@types/object-hash": "^3.0.6", "@types/uuid": "^9.0.5", "@typescript-eslint/eslint-plugin": "^6.7.5", "@typescript-eslint/parser": "^6.7.5", diff --git a/src/index.test.ts b/src/index.test.ts index f52960c..59add0b 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -12,7 +12,6 @@ import { lastUpdateKey, } from './index'; import { getTypeSafeRequest, getTypeSafeRequestUrl } from './test'; -import * as hash from 'object-hash'; jest.useFakeTimers(); diff --git a/src/index.ts b/src/index.ts index c946087..f487644 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,8 +4,11 @@ import type IStorageProvider from './storage-provider'; import InMemoryStorageProvider from './storage-provider-inmemory'; import LocalStorageProvider from './storage-provider-local'; import EventsHandler from './events-handler'; -import { notNullOrUndefined, urlWithContextAsQuery } from './util'; -import * as hash from 'object-hash'; +import { + computeObjectHashValue, + notNullOrUndefined, + urlWithContextAsQuery, +} from './util'; const DEFINED_FIELDS = [ 'userId', @@ -487,7 +490,8 @@ export class UnleashClient extends TinyEmitter { if (this.isTogglesStorageTTLEnabled()) { const lastRefresh: LastUpdateTerms | undefined = await this.storage.get(lastUpdateKey); - return lastRefresh?.contextHash === hash(this.context) + return lastRefresh?.contextHash === + computeObjectHashValue(this.context) ? lastRefresh.timestamp : 0; } @@ -499,7 +503,7 @@ export class UnleashClient extends TinyEmitter { this.lastRefreshTimestamp = Date.now(); const lastUpdateValue: LastUpdateTerms = { - contextHash: hash(this.context), + contextHash: computeObjectHashValue(this.context), timestamp: this.lastRefreshTimestamp, }; this.storage.save(lastUpdateKey, lastUpdateValue); diff --git a/src/util.test.ts b/src/util.test.ts index 98a6799..64ef322 100644 --- a/src/util.test.ts +++ b/src/util.test.ts @@ -1,4 +1,4 @@ -import { urlWithContextAsQuery } from './util'; +import { computeObjectHashValue, urlWithContextAsQuery } from './util'; test('should not add paramters to URL', async () => { const someUrl = new URL('https://test.com'); @@ -57,3 +57,29 @@ test('should exclude context properties that are null or undefined', async () => 'https://test.com/?appName=test&properties%5Bcustom1%5D=test&properties%5Bcustom2%5D=test2' ); }); + +describe('sortObjectProperties', () => { + test('Should compute hash value for a simple object', () => { + const obj = { a: 1, b: 2, c: 3, d: 4 }; + const hashValue = computeObjectHashValue(obj); + expect(hashValue).toBe('{"a":1,"b":2,"c":3,"d":4}'); + }); + + test('Should compute hash value for an object with not sorted keys', () => { + const obj = { d: 4, a: 1, c: 3, b: 2 }; + const hashValue = computeObjectHashValue(obj); + expect(hashValue).toBe('{"a":1,"b":2,"c":3,"d":4}'); + }); + + test('Should compute hash value for an object with nested objects', () => { + const obj = { a: 1, b: { c: 2, d: { e: 3 } } }; + const hashValue = computeObjectHashValue(obj); + expect(hashValue).toBe('{"a":1,"b":{"c":2,"d":{"e":3}}}'); + }); + + test('Should compute hash value for an object with nested objects and not sorted keys', () => { + const obj = { b: { d: 2, c: { e: 3 } }, a: 1 }; + const hashValue = computeObjectHashValue(obj); + expect(hashValue).toBe('{"a":1,"b":{"c":{"e":3},"d":2}}'); + }); +}); diff --git a/src/util.ts b/src/util.ts index c4c8fe1..4fca50b 100644 --- a/src/util.ts +++ b/src/util.ts @@ -26,3 +26,22 @@ export const urlWithContextAsQuery = (url: URL, context: IContext) => { }); return urlWithQuery; }; + +const sortObjectProperties = ( + obj: Record +): Record => { + const sortedKeys = Object.keys(obj).sort(); + const sortedObj: Record = {}; + sortedKeys.forEach((key) => { + if (typeof obj[key] === 'object') { + sortedObj[key] = sortObjectProperties(obj[key]); + } else { + sortedObj[key] = obj[key]; + } + }); + + return sortedObj; +}; + +export const computeObjectHashValue = (obj: Record) => + JSON.stringify(sortObjectProperties(obj)); From 3c20789cae5f287a962cd5a0568e816bad096033 Mon Sep 17 00:00:00 2001 From: Florent Date: Tue, 2 Jul 2024 11:00:19 +0200 Subject: [PATCH 34/38] system time goes back into the past --- src/index.test.ts | 51 +++++++++++++++++++++++++++++++++++++---------- src/index.ts | 18 ++++++++--------- 2 files changed, 49 insertions(+), 20 deletions(-) diff --git a/src/index.test.ts b/src/index.test.ts index 26efa80..218a4b4 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -1896,7 +1896,7 @@ describe('Experimental options togglesStorageTTL enabled', () => { const client = new UnleashClient(config); await client.start(); expect(saveSpy).toHaveBeenCalledWith(lastUpdateKey, { - contextHash: expect.any(String), + key: expect.any(String), timestamp: expect.any(Number), }); expect( @@ -1921,7 +1921,7 @@ describe('Experimental options togglesStorageTTL enabled', () => { const client = new UnleashClient(config); await client.start(); expect(saveSpy).toHaveBeenCalledWith(lastUpdateKey, { - contextHash: expect.any(String), + key: expect.any(String), timestamp: expect.any(Number), }); expect( @@ -1986,11 +1986,13 @@ describe('Experimental options togglesStorageTTL enabled', () => { const client = new UnleashClient(config); await client.start(); - const firstHash = saveSpy.mock.lastCall?.at(1).contextHash; + const firstHash = saveSpy.mock.lastCall?.at(1).key; await client.updateContext({}); - const secondHash = saveSpy.mock.lastCall?.at(1).contextHash; + const secondHash = saveSpy.mock.lastCall?.at(1).key; + expect(firstHash).not.toBeUndefined(); + expect(secondHash).not.toBeUndefined(); expect(firstHash).toEqual(secondHash); }); @@ -2009,17 +2011,19 @@ describe('Experimental options togglesStorageTTL enabled', () => { const client = new UnleashClient(config); await client.start(); - const firstHash = saveSpy.mock.lastCall?.at(1).contextHash; + const firstHash = saveSpy.mock.lastCall?.at(1).key; await client.updateContext({ userId: '123' }); - const secondHash = saveSpy.mock.lastCall?.at(1).contextHash; + const secondHash = saveSpy.mock.lastCall?.at(1).key; + expect(firstHash).not.toBeUndefined(); + expect(secondHash).not.toBeUndefined(); expect(firstHash).not.toEqual(secondHash); }); }); - describe('For bootstrap initialisation', () => { + describe('During bootstrap initialisation', () => { beforeEach(async () => { storage = new InMemoryStorageProvider(); jest.clearAllMocks(); @@ -2030,6 +2034,7 @@ describe('Experimental options togglesStorageTTL enabled', () => { }); test('Should store last update flag when bootstrap is set', async () => { + expect.assertions(1); const bootstrap = [ { name: 'toggles', @@ -2054,11 +2059,14 @@ describe('Experimental options togglesStorageTTL enabled', () => { }, }; const client = new UnleashClient(config); - await client.start(); - expect(await storage.get(lastUpdateKey)).not.toBeUndefined(); + + client.on(EVENTS.READY, async () => { + expect(await storage.get(lastUpdateKey)).not.toBeUndefined(); + }); }); test('Should not store last update flag when bootstrap is not set', async () => { + expect.assertions(1); const config: IConfig = { url: 'http://localhost/test', clientKey: '12', @@ -2069,8 +2077,9 @@ describe('Experimental options togglesStorageTTL enabled', () => { }, }; const client = new UnleashClient(config); - await client.start(); - expect(await storage.get(lastUpdateKey)).toBeUndefined(); + client.on(EVENTS.INIT, async () => { + expect(await storage.get(lastUpdateKey)).toBeUndefined(); + }); }); }); @@ -2154,6 +2163,26 @@ describe('Experimental options togglesStorageTTL enabled', () => { expect(fetchMock).toHaveBeenCalledTimes(1); }); + test('Should perform an initial fetch when system time goes back into the past', async () => { + jest.setSystemTime(fakeNow - 1000); + + const config: IConfig = { + url: 'http://localhost/test', + clientKey: '12', + appName: 'web', + storageProvider: storage, + experimental: { + togglesStorageTTL: 60, + }, + }; + const client = new UnleashClient(config); + await client.start(); + const isEnabled = client.isEnabled('simpleToggle'); + expect(isEnabled).toBe(false); + client.stop(); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + test('Should perform an initial fetch when context has changed, even if flags are up to date', async () => { jest.setSystemTime(fakeNow + 59000); const config: IConfig = { diff --git a/src/index.ts b/src/index.ts index 438dbb3..73e4b51 100644 --- a/src/index.ts +++ b/src/index.ts @@ -107,7 +107,7 @@ export const lastUpdateKey = 'repoLastUpdateTimestamp'; type SdkState = 'initializing' | 'healthy' | 'error'; type LastUpdateTerms = { - contextHash: string; + key: string; timestamp: number; }; @@ -479,14 +479,14 @@ export class UnleashClient extends TinyEmitter { await this.storage.save(storeKey, toggles); } - private isTogglesStorageTTLEnabled() { - return ( + private isTogglesStorageTTLEnabled(): boolean { + return !!( this.experimental?.togglesStorageTTL && this.experimental.togglesStorageTTL > 0 ); } - private isUpToDate() { + private isUpToDate(): boolean { if (!this.isTogglesStorageTTLEnabled()) { return false; } @@ -494,29 +494,29 @@ export class UnleashClient extends TinyEmitter { return !!( this.lastRefreshTimestamp && + this.lastRefreshTimestamp <= timestamp && timestamp - this.lastRefreshTimestamp <= this.experimental.togglesStorageTTL! ); } - private async getLastRefreshTimestamp() { + private async getLastRefreshTimestamp(): Promise { if (this.isTogglesStorageTTLEnabled()) { const lastRefresh: LastUpdateTerms | undefined = await this.storage.get(lastUpdateKey); - return lastRefresh?.contextHash === - computeObjectHashValue(this.context) + return lastRefresh?.key === computeObjectHashValue(this.context) ? lastRefresh.timestamp : 0; } return 0; } - private async storeLastRefreshTimestamp() { + private async storeLastRefreshTimestamp(): Promise { if (this.isTogglesStorageTTLEnabled()) { this.lastRefreshTimestamp = Date.now(); const lastUpdateValue: LastUpdateTerms = { - contextHash: computeObjectHashValue(this.context), + key: computeObjectHashValue(this.context), timestamp: this.lastRefreshTimestamp, }; this.storage.save(lastUpdateKey, lastUpdateValue); From c0078f89528c2ffa9e4f459ce36228fb95738c44 Mon Sep 17 00:00:00 2001 From: Florent Date: Tue, 2 Jul 2024 14:09:52 +0200 Subject: [PATCH 35/38] refacto --- src/index.ts | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/src/index.ts b/src/index.ts index 73e4b51..d584fcc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -525,7 +525,10 @@ export class UnleashClient extends TinyEmitter { private initialFetchToggles() { if (this.isUpToDate()) { - this.emitReady(); + if (!this.readyEventEmitted) { + this.emit(EVENTS.READY); + this.readyEventEmitted = true; + } return; } return this.fetchToggles(); @@ -612,13 +615,6 @@ export class UnleashClient extends TinyEmitter { } } } - - private emitReady() { - if (!this.readyEventEmitted) { - this.emit(EVENTS.READY); - this.readyEventEmitted = true; - } - } } // export storage providers from root module From 9e07183e1aad4156b8ae2f0d1360a397604f9d59 Mon Sep 17 00:00:00 2001 From: Florent Date: Tue, 2 Jul 2024 14:52:03 +0200 Subject: [PATCH 36/38] Met a jour le flag fetchedFromServer quand on est a jour --- src/index.test.ts | 21 +++++++++++++++++++++ src/index.ts | 5 +++-- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/src/index.test.ts b/src/index.test.ts index 218a4b4..5e22af6 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -2245,5 +2245,26 @@ describe('Experimental options togglesStorageTTL enabled', () => { isEnabled = client.isEnabled('simpleToggle'); expect(isEnabled).toBe(false); }); + + test('Should perform a fetch when context is updated and refreshInterval disabled, even if flags are up to date', async () => { + const config: IConfig = { + url: 'http://localhost/test', + clientKey: '12', + appName: 'web', + storageProvider: storage, + experimental: { + togglesStorageTTL: 60, + }, + refreshInterval: 0, + }; + const client = new UnleashClient(config); + await client.start(); + let isEnabled = client.isEnabled('simpleToggle'); + expect(isEnabled).toBe(true); + await client.updateContext({ userId: '123' }); + expect(fetchMock).toHaveBeenCalledTimes(1); + isEnabled = client.isEnabled('simpleToggle'); + expect(isEnabled).toBe(false); + }); }); }); diff --git a/src/index.ts b/src/index.ts index d584fcc..8704bae 100644 --- a/src/index.ts +++ b/src/index.ts @@ -525,9 +525,10 @@ export class UnleashClient extends TinyEmitter { private initialFetchToggles() { if (this.isUpToDate()) { - if (!this.readyEventEmitted) { - this.emit(EVENTS.READY); + if (!this.fetchedFromServer) { + this.fetchedFromServer = true; this.readyEventEmitted = true; + this.emit(EVENTS.READY); } return; } From 04cd36e354df601d86dec0ab852c97133f905274 Mon Sep 17 00:00:00 2001 From: Florent Date: Tue, 9 Jul 2024 17:44:35 +0200 Subject: [PATCH 37/38] retour --- src/index.ts | 23 +++++++++++++++-------- src/util.ts | 14 ++++++++------ 2 files changed, 23 insertions(+), 14 deletions(-) diff --git a/src/index.ts b/src/index.ts index 8704bae..a87ada5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -381,6 +381,11 @@ export class UnleashClient extends TinyEmitter { this.updateToggles(); } + private setReady() { + this.readyEventEmitted = true; + this.emit(EVENTS.READY); + } + private async init(): Promise { const sessionId = await this.resolveSessionId(); this.context = { sessionId, ...this.context }; @@ -395,12 +400,11 @@ export class UnleashClient extends TinyEmitter { await this.storage.save(storeKey, this.bootstrap); this.toggles = this.bootstrap; this.sdkState = 'healthy'; - this.readyEventEmitted = true; // Indicates that the bootstrap is fresh, and avoid the initial fetch this.storeLastRefreshTimestamp(); - this.emit(EVENTS.READY); + this.setReady(); } this.sdkState = 'healthy'; @@ -504,7 +508,10 @@ export class UnleashClient extends TinyEmitter { if (this.isTogglesStorageTTLEnabled()) { const lastRefresh: LastUpdateTerms | undefined = await this.storage.get(lastUpdateKey); - return lastRefresh?.key === computeObjectHashValue(this.context) + return lastRefresh?.key === + computeObjectHashValue( + this.context as unknown as Record + ) ? lastRefresh.timestamp : 0; } @@ -516,7 +523,9 @@ export class UnleashClient extends TinyEmitter { this.lastRefreshTimestamp = Date.now(); const lastUpdateValue: LastUpdateTerms = { - key: computeObjectHashValue(this.context), + key: computeObjectHashValue( + this.context as unknown as Record + ), timestamp: this.lastRefreshTimestamp, }; this.storage.save(lastUpdateKey, lastUpdateValue); @@ -527,8 +536,7 @@ export class UnleashClient extends TinyEmitter { if (this.isUpToDate()) { if (!this.fetchedFromServer) { this.fetchedFromServer = true; - this.readyEventEmitted = true; - this.emit(EVENTS.READY); + this.setReady(); } return; } @@ -596,8 +604,7 @@ export class UnleashClient extends TinyEmitter { } if (!this.fetchedFromServer) { this.fetchedFromServer = true; - this.readyEventEmitted = true; - this.emit(EVENTS.READY); + this.setReady(); } this.storeLastRefreshTimestamp(); } diff --git a/src/util.ts b/src/util.ts index 4fca50b..0688142 100644 --- a/src/util.ts +++ b/src/util.ts @@ -28,13 +28,15 @@ export const urlWithContextAsQuery = (url: URL, context: IContext) => { }; const sortObjectProperties = ( - obj: Record -): Record => { + obj: Record +): Record => { const sortedKeys = Object.keys(obj).sort(); - const sortedObj: Record = {}; + const sortedObj: Record = {}; sortedKeys.forEach((key) => { - if (typeof obj[key] === 'object') { - sortedObj[key] = sortObjectProperties(obj[key]); + if (obj[key] !== null && typeof obj[key] === 'object') { + sortedObj[key] = sortObjectProperties( + obj[key] as Record + ); } else { sortedObj[key] = obj[key]; } @@ -43,5 +45,5 @@ const sortObjectProperties = ( return sortedObj; }; -export const computeObjectHashValue = (obj: Record) => +export const computeObjectHashValue = (obj: Record) => JSON.stringify(sortObjectProperties(obj)); From 2fae8e0105da75a6cfb59f17bb05a7cc2f1070a9 Mon Sep 17 00:00:00 2001 From: Florent Date: Wed, 10 Jul 2024 11:13:31 +0200 Subject: [PATCH 38/38] retour PR --- src/index.ts | 10 +++++----- src/util.test.ts | 27 +++++++++++---------------- src/util.ts | 4 ++-- 3 files changed, 18 insertions(+), 23 deletions(-) diff --git a/src/index.ts b/src/index.ts index a87ada5..d299dfb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,7 +5,7 @@ import InMemoryStorageProvider from './storage-provider-inmemory'; import LocalStorageProvider from './storage-provider-local'; import EventsHandler from './events-handler'; import { - computeObjectHashValue, + computeContextHashValue, notNullOrUndefined, urlWithContextAsQuery, } from './util'; @@ -509,8 +509,8 @@ export class UnleashClient extends TinyEmitter { const lastRefresh: LastUpdateTerms | undefined = await this.storage.get(lastUpdateKey); return lastRefresh?.key === - computeObjectHashValue( - this.context as unknown as Record + computeContextHashValue( + this.context ) ? lastRefresh.timestamp : 0; @@ -523,8 +523,8 @@ export class UnleashClient extends TinyEmitter { this.lastRefreshTimestamp = Date.now(); const lastUpdateValue: LastUpdateTerms = { - key: computeObjectHashValue( - this.context as unknown as Record + key: computeContextHashValue( + this.context ), timestamp: this.lastRefreshTimestamp, }; diff --git a/src/util.test.ts b/src/util.test.ts index 64ef322..9df9ed5 100644 --- a/src/util.test.ts +++ b/src/util.test.ts @@ -1,4 +1,5 @@ -import { computeObjectHashValue, urlWithContextAsQuery } from './util'; +import { IContext } from '.'; +import { computeContextHashValue, urlWithContextAsQuery } from './util'; test('should not add paramters to URL', async () => { const someUrl = new URL('https://test.com'); @@ -60,26 +61,20 @@ test('should exclude context properties that are null or undefined', async () => describe('sortObjectProperties', () => { test('Should compute hash value for a simple object', () => { - const obj = { a: 1, b: 2, c: 3, d: 4 }; - const hashValue = computeObjectHashValue(obj); - expect(hashValue).toBe('{"a":1,"b":2,"c":3,"d":4}'); + const obj: IContext = { appName: '1', currentTime: '2', environment: '3', userId: '4' }; + const hashValue = computeContextHashValue(obj); + expect(hashValue).toBe('{"appName":"1","currentTime":"2","environment":"3","userId":"4"}'); }); test('Should compute hash value for an object with not sorted keys', () => { - const obj = { d: 4, a: 1, c: 3, b: 2 }; - const hashValue = computeObjectHashValue(obj); - expect(hashValue).toBe('{"a":1,"b":2,"c":3,"d":4}'); - }); - - test('Should compute hash value for an object with nested objects', () => { - const obj = { a: 1, b: { c: 2, d: { e: 3 } } }; - const hashValue = computeObjectHashValue(obj); - expect(hashValue).toBe('{"a":1,"b":{"c":2,"d":{"e":3}}}'); + const obj: IContext = { userId: '4', appName: '1', environment: '3', currentTime: '2' }; + const hashValue = computeContextHashValue(obj); + expect(hashValue).toBe('{"appName":"1","currentTime":"2","environment":"3","userId":"4"}'); }); test('Should compute hash value for an object with nested objects and not sorted keys', () => { - const obj = { b: { d: 2, c: { e: 3 } }, a: 1 }; - const hashValue = computeObjectHashValue(obj); - expect(hashValue).toBe('{"a":1,"b":{"c":{"e":3},"d":2}}'); + const obj: IContext = { appName: '1', properties: { d: "4", c: "3" }, currentTime: '2' }; + const hashValue = computeContextHashValue(obj); + expect(hashValue).toBe('{"appName":"1","currentTime":"2","properties":{"c":"3","d":"4"}}'); }); }); diff --git a/src/util.ts b/src/util.ts index 0688142..f8f47b4 100644 --- a/src/util.ts +++ b/src/util.ts @@ -45,5 +45,5 @@ const sortObjectProperties = ( return sortedObj; }; -export const computeObjectHashValue = (obj: Record) => - JSON.stringify(sortObjectProperties(obj)); +export const computeContextHashValue = (obj: IContext) => + JSON.stringify(sortObjectProperties(obj as unknown as Record));