From bc37b796deaba1c1b29b1d49af03e57afe60ceb1 Mon Sep 17 00:00:00 2001 From: petruki <31597636+petruki@users.noreply.github.com> Date: Mon, 23 Jun 2025 17:41:33 -0700 Subject: [PATCH 1/3] Added context configuration managed snapshotWatcher option --- README.md | 16 +++- package.json | 4 +- src/client.d.ts | 77 ++++++++++++++- src/client.js | 29 +++--- src/lib/constants.js | 1 + src/lib/globals/globalOptions.js | 4 + test/playground/index.js | 24 +++++ test/switcher-watch-snapshot.test.js | 134 ++++++++++++++++++--------- 8 files changed, 230 insertions(+), 59 deletions(-) diff --git a/README.md b/README.md index 3c4d4d0..843f1df 100644 --- a/README.md +++ b/README.md @@ -63,20 +63,22 @@ const local = true; const logger = true; const snapshotLocation = './snapshot/'; const snapshotAutoUpdateInterval = 3; +const snapshotWatcher = true; const silentMode = '5m'; const certPath = './certs/ca.pem'; Client.buildContext({ url, apiKey, domain, component, environment }, { - local, logger, snapshotLocation, snapshotAutoUpdateInterval, silentMode, certPath + local, logger, snapshotLocation, snapshotAutoUpdateInterval, snapshotWatcher, silentMode, certPath }); -let switcher = Client.getSwitcher(); +const switcher = Client.getSwitcher(); ``` - **local**: If activated, the client will only fetch the configuration inside your snapshot file. The default value is 'false' - **logger**: If activated, it is possible to retrieve the last results from a given Switcher key using Client.getLogger('KEY') - **snapshotLocation**: Location of snapshot files. The default value is './snapshot/' - **snapshotAutoUpdateInterval**: Enable Snapshot Auto Update given an interval in seconds (default: 0 disabled). +- **snapshotWatcher**: Enable Snapshot Watcher to monitor changes in the snapshot file (default: false). - **silentMode**: Enable contigency given the time for the client to retry - e.g. 5s (s: seconds - m: minutes - h: hours) - **regexSafe**: Enable REGEX Safe mode - Prevent agaist reDOS attack (default: true). - **regexMaxBlackList**: Number of entries cached when REGEX Strategy fails to perform (reDOS safe) - default: 50 @@ -212,6 +214,16 @@ Client.watchSnapshot({ }); ``` +Alternatively, you can also use the client context configuration to monitor changes in the snapshot file.
+ +```js +Client.buildContext({ domain, component, environment }, { + local: true, + snapshotLocation: './snapshot/', + snapshotWatcher: true +}); +``` + ## Snapshot version check For convenience, an implementation of a domain version checker is available if you have external processes that manage snapshot files. diff --git a/package.json b/package.json index 4fc29a6..6db7a86 100644 --- a/package.json +++ b/package.json @@ -31,8 +31,8 @@ ], "devDependencies": { "@babel/eslint-parser": "^7.27.5", - "@typescript-eslint/eslint-plugin": "^8.34.1", - "@typescript-eslint/parser": "^8.34.1", + "@typescript-eslint/eslint-plugin": "^8.35.0", + "@typescript-eslint/parser": "^8.35.0", "c8": "^10.1.3", "chai": "^5.2.0", "env-cmd": "^10.1.0", diff --git a/src/client.d.ts b/src/client.d.ts index 267f573..572ec11 100644 --- a/src/client.d.ts +++ b/src/client.d.ts @@ -137,7 +137,7 @@ export type Criteria = { * * @param strategy (StrategiesType) value to be evaluated */ - and(strategy: string, input: string | string[]): this; + and(strategy: string, input: string | string[]): Criteria; } @@ -156,10 +156,29 @@ export type LoggerRecord = { * SwitcherContext is required to build the context to communicate with the API. */ export type SwitcherContext = { + /** + * The API URL, e.g. https://api.switcherapi.com + */ url?: string; + + /** + * The API key provided for the component + */ apiKey?: string; + + /** + * The domain name of the Switcher API account + */ domain: string; + + /** + * The component name registered in the Switcher API account + */ component?: string; + + /** + * The environment name registered in the Switcher API account + */ environment?: string; } @@ -167,14 +186,63 @@ export type SwitcherContext = { * SwitcherOptions is optional to build the context to communicate with the API. */ export type SwitcherOptions = { + /** + * When enabled it will use the local snapshot (file or in-memory) + * If not set, it will use the remote API + */ local?: boolean; + + /** + * When enabled it allows inspecting the result details with Client.getLogger(key) + * If not set, it will not log the result details + */ logger?: boolean; + + /** + * The location of the snapshot file + * If not set, it will use the in-memory snapshot + */ snapshotLocation?: string; + + /** + * The interval in milliseconds to auto-update the snapshot + * If not set, it will not auto-update the snapshot + */ snapshotAutoUpdateInterval?: number; + + /** + * When enabled it will watch the snapshot file for changes + */ + snapshotWatcher?: boolean; + + /** + * When defined it will switch to local during the specified time before it switches back to remote + * e.g. 5s (s: seconds - m: minutes - h: hours) + */ silentMode?: string; + + /** + * When enabled it will check Regex strategy using background workers + * If not set, it will check Regex strategy synchronously + */ regexSafe?: boolean; + + /** + * The regex max black list + * If not set, it will use the default value + */ regexMaxBlackList?: number; + + /** + * The regex max time limit in milliseconds + * If not set, it will use the default value + */ regexMaxTimeLimit?: number; + + /** + * The certificate path for secure connections + * If not set, it will use the default certificate + */ certPath?: string; } @@ -185,7 +253,14 @@ export type SwitcherOptions = { * @param fetchRemote when true, it will initialize the snapshot from the API */ export type LoadSnapshotOptions = { + /** + * When enabled it will watch the snapshot file for changes + */ watchSnapshot?: boolean; + + /** + * When enabled it will fetch the remote API + */ fetchRemote?: boolean; } diff --git a/src/client.js b/src/client.js index a330890..ded4077 100644 --- a/src/client.js +++ b/src/client.js @@ -51,18 +51,25 @@ export class Client { static #buildOptions(options) { remote.removeAgent(); - if (SWITCHER_OPTIONS.CERT_PATH in options && options.certPath) { - remote.setCerts(options.certPath); - } - - if (SWITCHER_OPTIONS.SILENT_MODE in options && options.silentMode) { - this.#initSilentMode(options.silentMode); - } + + const optionsHandler = { + [SWITCHER_OPTIONS.CERT_PATH]: (val) => val && remote.setCerts(val), + [SWITCHER_OPTIONS.SILENT_MODE]: (val) => val && this.#initSilentMode(val), + [SWITCHER_OPTIONS.SNAPSHOT_AUTO_UPDATE_INTERVAL]: (val) => { + GlobalOptions.updateOptions({ snapshotAutoUpdateInterval: val }); + this.scheduleSnapshotAutoUpdate(); + }, + [SWITCHER_OPTIONS.SNAPSHOT_WATCHER]: (val) => { + GlobalOptions.updateOptions({ snapshotWatcher: val }); + this.watchSnapshot(); + } + }; - if (SWITCHER_OPTIONS.SNAPSHOT_AUTO_UPDATE_INTERVAL in options) { - GlobalOptions.updateOptions({ snapshotAutoUpdateInterval: options.snapshotAutoUpdateInterval }); - this.scheduleSnapshotAutoUpdate(); - } + Object.entries(optionsHandler).forEach(([key, handler]) => { + if (key in options) { + handler(options[key]); + } + }); this.#initTimedMatch(options); } diff --git a/src/lib/constants.js b/src/lib/constants.js index 63cc6c4..6f3151f 100644 --- a/src/lib/constants.js +++ b/src/lib/constants.js @@ -8,6 +8,7 @@ export const DEFAULT_REGEX_MAX_TIME_LIMIT = 3000; export const SWITCHER_OPTIONS = Object.freeze({ SNAPSHOT_LOCATION: 'snapshotLocation', SNAPSHOT_AUTO_UPDATE_INTERVAL: 'snapshotAutoUpdateInterval', + SNAPSHOT_WATCHER: 'snapshotWatcher', SILENT_MODE: 'silentMode', REGEX_SAFE: 'regexSafe', REGEX_MAX_BLACK_LIST: 'regexMaxBlackList', diff --git a/src/lib/globals/globalOptions.js b/src/lib/globals/globalOptions.js index 01b7582..36e2f23 100644 --- a/src/lib/globals/globalOptions.js +++ b/src/lib/globals/globalOptions.js @@ -30,6 +30,10 @@ export class GlobalOptions { return this.#options.snapshotAutoUpdateInterval; } + static get snapshotWatcher() { + return this.#options.snapshotWatcher; + } + static get silentMode() { return this.#options.silentMode; } diff --git a/test/playground/index.js b/test/playground/index.js index cead18e..98ddfa2 100644 --- a/test/playground/index.js +++ b/test/playground/index.js @@ -107,6 +107,7 @@ const _testAsyncCall = async () => { Client.unloadSnapshot(); }; +// Does not require remote API const _testBypasser = async () => { setupSwitcher(true); const switcher = Client.getSwitcher(); @@ -140,6 +141,29 @@ const _testWatchSnapshot = async () => { }); }; +// Does not require remote API +const _testWatchSnapshotContextOptions = async () => { + Client.buildContext({ domain, environment }, { + snapshotLocation, + snapshotWatcher: true, + local: true, + logger: true + }); + + await Client.loadSnapshot(); + + const switcher = Client.getSwitcher(); + + setInterval(async () => { + const time = Date.now(); + const result = await switcher + .detail() + .isItOn(SWITCHER_KEY); + + console.log(`- ${Date.now() - time} ms - ${JSON.stringify(result)}`); + }, 1000); +}; + // Requires remote API const _testSnapshotAutoUpdate = async () => { Client.buildContext({ url, apiKey, domain, component, environment }, diff --git a/test/switcher-watch-snapshot.test.js b/test/switcher-watch-snapshot.test.js index 09d1f9c..cd2af84 100644 --- a/test/switcher-watch-snapshot.test.js +++ b/test/switcher-watch-snapshot.test.js @@ -2,14 +2,59 @@ import { assert } from 'chai'; import { writeFileSync, existsSync, mkdirSync, readFileSync, unlinkSync } from 'fs'; import { Client } from '../switcher-client.js'; -import { deleteGeneratedSnapshot } from './helper/utils.js'; +import { deleteGeneratedSnapshot, sleep } from './helper/utils.js'; + + +const domain = 'Business'; +const component = 'business-service'; +let devJSON; + +const updateSwitcher = (environment, status) => { + const copyOfDevJSON = JSON.parse(JSON.stringify(devJSON)); + copyOfDevJSON.data.domain.group[0].config[0].activated = status; + writeFileSync(`generated-watch-snapshots/${environment}.json`, JSON.stringify(copyOfDevJSON, null, 4)); +}; + +const invalidateJSON = (environment) => { + writeFileSync(`generated-watch-snapshots/${environment}.json`, '[INVALID]'); +}; + +const beforeAll = () => { + if (!existsSync('generated-watch-snapshots/')) { + mkdirSync('generated-watch-snapshots/', { recursive: true }); + } + + const dataBuffer = readFileSync('./test/snapshot/dev.json'); + devJSON = JSON.parse(dataBuffer.toString()); + devJSON.data.domain.group[0].config[0].activated = true; +}; + +const afterAll = () => { + for (let i = 1; i <= 4; i++) { + const filePath = `generated-watch-snapshots/watch${i}.json`; + if (existsSync(filePath)) { + unlinkSync(filePath); + } + } + + Client.unloadSnapshot(); + setTimeout(() => deleteGeneratedSnapshot('./generated-watch-snapshots'), 0); +}; + +const afterEach = () => { + Client.unloadSnapshot(); +}; -describe('E2E test - Switcher local - Watch Snapshot:', function () { - const domain = 'Business'; - const component = 'business-service'; - let devJSON; +describe('E2E test - Switcher local - Watch Snapshot (watchSnapshot):', function () { + this.beforeAll(beforeAll); + this.afterAll(afterAll); + this.afterEach(afterEach); const initContext = async (environment) => { + if (!existsSync('generated-watch-snapshots/')) { + mkdirSync('generated-watch-snapshots/', { recursive: true }); + } + writeFileSync(`generated-watch-snapshots/${environment}.json`, JSON.stringify(devJSON, null, 4)); Client.buildContext({ domain, component, environment }, { @@ -21,44 +66,6 @@ describe('E2E test - Switcher local - Watch Snapshot:', function () { await Client.loadSnapshot(); }; - const updateSwitcher = (environment, status) => { - const copyOfDevJSON = JSON.parse(JSON.stringify(devJSON)); - copyOfDevJSON.data.domain.group[0].config[0].activated = status; - writeFileSync(`generated-watch-snapshots/${environment}.json`, JSON.stringify(copyOfDevJSON, null, 4)); - }; - - const invalidateJSON = (environment) => { - writeFileSync(`generated-watch-snapshots/${environment}.json`, '[INVALID]'); - }; - - this.beforeAll(function() { - if (!existsSync('generated-watch-snapshots/')) { - mkdirSync('generated-watch-snapshots/', { recursive: true }); - } - - const dataBuffer = readFileSync('./test/snapshot/dev.json'); - devJSON = JSON.parse(dataBuffer.toString()); - devJSON.data.domain.group[0].config[0].activated = true; - }); - - this.afterEach(function() { - Client.unloadSnapshot(); - }); - - this.afterAll(function() { - if (existsSync('generated-watch-snapshots/watch1.json')) - unlinkSync('generated-watch-snapshots/watch1.json'); - - if (existsSync('generated-watch-snapshots/watch2.json')) - unlinkSync('generated-watch-snapshots/watch2.json'); - - if (existsSync('generated-watch-snapshots/watch3.json')) - unlinkSync('generated-watch-snapshots/watch3.json'); - - Client.unloadSnapshot(); - deleteGeneratedSnapshot('./generated-watch-snapshots'); - }); - it('should read from snapshot - without watching', function (done) { this.timeout(10000); @@ -129,4 +136,45 @@ describe('E2E test - Switcher local - Watch Snapshot:', function () { Client.testMode(false); }); +}); + +describe('E2E test - Switcher local - Watch Snapshot (context):', function () { + this.beforeAll(beforeAll); + this.afterAll(afterAll); + this.afterEach(afterEach); + + const initContext = async (environment) => { + if (!existsSync('generated-watch-snapshots/')) { + mkdirSync('generated-watch-snapshots/', { recursive: true }); + } + + writeFileSync(`generated-watch-snapshots/${environment}.json`, JSON.stringify(devJSON, null, 4)); + + Client.buildContext({ domain, component, environment }, { + snapshotLocation: 'generated-watch-snapshots/', + snapshotWatcher: true, + local: true, + regexSafe: false + }); + + await Client.loadSnapshot(); + }; + + it('should read from updated snapshot', async function () { + this.timeout(10000); + + await initContext('watch4'); + + const switcher = Client.getSwitcher(); + assert.isTrue(await switcher.isItOn('FF2FOR2030')); + updateSwitcher('watch4', false); + + let result = true; + for (let i = 0; i < 10 && result; i++) { + await sleep(500); + result = await switcher.isItOn('FF2FOR2030'); + if (!result) break; + } + assert.isFalse(result); + }); }); \ No newline at end of file From 3fa3dde00283bea8e7a0ec6d305367ec862e104d Mon Sep 17 00:00:00 2001 From: petruki <31597636+petruki@users.noreply.github.com> Date: Mon, 23 Jun 2025 17:46:42 -0700 Subject: [PATCH 2/3] test: incremented sync snapchot change test retry to 20x --- test/switcher-watch-snapshot.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/switcher-watch-snapshot.test.js b/test/switcher-watch-snapshot.test.js index cd2af84..5dc6c19 100644 --- a/test/switcher-watch-snapshot.test.js +++ b/test/switcher-watch-snapshot.test.js @@ -170,7 +170,7 @@ describe('E2E test - Switcher local - Watch Snapshot (context):', function () { updateSwitcher('watch4', false); let result = true; - for (let i = 0; i < 10 && result; i++) { + for (let i = 0; i < 20 && result; i++) { await sleep(500); result = await switcher.isItOn('FF2FOR2030'); if (!result) break; From dd8e2bea2babb6d305ec7928a6fc797bb21e78cd Mon Sep 17 00:00:00 2001 From: petruki <31597636+petruki@users.noreply.github.com> Date: Mon, 23 Jun 2025 18:07:40 -0700 Subject: [PATCH 3/3] chore: removed unused function from GlobalOptions --- src/lib/globals/globalOptions.js | 4 ---- test/helper/utils.js | 15 +++++++++++++++ test/switcher-watch-snapshot.test.js | 11 ++--------- 3 files changed, 17 insertions(+), 13 deletions(-) diff --git a/src/lib/globals/globalOptions.js b/src/lib/globals/globalOptions.js index 36e2f23..01b7582 100644 --- a/src/lib/globals/globalOptions.js +++ b/src/lib/globals/globalOptions.js @@ -30,10 +30,6 @@ export class GlobalOptions { return this.#options.snapshotAutoUpdateInterval; } - static get snapshotWatcher() { - return this.#options.snapshotWatcher; - } - static get silentMode() { return this.#options.silentMode; } diff --git a/test/helper/utils.js b/test/helper/utils.js index 11ae4c7..b0113e6 100644 --- a/test/helper/utils.js +++ b/test/helper/utils.js @@ -54,6 +54,21 @@ export async function sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } +export async function getSwitcherResulUntil(switcher, key, expectToBe, retries = 20) { + let result; + + for (let i = 0; i < retries; i++) { + await sleep(500); + result = await switcher.isItOn(key); + + if (result === expectToBe) { + break; + } + } + + return result; +}; + export function deleteGeneratedSnapshot(dirname) { if (!fs.existsSync(dirname)) { return; diff --git a/test/switcher-watch-snapshot.test.js b/test/switcher-watch-snapshot.test.js index 5dc6c19..c96a4a7 100644 --- a/test/switcher-watch-snapshot.test.js +++ b/test/switcher-watch-snapshot.test.js @@ -2,8 +2,7 @@ import { assert } from 'chai'; import { writeFileSync, existsSync, mkdirSync, readFileSync, unlinkSync } from 'fs'; import { Client } from '../switcher-client.js'; -import { deleteGeneratedSnapshot, sleep } from './helper/utils.js'; - +import { deleteGeneratedSnapshot, getSwitcherResulUntil } from './helper/utils.js'; const domain = 'Business'; const component = 'business-service'; @@ -169,12 +168,6 @@ describe('E2E test - Switcher local - Watch Snapshot (context):', function () { assert.isTrue(await switcher.isItOn('FF2FOR2030')); updateSwitcher('watch4', false); - let result = true; - for (let i = 0; i < 20 && result; i++) { - await sleep(500); - result = await switcher.isItOn('FF2FOR2030'); - if (!result) break; - } - assert.isFalse(result); + assert.isFalse(await getSwitcherResulUntil(switcher, 'FF2FOR2030', false)); }); }); \ No newline at end of file