From d2567ab284fc5ab31863dfa229da4a0f2621d29a Mon Sep 17 00:00:00 2001 From: petruki <31597636+petruki@users.noreply.github.com> Date: Fri, 22 May 2026 17:34:37 -0700 Subject: [PATCH 1/2] feat: added autoRefreshToken client settings --- .devcontainer/Dockerfile | 2 +- .github/workflows/master.yml | 6 +-- .github/workflows/sonar.yml | 4 +- README.md | 12 ++++-- deno.jsonc | 2 +- sonar-project.properties | 2 +- src/client.ts | 5 +++ src/lib/constants.ts | 2 + src/lib/globals/globalAuth.ts | 12 +++--- src/lib/globals/globalOptions.ts | 4 ++ src/lib/remoteAuth.ts | 36 +++++++++++++++-- src/lib/snapshotWatcher.ts | 2 +- src/lib/utils/snapshotAutoUpdater.ts | 3 +- src/types/index.d.ts | 7 ++++ tests/switcher-integrated.test.ts | 7 +--- tests/switcher-remote.test.ts | 60 ++++++++++++++++++++++++++++ 16 files changed, 138 insertions(+), 28 deletions(-) diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 6443b2d..593358b 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -1,4 +1,4 @@ -FROM denoland/deno:2.7.13 +FROM denoland/deno:2.8.0 # Install tools RUN apt-get update && \ diff --git a/.github/workflows/master.yml b/.github/workflows/master.yml index ee1ed6b..5eb6080 100644 --- a/.github/workflows/master.yml +++ b/.github/workflows/master.yml @@ -17,10 +17,10 @@ jobs: with: fetch-depth: 0 - - name: Setup Deno v2.7.14 + - name: Setup Deno v2.8.0 uses: denoland/setup-deno@v2 with: - deno-version: v2.7.14 + deno-version: v2.8.0 - name: Setup LCOV run: sudo apt install -y lcov @@ -45,7 +45,7 @@ jobs: strategy: fail-fast: false matrix: - deno-version: [v1.46.3, v2.7.14] + deno-version: [v1.46.3, v2.8.0] os: [ ubuntu-latest, windows-latest ] runs-on: ${{ matrix.os }} diff --git a/.github/workflows/sonar.yml b/.github/workflows/sonar.yml index 0e2e1c4..ef386fd 100644 --- a/.github/workflows/sonar.yml +++ b/.github/workflows/sonar.yml @@ -33,10 +33,10 @@ jobs: ref: ${{ steps.pr.outputs.head_sha }} fetch-depth: 0 - - name: Setup Deno v2.7.14 + - name: Setup Deno v2.8.0 uses: denoland/setup-deno@v2 with: - deno-version: v2.7.14 + deno-version: v2.8.0 - name: Setup LCOV run: sudo apt install -y lcov diff --git a/README.md b/README.md index 4e03688..3f0dec7 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ A Deno SDK for Switcher API [![Master CI](https://github.com/switcherapi/switcher-client-deno/actions/workflows/master.yml/badge.svg)](https://github.com/switcherapi/switcher-client-deno/actions/workflows/master.yml) [![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=switcherapi_switcher-client-deno&metric=alert_status)](https://sonarcloud.io/dashboard?id=switcherapi_switcher-client-deno) +![Known Vulnerabilities](https://snyk.io/test/github/switcherapi/switcher-client-deno/badge.svg) [![deno.land/x/switcher4deno](https://shield.deno.dev/x/switcher4deno)](https://deno.land/x/switcher4deno) [![JSR](https://jsr.io/badges/@switcherapi/switcher-client-deno)](https://jsr.io/@switcherapi/switcher-client-deno) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) @@ -14,9 +15,10 @@ A Deno SDK for Switcher API -
- Switcher API: Deno Client -
+*** + +![Switcher API: Deno Client: Cloud-based Feature Flag API](https://github.com/switcherapi/switcherapi-assets/blob/master/logo/switcherapi_deno_client.png) + ## Table of Contents @@ -136,7 +138,8 @@ Client.buildContext({ restrictRelay: true, // Relay restrictions in local mode regexSafe: true, // Prevent reDOS attacks certPath: './certs/ca.pem', // SSL certificate path - remoteTimeout: 2000 // Remote Criteria API timeout (milliseconds) + remoteTimeout: 2000, // Remote Criteria API timeout (milliseconds) + autoRefreshToken: true, // Automatically refresh auth token before expiration }); ``` @@ -157,6 +160,7 @@ Client.buildContext({ | `regexMaxTimeLimit` | number | Regex timeout in milliseconds | | `certPath` | string | Path to SSL certificate file | | `remoteTimeout` | number | Remote Criteria API timeout in milliseconds | +| `autoRefreshToken` | boolean | Automatically refresh auth token before expiration | > **⚠️ Note on regexSafe**: This feature protects against reDOS attacks but uses Web Workers, which are incompatible with compiled executables. diff --git a/deno.jsonc b/deno.jsonc index ed3ccf3..4284553 100644 --- a/deno.jsonc +++ b/deno.jsonc @@ -1,6 +1,6 @@ { "name": "@switcherapi/switcher-client-deno", - "version": "2.4.1", + "version": "2.5.0", "description": "Switcher Client Deno is a Feature Flag Deno Client SDK for Switcher API", "tasks": { "cache-reload": "deno cache --reload --lock=deno.lock mod.ts", diff --git a/sonar-project.properties b/sonar-project.properties index 83e89b3..4b01c94 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -1,7 +1,7 @@ sonar.projectKey=switcherapi_switcher-client-deno sonar.projectName=switcher-client-deno sonar.organization=switcherapi -sonar.projectVersion=2.4.1 +sonar.projectVersion=2.5.0 sonar.javascript.lcov.reportPaths=coverage/report.lcov diff --git a/src/client.ts b/src/client.ts index a0f4cb5..d393813 100644 --- a/src/client.ts +++ b/src/client.ts @@ -2,6 +2,7 @@ import * as remote from './lib/remote.ts'; import * as util from './lib/utils/index.ts'; import Bypasser from './lib/bypasser/index.ts'; import { + DEFAULT_AUTO_REFRESH_TOKEN, DEFAULT_ENVIRONMENT, DEFAULT_FREEZE, DEFAULT_LOCAL, @@ -66,6 +67,7 @@ export class Client { local: util.get(options?.local, DEFAULT_LOCAL), freeze: util.get(options?.freeze, DEFAULT_FREEZE), logger: util.get(options?.logger, DEFAULT_LOGGER), + autoRefreshToken: util.get(options?.autoRefreshToken, DEFAULT_AUTO_REFRESH_TOKEN), }); // Initialize Auth @@ -98,6 +100,9 @@ export class Client { GlobalOptions.updateOptions({ snapshotWatcher: options.snapshotWatcher }); this.watchSnapshot(); }, + [SWITCHER_OPTIONS.AUTO_REFRESH_TOKEN]: () => { + GlobalOptions.updateOptions({ autoRefreshToken: options.autoRefreshToken }); + }, }; for (const key in optionsHandler) { diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 6306700..e6ad677 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -3,6 +3,7 @@ export const DEFAULT_LOCAL = false; export const DEFAULT_FREEZE = false; export const DEFAULT_LOGGER = false; export const DEFAULT_TEST_MODE = false; +export const DEFAULT_AUTO_REFRESH_TOKEN = false; export const DEFAULT_REGEX_MAX_BLACKLISTED = 50; export const DEFAULT_REGEX_MAX_TIME_LIMIT = 3000; @@ -17,4 +18,5 @@ export enum SWITCHER_OPTIONS { REGEX_MAX_TIME_LIMIT = 'regexMaxTimeLimit', CERT_PATH = 'certPath', REMOTE_TIMEOUT = 'remoteTimeout', + AUTO_REFRESH_TOKEN = 'autoRefreshToken', } diff --git a/src/lib/globals/globalAuth.ts b/src/lib/globals/globalAuth.ts index 5ea2c16..f6436c1 100644 --- a/src/lib/globals/globalAuth.ts +++ b/src/lib/globals/globalAuth.ts @@ -1,19 +1,19 @@ export class GlobalAuth { - private static _token?: string; - private static _exp?: number; private static _url?: string; + private static _token: string; + private static _exp: number; static init(url: string | undefined) { this._url = url; - this._token = undefined; - this._exp = undefined; + this._token = ''; + this._exp = 0; } static get token() { return this._token; } - static set token(value: string | undefined) { + static set token(value: string) { this._token = value; } @@ -21,7 +21,7 @@ export class GlobalAuth { return this._exp; } - static set exp(value: number | undefined) { + static set exp(value: number) { this._exp = value; } diff --git a/src/lib/globals/globalOptions.ts b/src/lib/globals/globalOptions.ts index 830a975..ca0a0b1 100644 --- a/src/lib/globals/globalOptions.ts +++ b/src/lib/globals/globalOptions.ts @@ -43,4 +43,8 @@ export class GlobalOptions { static get restrictRelay() { return this.options.restrictRelay; } + + static get autoRefreshToken() { + return this.options.autoRefreshToken; + } } diff --git a/src/lib/remoteAuth.ts b/src/lib/remoteAuth.ts index 7f00840..2550791 100644 --- a/src/lib/remoteAuth.ts +++ b/src/lib/remoteAuth.ts @@ -1,5 +1,6 @@ import type { RetryOptions, SwitcherContext } from '../types/index.d.ts'; import { GlobalAuth } from './globals/globalAuth.ts'; +import { GlobalOptions } from './globals/globalOptions.ts'; import { auth, checkAPIHealth } from './remote.ts'; import DateMoment from './utils/datemoment.ts'; import * as util from './utils/index.ts'; @@ -10,12 +11,32 @@ import * as util from './utils/index.ts'; export class Auth { private static context: SwitcherContext; private static retryOptions: RetryOptions; + private static refreshTimer: NodeJS.Timeout | undefined; static init(context: SwitcherContext) { this.context = context; GlobalAuth.init(context.url); } + private static scheduleNextAuth() { + const msUntilExpiry = (GlobalAuth.exp * 1000) - Date.now(); + const refreshAt = Math.max(msUntilExpiry - 5000, 0); // 5s before expiry + + this.refreshTimer = setTimeout(() => { + this.authUpdate().then(() => { + this.scheduleNextAuth(); + }).catch(() => { + this.terminateAutoRefresh(); + }); + }, refreshAt); + } + + private static async authUpdate() { + const response = await auth(this.context); + GlobalAuth.token = response.token; + GlobalAuth.exp = response.exp; + } + static setRetryOptions(silentMode: string) { this.retryOptions = { retryTime: Number.parseInt(silentMode.slice(0, -1)), @@ -23,10 +44,19 @@ export class Auth { }; } + static terminateAutoRefresh() { + if (this.refreshTimer) { + clearTimeout(this.refreshTimer); + this.refreshTimer = undefined; + } + } + static async auth() { - const response = await auth(this.context); - GlobalAuth.token = response.token; - GlobalAuth.exp = response.exp; + await this.authUpdate(); + + if (GlobalOptions.autoRefreshToken && !this.refreshTimer) { + this.scheduleNextAuth(); + } } static checkHealth() { diff --git a/src/lib/snapshotWatcher.ts b/src/lib/snapshotWatcher.ts index 46e1291..e46ae5f 100644 --- a/src/lib/snapshotWatcher.ts +++ b/src/lib/snapshotWatcher.ts @@ -10,7 +10,7 @@ import * as util from './utils/index.ts'; */ export class SnapshotWatcher { private _watcher: Deno.FsWatcher | undefined; - private readonly _watchDebounce = new Map(); + private readonly _watchDebounce = new Map(); async watchSnapshot(environment: string, callback: { success?: () => void | Promise; diff --git a/src/lib/utils/snapshotAutoUpdater.ts b/src/lib/utils/snapshotAutoUpdater.ts index 19edf75..69185ed 100644 --- a/src/lib/utils/snapshotAutoUpdater.ts +++ b/src/lib/utils/snapshotAutoUpdater.ts @@ -1,5 +1,5 @@ export default class SnapshotAutoUpdater { - private static _intervalId: number | undefined; + private static _intervalId: NodeJS.Timeout | undefined; static schedule( interval: number, @@ -23,5 +23,6 @@ export default class SnapshotAutoUpdater { static terminate() { clearInterval(this._intervalId); + this._intervalId = undefined; } } diff --git a/src/types/index.d.ts b/src/types/index.d.ts index 7fcbdfb..8dc4d5c 100644 --- a/src/types/index.d.ts +++ b/src/types/index.d.ts @@ -122,6 +122,13 @@ export type SwitcherOptions = { * If not set, it will use the default value (2000 ms) */ remoteTimeout?: number; + + /** + * When enabled it will automatically refresh the token before it expires + * + * If not set, it will not refresh the token automatically + */ + autoRefreshToken?: boolean; }; export type RetryOptions = { diff --git a/tests/switcher-integrated.test.ts b/tests/switcher-integrated.test.ts index dfdf807..dc69802 100644 --- a/tests/switcher-integrated.test.ts +++ b/tests/switcher-integrated.test.ts @@ -1,5 +1,4 @@ import { describe, it, assertExists, assertGreater, load } from './deps.ts'; -import { assertTrue } from "./helper/utils.ts"; import { Client } from '../mod.ts'; @@ -47,7 +46,7 @@ describe('Switcher integrated test', () => { assertGreater(Client.snapshotVersion, 0); }); - it('should check Switcher availability', async function () { + it('should check Switcher availability', function () { if (!Deno.env.get('SWITCHER_API_KEY')) { return; } @@ -56,9 +55,7 @@ describe('Switcher integrated test', () => { Client.buildContext(contextSettings); // test - await Client.checkSwitchers(['CLIENT_DENO_FEATURE']); - - assertTrue(true); + return Client.checkSwitchers(['CLIENT_DENO_FEATURE']); }); }); \ No newline at end of file diff --git a/tests/switcher-remote.test.ts b/tests/switcher-remote.test.ts index 8c6cd75..cf82ca3 100644 --- a/tests/switcher-remote.test.ts +++ b/tests/switcher-remote.test.ts @@ -4,6 +4,8 @@ import { describe, it, afterAll, afterEach, beforeEach, import { given, tearDown, assertTrue, generateAuth, generateResult, generateDetailedResult, sleep } from './helper/utils.ts' import { Client, type SwitcherResult, type SwitcherContext } from '../mod.ts'; +import { Auth } from "../src/lib/remoteAuth.ts"; +import { GlobalAuth } from "../src/lib/globals/globalAuth.ts"; import TimedMatch from '../src/lib/utils/timed-match/index.ts'; import ExecutionLogger from '../src/lib/utils/executionLogger.ts'; @@ -339,4 +341,62 @@ describe('Switcher Remote:', function () { }); + describe('auto refresh token:', function () { + + afterEach(function() { + Auth.terminateAutoRefresh(); + }); + + it('should refresh the token before it expires in the background', async function () { + // given API responses - 1st auth call + given('POST@/criteria/auth', generateAuth('[auth_token_1]', 5)); + + // test + Client.buildContext(contextSettings, { autoRefreshToken: true }); + await Client.getSwitcher('FLAG_1').prepare(); + + // given API responses - 2nd auth call (after token expiration) + given('POST@/criteria/auth', generateAuth('[auth_token_2]', 5)); + + assertEquals(GlobalAuth.token, '[auth_token_1]'); + await sleep(1000); + assertEquals(GlobalAuth.token, '[auth_token_2]'); + }); + + it('should not refresh the token if autoRefreshToken is false', async function () { + // given API responses - 1st auth call + given('POST@/criteria/auth', generateAuth('[auth_token_1]', 1)); + + // test + Client.buildContext(contextSettings, { autoRefreshToken: false }); + await Client.getSwitcher('FLAG_1').prepare(); + + // given API responses - 2nd auth call (after token expiration) + given('POST@/criteria/auth', generateAuth('[auth_token_2]', 5)); + + assertEquals(GlobalAuth.token, '[auth_token_1]'); + await sleep(1500); + assertEquals(GlobalAuth.token, '[auth_token_1]', 'Token should not have been refreshed'); + }); + + it('should handle token refresh failure gracefully', async function () { + const spyTerminate = spy(Auth, 'terminateAutoRefresh'); + + // given API responses - 1st auth call + given('POST@/criteria/auth', generateAuth('[auth_token_1]', 1)); + + // test + Client.buildContext(contextSettings, { autoRefreshToken: true }); + await Client.getSwitcher('FLAG_1').prepare(); + + // given API responses - 2nd auth call (after token expiration) fails + given('POST@/criteria/auth', undefined, 500); + + assertEquals(GlobalAuth.token, '[auth_token_1]'); + await sleep(1500); + assertEquals(GlobalAuth.token, '[auth_token_1]', 'Token should not have been refreshed'); + assertSpyCalls(spyTerminate, 1); + }); + }); + }); \ No newline at end of file From cd2d2b6ca3d24cac4176ce6a03a6648045c80670 Mon Sep 17 00:00:00 2001 From: petruki <31597636+petruki@users.noreply.github.com> Date: Fri, 22 May 2026 17:42:47 -0700 Subject: [PATCH 2/2] dropped support for Deno v1 --- .github/workflows/master.yml | 11 +++-------- .github/workflows/sonar.yml | 4 ++-- README.md | 3 +-- deno.jsonc | 1 - 4 files changed, 6 insertions(+), 13 deletions(-) diff --git a/.github/workflows/master.yml b/.github/workflows/master.yml index 5eb6080..5e0b1f0 100644 --- a/.github/workflows/master.yml +++ b/.github/workflows/master.yml @@ -35,7 +35,7 @@ jobs: run: deno task cover - name: SonarCloud Scan - uses: sonarsource/sonarqube-scan-action@v8.0.0 + uses: sonarsource/sonarqube-scan-action@v8.1.0 env: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} if: env.SONAR_TOKEN != '' @@ -45,7 +45,7 @@ jobs: strategy: fail-fast: false matrix: - deno-version: [v1.46.3, v2.8.0] + deno-version: [v2.8.0] os: [ ubuntu-latest, windows-latest ] runs-on: ${{ matrix.os }} @@ -58,10 +58,5 @@ jobs: with: deno-version: ${{ matrix.deno-version }} - - name: Run tests (Deno v1) - if: "contains(matrix.deno-version, 'v1')" - run: deno task test-v1 - - - name: Run tests (Deno v2) - if: "contains(matrix.deno-version, 'v2')" + - name: Run tests run: deno task test \ No newline at end of file diff --git a/.github/workflows/sonar.yml b/.github/workflows/sonar.yml index ef386fd..7a57271 100644 --- a/.github/workflows/sonar.yml +++ b/.github/workflows/sonar.yml @@ -16,7 +16,7 @@ jobs: steps: - name: Get PR details id: pr - uses: actions/github-script@v7 + uses: actions/github-script@v9 with: script: | const pr = await github.rest.pulls.get({ @@ -51,7 +51,7 @@ jobs: run: deno task cover - name: SonarCloud Scan - uses: sonarsource/sonarqube-scan-action@v8.0.0 + uses: sonarsource/sonarqube-scan-action@v8.1.0 env: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} if: env.SONAR_TOKEN != '' diff --git a/README.md b/README.md index 3f0dec7..4918183 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,7 @@ A Deno SDK for Switcher API ### Prerequisites -- **Deno**: Version 1.4x, 2.x or above +- **Deno**: Version 2.x or above - **Permissions**: | Permission | Required For | @@ -75,7 +75,6 @@ A Deno SDK for Switcher API | `--allow-write` | Writing snapshot files (if enabled) | | `--allow-net` | Communicating with the Switcher API | | `--allow-env` | Accessing environment variables for configuration | -| `--unstable-http` | Required for custom SSL certificates in Deno v1.4x (see [Advanced Options](#advanced-options)) | ### Installation diff --git a/deno.jsonc b/deno.jsonc index 4284553..a32ddc4 100644 --- a/deno.jsonc +++ b/deno.jsonc @@ -7,7 +7,6 @@ "fmt": "deno fmt mod.ts src/ --options-single-quote --options-line-width=120 --check", "fmt:fix": "deno fmt mod.ts src/ --options-single-quote --options-line-width=120", "test": "deno test --allow-read --allow-net --allow-write --allow-env --allow-import --coverage=coverage", - "test-v1": "deno test --allow-read --allow-net --allow-write --allow-env --unstable-http --coverage=coverage", "clean": "rm -rf ./npm ./coverage ./generated-snapshots", "lcov": "deno coverage coverage --lcov --output=coverage/report.lcov", "cover": "deno task clean && deno task test && deno task lcov && genhtml -o coverage/html coverage/report.lcov",