diff --git a/README.md b/README.md index a886bfc..bd8a2cc 100644 --- a/README.md +++ b/README.md @@ -96,14 +96,23 @@ const switcher = Client.getSwitcher(); There are a few different ways to call the API. Here are some examples: -1. **No parameters** -This is the simplest way to execute a criteria. It will return a boolean value indicating whether the feature is enabled or not. +1. **Basic usage** +Some of the ways you can check if a feature is enabled or not. ```js const switcher = Client.getSwitcher(); -await switcher.isItOn('FEATURE01'); -// or -const { result, reason, metadata } = await switcher.detail().isItOn('FEATURE01'); + +// Local (synchronous) execution +const isOnBool = switcher.isItOn('FEATURE01'); // true or false +const isOnBool = switcher.isItOnBool('FEATURE01'); // true or false +const isOnDetail = switcher.detail().isItOn('FEATURE01'); // { result: true, reason: 'Success', metadata: {} } +const isOnDetail = switcher.isItOnDetail('FEATURE01'); // { result: true, reason: 'Success', metadata: {} } + +// Remote (asynchronous) execution or hybrid (local/remote) execution +const isOnBoolAsync = await switcher.isItOn('FEATURE01'); // Promise +const isOnBoolAsync = await switcher.isItOnBool('FEATURE01', true); // Promise +const isOnDetailAsync = await switcher.detail().isItOn('FEATURE01'); // Promise +const isOnDetailAsync = await switcher.isItOnDetail('FEATURE01', true); // Promise ``` 2. **Strategy validation - preparing input** diff --git a/package.json b/package.json index b923651..dbb327d 100644 --- a/package.json +++ b/package.json @@ -31,13 +31,13 @@ "src/" ], "devDependencies": { - "@babel/eslint-parser": "^7.27.5", + "@babel/eslint-parser": "^7.28.0", "@typescript-eslint/eslint-plugin": "^8.35.1", "@typescript-eslint/parser": "^8.35.1", "c8": "^10.1.3", "chai": "^5.2.0", "env-cmd": "^10.1.0", - "eslint": "^9.30.0", + "eslint": "^9.30.1", "mocha": "^11.7.1", "mocha-sonarqube-reporter": "^1.0.2", "sinon": "^21.0.0" diff --git a/src/client.js b/src/client.js index 46db506..b816800 100644 --- a/src/client.js +++ b/src/client.js @@ -140,8 +140,7 @@ export class Client { util.get(Client.#context.environment, DEFAULT_ENVIRONMENT) )); - if (GlobalSnapshot.snapshot.data.domain.version == 0 && - (fetchRemote || !GlobalOptions.local)) { + if (this.#isCheckSnapshotAvailable(fetchRemote)) { await Client.checkSnapshot(); } @@ -152,6 +151,17 @@ export class Client { return GlobalSnapshot.snapshot?.data.domain.version || 0; } + /** + * Checks if the snapshot is available to be checked. + * + * Snapshots with version 0 are required to be checked if either: + * - fetchRemote is true, meaning it will fetch the latest snapshot from the API. + * - GlobalOptions.local is false, meaning it will not use the local snapshot. + */ + static #isCheckSnapshotAvailable(fetchRemote) { + return GlobalSnapshot.snapshot?.data.domain.version == 0 && (fetchRemote || !GlobalOptions.local); + } + static watchSnapshot(callback = {}) { const { success = () => {}, reject = () => {} } = callback; diff --git a/src/lib/resolver.js b/src/lib/resolver.js index a7d2b00..5165dc5 100644 --- a/src/lib/resolver.js +++ b/src/lib/resolver.js @@ -8,15 +8,15 @@ import { SwitcherResult } from './result.js'; * * @param {SnapshotData} data - The snapshot data containing domain and group information. * @param {SwitcherRequest} switcher - The switcher request to be evaluated. - * @returns {Promise} - The result of the switcher evaluation. + * @returns {SwitcherResult} - The result of the switcher evaluation. */ -async function resolveCriteria(data, switcher) { +function resolveCriteria(data, switcher) { if (!data.domain.activated) { return SwitcherResult.disabled('Domain disabled'); } const { group } = data.domain; - return await checkGroup(group, switcher); + return checkGroup(group, switcher); } /** @@ -24,10 +24,10 @@ async function resolveCriteria(data, switcher) { * * @param {Group[]} groups - The list of groups to check against. * @param {SwitcherRequest} switcher - The switcher request to be evaluated. - * @returns {Promise} - The result of the switcher evaluation. + * @returns {SwitcherResult} - The result of the switcher evaluation. * @throws {Error} - If the switcher key is not found in any group. */ -async function checkGroup(groups, switcher) { +function checkGroup(groups, switcher) { const key = util.get(switcher.key, ''); for (const group of groups) { @@ -39,7 +39,7 @@ async function checkGroup(groups, switcher) { return SwitcherResult.disabled('Group disabled'); } - return await checkConfig(configFound[0], switcher); + return checkConfig(configFound[0], switcher); } } @@ -53,9 +53,9 @@ async function checkGroup(groups, switcher) { * * @param {Config} config Configuration to check * @param {SwitcherRequest} switcher - The switcher request to be evaluated. - * @return {Promise} - The result of the switcher evaluation. + * @return {SwitcherResult} - The result of the switcher evaluation. */ -async function checkConfig(config, switcher) { +function checkConfig(config, switcher) { if (!config.activated) { return SwitcherResult.disabled('Config disabled'); } @@ -65,7 +65,7 @@ async function checkConfig(config, switcher) { } if (config.strategies) { - return await checkStrategy(config, switcher.input); + return checkStrategy(config, switcher.input); } return SwitcherResult.enabled(); @@ -76,9 +76,9 @@ async function checkConfig(config, switcher) { * * @param {Config} config - The configuration containing strategies. * @param {string[][]} [input] - The input data to be evaluated against the strategies. - * @returns {Promise} - The result of the strategy evaluation. + * @returns {SwitcherResult} - The result of the strategy evaluation. */ -async function checkStrategy(config, input) { +function checkStrategy(config, input) { const { strategies } = config; const entry = getEntry(util.get(input, [])); @@ -87,7 +87,7 @@ async function checkStrategy(config, input) { continue; } - const strategyResult = await checkStrategyConfig(strategyConfig, entry); + const strategyResult = checkStrategyConfig(strategyConfig, entry); if (strategyResult) { return strategyResult; } @@ -101,15 +101,15 @@ async function checkStrategy(config, input) { * * @param {Strategy} strategyConfig - The strategy configuration to be checked. * @param {Entry[]} [entry] - The entry data to be evaluated against the strategy. - * @returns {Promise} - The result of the strategy evaluation or undefined if valid. + * @returns {SwitcherResult | undefined} - The result of the strategy evaluation or undefined if valid. */ -async function checkStrategyConfig(strategyConfig, entry) { +function checkStrategyConfig(strategyConfig, entry) { if (!entry?.length) { return SwitcherResult.disabled(`Strategy '${strategyConfig.strategy}' did not receive any input`); } const strategyEntry = entry.filter((e) => e.strategy === strategyConfig.strategy); - if (await isStrategyFulfilled(strategyEntry, strategyConfig)) { + if (isStrategyFulfilled(strategyEntry, strategyConfig)) { return SwitcherResult.disabled(`Strategy '${strategyConfig.strategy}' does not agree`); } @@ -120,9 +120,8 @@ function hasRelayEnabled(config) { return config.relay?.activated; } -async function isStrategyFulfilled(strategyEntry, strategyConfig) { - return strategyEntry.length == 0 || - !(await processOperation(strategyConfig, strategyEntry[0].input)); +function isStrategyFulfilled(strategyEntry, strategyConfig) { + return strategyEntry.length == 0 || !processOperation(strategyConfig, strategyEntry[0].input); } /** @@ -130,10 +129,10 @@ async function isStrategyFulfilled(strategyEntry, strategyConfig) { * * @param {Snapshot | undefined} snapshot - The snapshot containing the data to check against. * @param {SwitcherRequest} switcher - The switcher request to be evaluated. - * @returns {Promise} - The result of the switcher evaluation. + * @returns {SwitcherResult} - The result of the switcher evaluation. * @throws {Error} - If the snapshot is not loaded. */ -export default async function checkCriteriaLocal(snapshot, switcher) { +export default function checkCriteriaLocal(snapshot, switcher) { if (!snapshot) { throw new Error('Snapshot not loaded. Try to use \'Client.loadSnapshot()\''); } diff --git a/src/lib/snapshot.js b/src/lib/snapshot.js index f95b5e3..8a1537a 100644 --- a/src/lib/snapshot.js +++ b/src/lib/snapshot.js @@ -86,7 +86,7 @@ const OperationsType = Object.freeze({ HAS_ALL: 'HAS_ALL' }); -const processOperation = async (strategyConfig, input) => { +const processOperation = (strategyConfig, input) => { const { strategy, operation, values } = strategyConfig; switch(strategy) { @@ -207,16 +207,16 @@ function processDATE(operation, input, values) { } } -async function processREGEX(operation, input, values) { +function processREGEX(operation, input, values) { switch(operation) { case OperationsType.EXIST: - return await TimedMatch.tryMatch(values, input); + return TimedMatch.tryMatch(values, input); case OperationsType.NOT_EXIST: - return !(await processREGEX(OperationsType.EXIST, input, values)); + return !processREGEX(OperationsType.EXIST, input, values); case OperationsType.EQUAL: - return await TimedMatch.tryMatch([`\\b${values[0]}\\b`], input); + return TimedMatch.tryMatch([`\\b${values[0]}\\b`], input); case OperationsType.NOT_EQUAL: - return !(await TimedMatch.tryMatch([`\\b${values[0]}\\b`], input)); + return !TimedMatch.tryMatch([`\\b${values[0]}\\b`], input); } } diff --git a/src/lib/utils/timed-match/index.js b/src/lib/utils/timed-match/index.js index d1137fe..cd0a1ca 100644 --- a/src/lib/utils/timed-match/index.js +++ b/src/lib/utils/timed-match/index.js @@ -1,5 +1,5 @@ -import cp from 'node:child_process'; import path from 'node:path'; +import { Worker } from 'node:worker_threads'; import { fileURLToPath } from 'node:url'; import tryMatch from '../timed-match/match.js'; @@ -9,102 +9,92 @@ const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); /** - * This class will run a match operation using a child process. + * This class will run a match operation using a Worker Thread. + * * Workers should be killed given a specified (3000 ms default) time limit. + * * Blacklist caching is available to prevent sequence of matching failures and resource usage. */ export default class TimedMatch { - static _worker = undefined; - static _workerActive = false; - static _blacklisted = []; - static _maxBlackListed = DEFAULT_REGEX_MAX_BLACKLISTED; - static _maxTimeLimit = DEFAULT_REGEX_MAX_TIME_LIMIT; + static #worker = undefined; + static #workerActive = false; + static #blacklisted = []; + static #maxBlackListed = DEFAULT_REGEX_MAX_BLACKLISTED; + static #maxTimeLimit = DEFAULT_REGEX_MAX_TIME_LIMIT; /** * Initialize Worker process for working with Regex process operators */ static initializeWorker() { - this._worker = this._createChildProcess(); - this._workerActive = true; + this.#worker = this.#createWorker(); + this.#workerActive = true; } /** * Gracefully terminate worker */ static terminateWorker() { - this._worker?.kill(); - this._workerActive = false; + this.#worker?.terminate(); + this.#workerActive = false; } /** - * Run match using child process + * Executes regex matching operation with timeout protection. + * + * If a worker is initialized and active, the operation runs in a separate worker thread + * with timeout protection to prevent runaway regex operations. Uses SharedArrayBuffer + * for synchronous communication between main thread and worker. + * + * If no worker is available, falls back to direct execution on the main thread. + * + * Failed operations (timeouts, errors) are automatically added to a blacklist to + * prevent repeated attempts with the same problematic patterns. * * @param {*} values array of regular expression to be evaluated * @param {*} input to be matched * @returns match result */ - static async tryMatch(values, input) { - if (this._worker && this._workerActive) { - return this._safeMatch(values, input); + static tryMatch(values, input) { + if (this.#worker && this.#workerActive) { + return this.#safeMatch(values, input); } - return await Promise.resolve(tryMatch(values, input)); + return tryMatch(values, input); } - - /** - * Clear entries from failed matching operations - */ - static clearBlackList() { - this._blacklisted = []; - } - - static setMaxBlackListed(value) { - this._maxBlackListed = value; - } - - static setMaxTimeLimit(value) { - this._maxTimeLimit = value; - } - + /** - * Run match using child process + * Run match using Node.js Worker Threads API. * * @param {*} values array of regular expression to be evaluated * @param {*} input to be matched * @returns match result */ - static async _safeMatch(values, input) { - let result = false; - let timer, resolveListener; + static #safeMatch(values, input) { + if (this.#isBlackListed(values, input)) { + return false; + } - if (this._isBlackListed({ values, input })) { + // Create a SharedArrayBuffer for communication + const sharedBuffer = new SharedArrayBuffer(4); + const int32Array = new Int32Array(sharedBuffer); + + // Send parameters to worker using postMessage (Worker Threads API) + this.#worker.postMessage({ values, input, sharedBuffer }); + + // Wait for worker to complete or timeout + const result = Atomics.wait(int32Array, 0, 0, this.#maxTimeLimit); + + if (result === 'timed-out') { + this.#resetWorker(values, input); return false; } - - const matchPromise = new Promise((resolve) => { - resolveListener = resolve; - this._worker.on('message', resolveListener); - this._worker.send({ values, input }); - }); - - const matchTimer = new Promise((resolve) => { - timer = setTimeout(() => { - this._resetWorker({ values, input }); - resolve(false); - }, this._maxTimeLimit); - }); - - await Promise.race([matchPromise, matchTimer]).then((value) => { - this._worker.off('message', resolveListener); - clearTimeout(timer); - result = value; - }); - - return result; + + // Get the actual result from the shared buffer + return Atomics.load(int32Array, 0) === 1; } - static _isBlackListed({ values, input }) { - const bls = this._blacklisted.filter(bl => + static #isBlackListed(values, input) { + const bls = this.#blacklisted.filter(bl => // input can contain same segment that could fail matching operation (bl.input.includes(input) || input.includes(bl.input)) && // regex order should not affect @@ -120,27 +110,39 @@ export default class TimedMatch { * * @param {*} param0 list of regex and input */ - static _resetWorker({ values, input }) { - this._worker.kill(); - this._worker = this._createChildProcess(); + static #resetWorker(values, input) { + this.#worker.terminate(); + this.#worker = this.#createWorker(); - if (this._blacklisted.length == this._maxBlackListed) { - this._blacklisted.splice(0, 1); + if (this.#blacklisted.length == this.#maxBlackListed) { + this.#blacklisted.splice(0, 1); } - this._blacklisted.push({ + this.#blacklisted.push({ res: values, input }); } - static _createChildProcess() { - const match_proc = cp.fork(`${__dirname}/match-proc.js`, { - stdio: 'ignore' - }); + static #createWorker() { + const match_proc = new Worker(`${__dirname}/match-proc.js`); match_proc.unref(); - match_proc.channel.unref(); return match_proc; } + + /** + * Clear entries from failed matching operations + */ + static clearBlackList() { + this.#blacklisted = []; + } + + static setMaxBlackListed(value) { + this.#maxBlackListed = value; + } + + static setMaxTimeLimit(value) { + this.#maxTimeLimit = value; + } } \ No newline at end of file diff --git a/src/lib/utils/timed-match/match-proc.js b/src/lib/utils/timed-match/match-proc.js index 8cebb25..5e71a2b 100644 --- a/src/lib/utils/timed-match/match-proc.js +++ b/src/lib/utils/timed-match/match-proc.js @@ -1,5 +1,14 @@ +import { parentPort } from 'node:worker_threads'; import tryMatch from './match.js'; -process.on('message', ({ values, input }) => { - process.send(tryMatch(values, input)); +parentPort.on('message', (e) => { + const { values, input, sharedBuffer } = e; + const int32Array = new Int32Array(sharedBuffer); + const result = tryMatch(values, input); + + // Store result in shared buffer (1 for true, 0 for false) + Atomics.store(int32Array, 0, result ? 1 : 0); + + // Notify the main thread that work is complete + Atomics.notify(int32Array, 0); }); \ No newline at end of file diff --git a/src/switcher.d.ts b/src/switcher.d.ts index 5284113..9f6ecbe 100644 --- a/src/switcher.d.ts +++ b/src/switcher.d.ts @@ -1,28 +1,88 @@ import { SwitcherResult } from './lib/result.js'; +export type SwitcherExecutionResult = Promise | boolean | SwitcherResult; + /** * Switcher handles criteria execution and validations. * - * Create a intance of Switcher using Client.getSwitcher() + * The class provides methods to execute criteria with both boolean and detailed results, + * and supports both synchronous and asynchronous execution modes. + * + * @example + * Example usage of the Switcher class: + * ```typescript + * // Local mode - synchronous execution + * const isOn = switcher.isItOnBool(); + * const { result, reason, metadata } = switcher.isItOnDetail(); + * + * // Force asynchronous execution + * const isOnAsync = await switcher.isItOnBool('MY_SWITCHER', true); + * const detailAsync = await switcher.isItOnDetail('MY_SWITCHER', true); + * ``` */ export class Switcher { /** - * Validates client settings for remote API calls + * Execute criteria with boolean result (synchronous version) + * + * @param key - switcher key + * @param forceAsync - when true, forces async execution + * @returns boolean value */ - validate(): Promise; + isItOnBool(key: string, forceAsync?: false): boolean; /** - * Checks API credentials and connectivity + * Execute criteria with boolean result (asynchronous version) + * + * @param key - switcher key + * @param forceAsync - when true, forces async execution + * @returns Promise value */ - prepare(key?: string): Promise; + isItOnBool(key: string, forceAsync?: true): Promise; + + /** + * Execute criteria with boolean result + * + * @param key - switcher key + * @param forceAsync - when true, forces async execution + * @returns boolean value or Promise based on execution mode + */ + isItOnBool(key: string, forceAsync?: boolean): Promise | boolean; + + /** + * Execute criteria with detail information (synchronous version) + * + * @param key - switcher key + * @param forceAsync - when true, forces async execution + * @returns SwitcherResult object + */ + isItOnDetail(key: string, forceAsync?: false): SwitcherResult; + + /** + * Execute criteria with detail information (asynchronous version) + * + * @param key - switcher key + * @param forceAsync - when true, forces async execution + * @returns Promise object + */ + isItOnDetail(key: string, forceAsync?: true): Promise; + + /** + * Execute criteria with detail information + * + * @param key - switcher key + * @param forceAsync - when true, forces async execution + * @returns SwitcherResult or Promise based on execution mode + */ + isItOnDetail(key: string, forceAsync?: boolean): Promise | SwitcherResult; /** * Execute criteria * - * @returns boolean or SwitcherResult when detail() is used + * @param key - switcher key + * @returns {SwitcherExecutionResult} - boolean value or SwitcherResult when detail() is applied */ - isItOn(key?: string): Promise | boolean | SwitcherResult; + isItOn(key?: string): SwitcherExecutionResult; /** * Schedules background refresh of the last criteria request @@ -32,12 +92,22 @@ export class Switcher { /** * Execute criteria from remote API */ - async executeRemoteCriteria(): Promise + async executeRemoteCriteria(): Promise; /** * Execute criteria from local snapshot */ - async executeLocalCriteria(): Promise + executeLocalCriteria(): boolean | SwitcherResult; + + /** + * Validates client settings for remote API calls + */ + validate(): Promise; + + /** + * Checks API credentials and connectivity + */ + prepare(key?: string): Promise; /** * Define a delay (ms) for the next async execution. @@ -115,4 +185,9 @@ export class Switcher { * Return switcher current strategy input */ get input(): string[][] | undefined; + + /** + * Return switcher Relay restriction settings + */ + get isRelayRestricted(): boolean; } \ No newline at end of file diff --git a/src/switcher.js b/src/switcher.js index 593aed3..571ba0e 100644 --- a/src/switcher.js +++ b/src/switcher.js @@ -43,6 +43,26 @@ export class Switcher extends SwitcherRequest { } } + isItOnBool(key, forceAsync = false) { + this.detail(false); + + if (forceAsync) { + return Promise.resolve(this.isItOn(key)); + } + + return this.isItOn(key); + } + + isItOnDetail(key, forceAsync = false) { + this.detail(true); + + if (forceAsync) { + return Promise.resolve(this.isItOn(key)); + } + + return this.isItOn(key); + } + isItOn(key) { this.#validateArgs(key); @@ -50,7 +70,7 @@ export class Switcher extends SwitcherRequest { const bypassKey = Bypasser.searchBypassed(this._key); if (bypassKey) { const response = bypassKey.getResponse(util.get(this._input, [])); - return this._showDetail ? response : response.result; + return this.#transformResult(response); } // try to get cached result @@ -62,58 +82,50 @@ export class Switcher extends SwitcherRequest { return this.#submit(); } - /** - * Schedules background refresh of the last criteria request - */ scheduleBackgroundRefresh() { const now = Date.now(); + if (now > this._nextRefreshTime) { this._nextRefreshTime = now + this._delay; - queueMicrotask(() => this.#submit().catch((err) => this._notifyError(err))); + queueMicrotask(async () => { + try { + await this.#submit(); + } catch (err) { + this.#notifyError(err); + } + }); } } - /** - * Execute criteria from remote API - */ async executeRemoteCriteria() { - let responseCriteria; - - try { - responseCriteria = await remote.checkCriteria( - this._key, - this._input, - this._showDetail, - ); - } catch (err) { - responseCriteria = this.#getDefaultResultOrThrow(err); - } - - if (GlobalOptions.logger && this._key) { - ExecutionLogger.add(responseCriteria, this._key, this._input); - } + return remote.checkCriteria( + this._key, + this._input, + this._showDetail, + ).then((responseCriteria) => { + if (this.#canLog()) { + ExecutionLogger.add(responseCriteria, this._key, this._input); + } - return this._showDetail ? responseCriteria : responseCriteria.result; + return this.#transformResult(responseCriteria); + }).catch((err) => { + const responseCriteria = this.#getDefaultResultOrThrow(err); + return this.#transformResult(responseCriteria); + }); } - async executeLocalCriteria() { - let response; - + executeLocalCriteria() { try { - response = await checkCriteriaLocal(GlobalSnapshot.snapshot, this); - } catch (err) { - response = this.#getDefaultResultOrThrow(err); - } - - if (GlobalOptions.logger) { - ExecutionLogger.add(response, this._key, this._input); - } + const response = checkCriteriaLocal(GlobalSnapshot.snapshot, this); + if (this.#canLog()) { + ExecutionLogger.add(response, this._key, this._input); + } - if (this._showDetail) { - return response; + return this.#transformResult(response); + } catch (err) { + const response = this.#getDefaultResultOrThrow(err); + return this.#transformResult(response); } - - return response.result; } #notifyError(err) { @@ -127,30 +139,31 @@ export class Switcher extends SwitcherRequest { } } - async #submit() { - try { - // verify if query from local snapshot - if (GlobalOptions.local && !this._forceRemote) { - return await this.executeLocalCriteria(); - } + #submit() { + // verify if query from snapshot + if (GlobalOptions.local && !this._forceRemote) { + return this.executeLocalCriteria(); + } - // otherwise, execute remote criteria or local snapshot when silent mode is enabled - await this.validate(); - if (GlobalAuth.token === 'SILENT') { - return await this.executeLocalCriteria(); - } + // otherwise, execute remote criteria or local snapshot when silent mode is enabled + return this.validate() + .then(() => { + if (GlobalAuth.token === 'SILENT') { + return this.executeLocalCriteria(); + } - return await this.executeRemoteCriteria(); - } catch (err) { - this.#notifyError(err); - - if (GlobalOptions.silentMode) { - Auth.updateSilentToken(); - return this.executeLocalCriteria(); - } - - throw err; - } + return this.executeRemoteCriteria(); + }) + .catch((err) => { + this.#notifyError(err); + + if (GlobalOptions.silentMode) { + Auth.updateSilentToken(); + return this.executeLocalCriteria(); + } + + throw err; + }); } #tryCachedResult() { @@ -161,7 +174,7 @@ export class Switcher extends SwitcherRequest { const cachedResultLogger = ExecutionLogger.getExecution(this._key, this._input); if (cachedResultLogger.key) { - return this._showDetail ? cachedResultLogger.response : cachedResultLogger.response.result; + return this.#transformResult(cachedResultLogger.response); } } @@ -178,6 +191,10 @@ export class Switcher extends SwitcherRequest { return this._delay !== 0; } + #canLog() { + return GlobalOptions.logger === true && this._key.length > 0; + } + #getDefaultResultOrThrow(err) { if (this._defaultResult === undefined) { throw err; @@ -189,4 +206,8 @@ export class Switcher extends SwitcherRequest { return response; } + #transformResult(result) { + return this._showDetail ? result : result.result; + } + } \ No newline at end of file diff --git a/src/switcherBuilder.js b/src/switcherBuilder.js index fde5b48..0f5fb11 100644 --- a/src/switcherBuilder.js +++ b/src/switcherBuilder.js @@ -17,7 +17,10 @@ export class SwitcherBuilder { throttle(delay) { this._delay = delay; - this._nextRefreshTime = Date.now() + delay; + + if (this._nextRefreshTime === 0) { + this._nextRefreshTime = Date.now() + delay; + } if (delay > 0) { GlobalOptions.updateOptions({ logger: true }); diff --git a/src/types/index.d.ts b/src/types/index.d.ts index 66c66aa..2cda8c1 100644 --- a/src/types/index.d.ts +++ b/src/types/index.d.ts @@ -1,10 +1,11 @@ import { Client, type SwitcherContext, type SwitcherOptions } from "../client"; -import { Switcher, SwitcherResult } from "../switcher"; +import { Switcher, SwitcherResult, type SwitcherExecutionResult } from "../switcher"; export { Switcher, Client, SwitcherResult, SwitcherContext, - SwitcherOptions + SwitcherOptions, + SwitcherExecutionResult } \ No newline at end of file diff --git a/tests/playground/index.js b/tests/playground/index.js index 1a9815a..15ba683 100644 --- a/tests/playground/index.js +++ b/tests/playground/index.js @@ -38,14 +38,12 @@ const _testLocal = async () => { .then(version => console.log('Snapshot loaded - version:', version)) .catch(() => console.log('Failed to load Snapshot')); - const switcher = Client.getSwitcher(); + const switcher = Client.getSwitcher(SWITCHER_KEY) + .detail(); - setInterval(async () => { + setInterval(() => { const time = Date.now(); - const result = await switcher - .detail() - .throttle(1000) - .isItOn(SWITCHER_KEY); + const result = switcher.isItOn(); console.log(`- ${Date.now() - time} ms - ${JSON.stringify(result)}`); }, 1000); @@ -129,16 +127,24 @@ const _testBypasser = async () => { // Requires remote API const _testWatchSnapshot = async () => { Client.buildContext({ url, apiKey, domain, component, environment }, { snapshotLocation, local: true, logger: true }); - await Client.loadSnapshot({ watchSnapshot: false, fetchRemote: true }) + await Client.loadSnapshot({ fetchRemote: true }) .then(() => console.log('Snapshot loaded')) .catch(() => console.log('Failed to load Snapshot')); - const switcher = Client.getSwitcher(); - Client.watchSnapshot({ - success: async () => console.log('In-memory snapshot updated', await switcher.isItOn(SWITCHER_KEY)), + success: () => console.log('In-memory snapshot updated'), reject: (err) => console.log(err) }); + + const switcher = Client.getSwitcher(SWITCHER_KEY) + .detail() + .throttle(1000); + + setInterval(() => { + const time = Date.now(); + const result = switcher.isItOn(SWITCHER_KEY); + console.log(`- ${Date.now() - time} ms - ${JSON.stringify(result)}`); + }, 1000); }; // Does not require remote API @@ -185,4 +191,4 @@ const _testSnapshotAutoUpdate = async () => { }, 2000); }; -_testLocal(); \ No newline at end of file +_testWatchSnapshot(); \ No newline at end of file diff --git a/tests/strategy-operations/regex.test.js b/tests/strategy-operations/regex.test.js index e7d53ed..62e2500 100644 --- a/tests/strategy-operations/regex.test.js +++ b/tests/strategy-operations/regex.test.js @@ -31,72 +31,72 @@ describe('Processing strategy: [REGEX Safe] ', function() { TimedMatch.terminateWorker(); }); - it('should agree when expect to exist using EXIST operation', async () => { + it('should agree when expect to exist using EXIST operation', () => { let strategyConfig = givenStrategyConfig(OperationsType.EXIST, mock_values1); - let result = await processOperation(strategyConfig, 'USER_1'); + let result = processOperation(strategyConfig, 'USER_1'); assert.isTrue(result); strategyConfig = givenStrategyConfig(OperationsType.EXIST, mock_values2); - result = await processOperation(strategyConfig, 'user-01'); + result = processOperation(strategyConfig, 'user-01'); assert.isTrue(result); }); - it('should NOT agree when expect to exist using EXIST operation', async () => { + it('should NOT agree when expect to exist using EXIST operation', () => { let strategyConfig = givenStrategyConfig(OperationsType.EXIST, mock_values1); - let result = await processOperation(strategyConfig, 'USER_123'); + let result = processOperation(strategyConfig, 'USER_123'); assert.isFalse(result); //mock_values3 does not require exact match strategyConfig = givenStrategyConfig(OperationsType.EXIST, mock_values3); - result = await processOperation(strategyConfig, 'USER_123'); + result = processOperation(strategyConfig, 'USER_123'); assert.isTrue(result); }); - it('should agree when expect to not exist using NOT_EXIST operation', async () => { + it('should agree when expect to not exist using NOT_EXIST operation', () => { let strategyConfig = givenStrategyConfig(OperationsType.NOT_EXIST, mock_values1); - let result = await processOperation(strategyConfig, 'USER_123'); + let result = processOperation(strategyConfig, 'USER_123'); assert.isTrue(result); strategyConfig = givenStrategyConfig(OperationsType.NOT_EXIST, mock_values2); - result = await processOperation(strategyConfig, 'user-123'); + result = processOperation(strategyConfig, 'user-123'); assert.isTrue(result); }); - it('should NOT agree when expect to not exist using NOT_EXIST operation', async () => { + it('should NOT agree when expect to not exist using NOT_EXIST operation', () => { const strategyConfig = givenStrategyConfig(OperationsType.NOT_EXIST, mock_values1); - const result = await processOperation(strategyConfig, 'USER_12'); + const result = processOperation(strategyConfig, 'USER_12'); assert.isFalse(result); }); - it('should agree when expect to be equal using EQUAL operation', async () => { + it('should agree when expect to be equal using EQUAL operation', () => { const strategyConfig = givenStrategyConfig(OperationsType.EQUAL, mock_values3); - const result = await processOperation(strategyConfig, 'USER_11'); + const result = processOperation(strategyConfig, 'USER_11'); assert.isTrue(result); }); - it('should NOT agree when expect to be equal using EQUAL operation', async () => { + it('should NOT agree when expect to be equal using EQUAL operation', () => { const strategyConfig = givenStrategyConfig(OperationsType.EQUAL, mock_values3); - const result = await processOperation(strategyConfig, 'user-11'); + const result = processOperation(strategyConfig, 'user-11'); assert.isFalse(result); }); - it('should agree when expect to not be equal using NOT_EQUAL operation', async () => { + it('should agree when expect to not be equal using NOT_EQUAL operation', () => { const strategyConfig = givenStrategyConfig(OperationsType.NOT_EQUAL, mock_values3); - const result = await processOperation(strategyConfig, 'USER_123'); + const result = processOperation(strategyConfig, 'USER_123'); assert.isTrue(result); }); - it('should NOT agree when expect to not be equal using NOT_EQUAL operation', async () => { + it('should NOT agree when expect to not be equal using NOT_EQUAL operation', () => { const strategyConfig = givenStrategyConfig(OperationsType.NOT_EQUAL, mock_values3); - const result = await processOperation(strategyConfig, 'USER_1'); + const result = processOperation(strategyConfig, 'USER_1'); assert.isFalse(result); }); - it('should NOT agree when match cannot finish (reDoS attempt)', async function () { + it('should NOT agree when match cannot finish (reDoS attempt)', function () { this.timeout(3100); const strategyConfig = givenStrategyConfig(OperationsType.EQUAL, ['^(([a-z])+.)+[A-Z]([a-z])+$']); - const result = await processOperation(strategyConfig, 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'); + const result = processOperation(strategyConfig, 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'); assert.isFalse(result); }); }); @@ -106,9 +106,9 @@ describe('Strategy [REGEX] tests:', function() { TimedMatch.terminateWorker(); }); - it('should agree when expect to exist using EXIST operation', async function () { + it('should agree when expect to exist using EXIST operation', function () { const strategyConfig = givenStrategyConfig(OperationsType.EXIST, mock_values1); - const result = await processOperation(strategyConfig, 'USER_1'); + const result = processOperation(strategyConfig, 'USER_1'); assert.isTrue(result); }); }); \ No newline at end of file diff --git a/tests/switcher-client.test.js b/tests/switcher-client.test.js index d8aff31..0a4f796 100644 --- a/tests/switcher-client.test.js +++ b/tests/switcher-client.test.js @@ -48,11 +48,13 @@ describe('E2E test - Client local #1:', function () { await switcher .checkValue('Japan') .checkNetwork('10.0.0.3') - .prepare('FF2FOR2020'); - - const result = await switcher.isItOn('FF2FOR2020'); - assert.isTrue(result); - assert.isNotEmpty(Client.getLogger('FF2FOR2020')); + .prepare(); + + assert.isTrue(await switcher.isItOn('FF2FOR2020') === true); + assert.isTrue(switcher.isItOnBool('FF2FOR2020') === true); + assert.isTrue(await switcher.isItOnBool('FF2FOR2020', true) === true); + assert.isTrue(switcher.isItOnDetail('FF2FOR2020').result === true); + assert.isTrue((await switcher.isItOnDetail('FF2FOR2020', true)).result === true); }); it('should get execution from logger', async function () { @@ -444,7 +446,7 @@ describe('E2E test - Client testing (assume) feature:', function () { assert.isTrue(await switcher.isItOn()); Client.forget('UNKNOWN'); - await assertReject(assert, switcher.isItOn(), 'Something went wrong: {"error":"Unable to load a key UNKNOWN"}'); + assert.throws(() => switcher.isItOn(), Error, 'Something went wrong: {"error":"Unable to load a key UNKNOWN"}'); }); it('should return true using Client.assume only when Strategy input values match', async function () { diff --git a/tests/switcher-functional.test.js b/tests/switcher-functional.test.js index ad63e79..96867a4 100644 --- a/tests/switcher-functional.test.js +++ b/tests/switcher-functional.test.js @@ -180,8 +180,7 @@ describe('Integrated test - Switcher:', function () { // given API responses // first API call given(fetchStub, 0, { json: () => generateAuth('[auth_token]', 5), status: 200 }); - given(fetchStub, 1, { json: () => generateResult(true), status: 200 }); // sync call - given(fetchStub, 2, { json: () => generateResult(true), status: 200 }); // async call + given(fetchStub, 1, { json: () => generateResult(true), status: 200 }); // before token expires // test let asyncErrorMessage = null; @@ -199,7 +198,7 @@ describe('Integrated test - Switcher:', function () { assert.isNull(asyncErrorMessage); // given - given(fetchStub, 3, { status: 500 }); + given(fetchStub, 2, { status: 500 }); // test assert.isTrue(await switcher.isItOn('FLAG_1')); // async @@ -610,8 +609,8 @@ describe('Integrated test - Switcher:', function () { givenError(fetchStub, 0, { errno: 'ECONNREFUSED' }); assert.isTrue(await switcher.isItOn('FF2FOR2030')); - await sleep(500); // The call below is in silent mode. It is getting the configuration from the local snapshot again + await sleep(500); assert.isTrue(await switcher.isItOn()); // As the silent mode was configured to retry after 2 seconds, it's still in time, @@ -626,6 +625,9 @@ describe('Integrated test - Switcher:', function () { // Auth is async when silent mode is enabled to prevent blocking the execution while the API is not available assert.isTrue(await switcher.isItOn()); + + // Now the remote call was invoked, so it should return false + await sleep(500); assert.isFalse(await switcher.isItOn()); assert.equal(spyRemote.callCount, 1); }); diff --git a/tests/switcher-snapshot.test.js b/tests/switcher-snapshot.test.js index 2726755..fd75ee9 100644 --- a/tests/switcher-snapshot.test.js +++ b/tests/switcher-snapshot.test.js @@ -383,7 +383,7 @@ describe('Error Scenarios - Snapshot', function() { }); const switcher = Client.getSwitcher(); - await assertReject(assert, switcher.isItOn('FF2FOR2030'), - 'Snapshot not loaded. Try to use \'Client.loadSnapshot()\'' ); + assert.throws(() => switcher.isItOn('FF2FOR2030'), + Error, 'Snapshot not loaded. Try to use \'Client.loadSnapshot()\'' ); }); }); \ No newline at end of file diff --git a/tests/switcher-watch-snapshot.test.js b/tests/switcher-watch-snapshot.test.js index 7e4b50d..36ab084 100644 --- a/tests/switcher-watch-snapshot.test.js +++ b/tests/switcher-watch-snapshot.test.js @@ -70,11 +70,11 @@ describe('E2E test - Switcher local - Watch Snapshot (watchSnapshot):', function initContext('watch1').then(async () => { const switcher = Client.getSwitcher(); - const result1 = await switcher.isItOn('FF2FOR2030'); + const result1 = switcher.isItOn('FF2FOR2030'); assert.isTrue(result1); updateSwitcher('watch1', false); - const result2 = await switcher.isItOn('FF2FOR2030'); + const result2 = switcher.isItOn('FF2FOR2030'); assert.isTrue(result2); done(); }); @@ -87,14 +87,14 @@ describe('E2E test - Switcher local - Watch Snapshot (watchSnapshot):', function const switcher = Client.getSwitcher(); Client.watchSnapshot({ success: async () => { - const result = await switcher.isItOn('FF2FOR2030'); + const result = switcher.isItOn('FF2FOR2030'); assert.isFalse(result); done(); } }); setTimeout(async () => { - const result = await switcher.isItOn('FF2FOR2030'); + const result = switcher.isItOn('FF2FOR2030'); assert.isTrue(result); updateSwitcher('watch2', false); }, 1000); @@ -114,14 +114,11 @@ describe('E2E test - Switcher local - Watch Snapshot (watchSnapshot):', function }); setTimeout(() => { - switcher.isItOn('FF2FOR2030').then(handleValue); + const result = switcher.isItOn('FF2FOR2030'); + assert.isTrue(result); + invalidateJSON('watch3'); }, 1000); }); - - function handleValue(result) { - assert.isTrue(result); - invalidateJSON('watch3'); - } }); it('should NOT allow to watch snapshot - Switcher test is enabled', function (done) { @@ -165,7 +162,7 @@ describe('E2E test - Switcher local - Watch Snapshot (context):', function () { await initContext('watch4'); const switcher = Client.getSwitcher(); - assert.isTrue(await switcher.isItOn('FF2FOR2030')); + assert.isTrue(switcher.isItOn('FF2FOR2030')); updateSwitcher('watch4', false); assert.isFalse(await getSwitcherResulUntil(switcher, 'FF2FOR2030', false)); diff --git a/tests/utils/timed-match.test.js b/tests/utils/timed-match.test.js index ca94b6f..090dbec 100644 --- a/tests/utils/timed-match.test.js +++ b/tests/utils/timed-match.test.js @@ -8,126 +8,131 @@ const nokInput = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; const COLD_TIME = 550; const WARM_TIME = 50; -const TIMEOUT = 1000; +const TIMEOUT = 950; // 50ms margin for worker thread to finish const getTimer = (timer) => (timer - Date.now()) * -1; describe('REGEX - Timed Match', () => { beforeEach(() => { + TimedMatch.initializeWorker(); TimedMatch.clearBlackList(); TimedMatch.setMaxBlackListed(50); TimedMatch.setMaxTimeLimit(1000); }); - it('should return true', async function () { - const result = await TimedMatch.tryMatch([okRE], okInput); + afterEach(() => { + TimedMatch.terminateWorker(); + }); + + it('should return true', function () { + const result = TimedMatch.tryMatch([okRE], okInput); assert.isTrue(result); }); - it('should return false and abort processing', async function () { + it('should return false and abort processing', function () { this.timeout(3100); - const result = await TimedMatch.tryMatch([nokRE], nokInput); + const result = TimedMatch.tryMatch([nokRE], nokInput); assert.isFalse(result); }); - it('runs stress tests', async function () { + it('runs stress tests', function () { this.timeout(4000); let timer; timer = Date.now(); - await TimedMatch.tryMatch([okRE], okInput); + TimedMatch.tryMatch([okRE], okInput); assert.isBelow(getTimer(timer), COLD_TIME); timer = Date.now(); - await TimedMatch.tryMatch([nokRE], nokInput); + TimedMatch.tryMatch([nokRE], nokInput); assert.isAbove(getTimer(timer), TIMEOUT); timer = Date.now(); - await TimedMatch.tryMatch([okRE], okInput); + TimedMatch.tryMatch([okRE], okInput); assert.isBelow(getTimer(timer), COLD_TIME); for (let index = 0; index < 10; index++) { timer = Date.now(); - await TimedMatch.tryMatch([okRE], okInput); + TimedMatch.tryMatch([okRE], okInput); assert.isBelow(getTimer(timer), WARM_TIME); } }); - it('should rotate blacklist', async function () { + it('should rotate blacklist', function () { this.timeout(10000); let timer; TimedMatch.setMaxBlackListed(1); timer = Date.now(); - await TimedMatch.tryMatch([nokRE], nokInput); + TimedMatch.tryMatch([nokRE], nokInput); assert.isAbove(getTimer(timer), TIMEOUT); // blacklisted timer = Date.now(); - await TimedMatch.tryMatch([nokRE], nokInput); + TimedMatch.tryMatch([nokRE], nokInput); assert.isBelow(getTimer(timer), WARM_TIME); timer = Date.now(); - await TimedMatch.tryMatch([nokRE], 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'); + TimedMatch.tryMatch([nokRE], 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'); assert.isAbove(getTimer(timer), TIMEOUT); // blacklisted timer = Date.now(); - await TimedMatch.tryMatch([nokRE], 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'); + TimedMatch.tryMatch([nokRE], 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'); assert.isBelow(getTimer(timer), WARM_TIME); }); - it('should capture blacklisted item from multiple regex options', async function () { + it('should capture blacklisted item from multiple regex options', function () { this.timeout(2000); let timer; TimedMatch.setMaxBlackListed(1); timer = Date.now(); - await TimedMatch.tryMatch([nokRE, okRE], nokInput); + TimedMatch.tryMatch([nokRE, okRE], nokInput); assert.isAbove(getTimer(timer), TIMEOUT); // blacklisted (inverted regex order should still work) timer = Date.now(); - await TimedMatch.tryMatch([okRE, nokRE], nokInput); + TimedMatch.tryMatch([okRE, nokRE], nokInput); assert.isBelow(getTimer(timer), WARM_TIME); }); - it('should capture blacklisted item from similar inputs', async function () { + it('should capture blacklisted item from similar inputs', function () { this.timeout(2000); let timer; TimedMatch.setMaxBlackListed(1); timer = Date.now(); - await TimedMatch.tryMatch([nokRE, okRE], 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'); + TimedMatch.tryMatch([nokRE, okRE], 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'); assert.isAbove(getTimer(timer), TIMEOUT); // blacklisted (input slightly different but contains the same evil segment) timer = Date.now(); - await TimedMatch.tryMatch([nokRE, okRE], 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaab'); + TimedMatch.tryMatch([nokRE, okRE], 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaab'); assert.isBelow(getTimer(timer), WARM_TIME); // same here timer = Date.now(); - await TimedMatch.tryMatch([nokRE, okRE], 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'); + TimedMatch.tryMatch([nokRE, okRE], 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'); assert.isBelow(getTimer(timer), WARM_TIME); // and here with inverted regex timer = Date.now(); - await TimedMatch.tryMatch([okRE, nokRE], 'aaaaaaaaaaaaaaaaaaaaaaaaaa'); + TimedMatch.tryMatch([okRE, nokRE], 'aaaaaaaaaaaaaaaaaaaaaaaaaa'); assert.isBelow(getTimer(timer), WARM_TIME); }); - it('should reduce worker timer', async function () { + it('should reduce worker timer', function () { this.timeout(1000); TimedMatch.setMaxTimeLimit(500); let timer = Date.now(); - await TimedMatch.tryMatch([nokRE], nokInput); + TimedMatch.tryMatch([nokRE], nokInput); timer = getTimer(timer); - assert.isAbove(timer, 500); + assert.isAbove(timer, 450); assert.isBelow(timer, TIMEOUT); }); }); \ No newline at end of file