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
[](https://github.com/switcherapi/switcher-client-deno/actions/workflows/master.yml)
[](https://sonarcloud.io/dashboard?id=switcherapi_switcher-client-deno)
+
[](https://deno.land/x/switcher4deno)
[](https://jsr.io/@switcherapi/switcher-client-deno)
[](https://opensource.org/licenses/MIT)
@@ -14,9 +15,10 @@ A Deno SDK for Switcher API
-
-

-
+***
+
+
+
## 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",