From cfd1a3cd20552820a6aa5c1ac02e79efe6cb44f1 Mon Sep 17 00:00:00 2001 From: Simon Hornby Date: Tue, 21 Oct 2025 15:04:50 +0200 Subject: [PATCH] feat: add redis as a backup option --- README.md | 41 +++++- package.json | 9 +- src/index.ts | 13 +- src/repository/index.ts | 10 ++ src/repository/storage-provider-redis.ts | 118 ++++++++++++++++++ src/repository/storage-provider.ts | 1 + .../repository/storage-provider-redis.test.ts | 80 ++++++++++++ 7 files changed, 268 insertions(+), 4 deletions(-) create mode 100644 src/repository/storage-provider-redis.ts create mode 100644 src/test/repository/storage-provider-redis.test.ts diff --git a/README.md b/README.md index 57432c00..198154ce 100644 --- a/README.md +++ b/README.md @@ -435,11 +435,48 @@ const client = initialize({ }); ``` -### 2. Custom store provider backed by Redis +### 2. Use `RedisStorageProvider` ```js -import { initialize, InMemStorageProvider } from 'unleash-client'; +import { initialize, RedisStorageProvider } from 'unleash-client'; + +const client = initialize({ + appName: 'my-application', + url: 'http://localhost:3000/api/', + customHeaders: { Authorization: '' }, + storageProvider: new RedisStorageProvider({ + url: 'redis://localhost:6379', + keyPrefix: 'unleash:backup', + }), +}); +``` + +If you already manage your own Redis connection you can pass it in: + +```js +import { initialize, RedisStorageProvider } from 'unleash-client'; +import { createClient } from 'redis'; + +const redisClient = createClient({ url: 'redis://localhost:6379' }); +const client = initialize({ + appName: 'my-application', + url: 'http://localhost:3000/api/', + customHeaders: { Authorization: '' }, + storageProvider: new RedisStorageProvider({ client: redisClient }), +}); +``` + +When the SDK is destroyed it will close the Redis connection if it created it internally. If you +pass in your own Redis client, you stay in control of its lifecycle. + +> ℹ️ Make sure you add the [`redis`](https://www.npmjs.com/package/redis) package to your +> application when using the `RedisStorageProvider`: `npm install redis` or `yarn add redis`. + +### 3. Provide your own storage provider backed by Redis + +```js +import { initialize } from 'unleash-client'; import { createClient } from 'redis'; class CustomRedisStore { diff --git a/package.json b/package.json index 6f720231..401af298 100644 --- a/package.json +++ b/package.json @@ -81,10 +81,17 @@ "nock": "^13.3.1", "nyc": "^15.1.0", "prettier": "^3.0.0", - "redis": "^4.6.7", "sinon": "^18.0.0", "typescript": "^5.4.3" }, + "peerDependencies": { + "redis": "^4.6.7" + }, + "peerDependenciesMeta": { + "redis": { + "optional": true + } + }, "resolutions": { "ansi-regex": "^5.0.1", "debug": "^4.0.0", diff --git a/src/index.ts b/src/index.ts index 5d7fd77a..7e7b96ee 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,12 +6,23 @@ import { TagFilter } from './tags'; import { UnleashEvents } from './events'; import { ClientFeaturesResponse } from './feature'; import InMemStorageProvider from './repository/storage-provider-in-mem'; +import RedisStorageProvider from './repository/storage-provider-redis'; import { UnleashConfig } from './unleash-config'; // exports export { Strategy } from './strategy/index'; -export { Context, Variant, PayloadType, Unleash, TagFilter, InMemStorageProvider, UnleashEvents }; +export { + Context, + Variant, + PayloadType, + Unleash, + TagFilter, + InMemStorageProvider, + RedisStorageProvider, + UnleashEvents, +}; export type { ClientFeaturesResponse, UnleashConfig }; +export type { RedisStorageProviderOptions } from './repository/storage-provider-redis'; export { UnleashMetricClient } from './impact-metrics/metric-client'; let instance: undefined | Unleash; diff --git a/src/repository/index.ts b/src/repository/index.ts index b8c6a6be..907becf8 100644 --- a/src/repository/index.ts +++ b/src/repository/index.ts @@ -298,6 +298,16 @@ Message: ${err.message}`, this.stopped = true; this.fetcher.stop(); this.removeAllListeners(); + if (typeof this.storageProvider.destroy === 'function') { + try { + const destroyResult = this.storageProvider.destroy(); + if (destroyResult) { + void destroyResult.catch((error) => this.emit(UnleashEvents.Warn, error)); + } + } catch (error) { + this.emit(UnleashEvents.Warn, error); + } + } } getSegment(segmentId: number): Segment | undefined { diff --git a/src/repository/storage-provider-redis.ts b/src/repository/storage-provider-redis.ts new file mode 100644 index 00000000..4793693e --- /dev/null +++ b/src/repository/storage-provider-redis.ts @@ -0,0 +1,118 @@ +import { safeName } from '../helpers'; +import { StorageProvider } from './storage-provider'; + +type RedisClient = { + isOpen: boolean; + connect(): Promise; + set(key: string, value: string): Promise; + get(key: string): Promise; + quit(): Promise; +}; + +type RedisModule = { + createClient(options?: Record): RedisClient; +}; + +function loadRedisModule(): RedisModule { + try { + // eslint-disable-next-line global-require, import/no-dynamic-require + return require('redis'); + } catch (error: any) { + const message = + 'RedisStorageProvider requires the "redis" package. Install it with ' + + '`npm install redis` or `yarn add redis`.'; + if (error instanceof Error) { + error.message = `${message} Original error: ${error.message}`; + } + throw error; + } +} + +export interface RedisStorageProviderOptions { + client?: RedisClient; + url?: string; + clientOptions?: Record; + keyPrefix?: string; +} + +export default class RedisStorageProvider implements StorageProvider { + private readonly client: RedisClient; + + private readonly ownsClient: boolean; + + private readonly keyPrefix: string; + + private connectPromise?: Promise; + + constructor(options: RedisStorageProviderOptions = {}) { + const { client, url, clientOptions, keyPrefix = 'unleash:backup:' } = options; + + if (client) { + this.client = client; + this.ownsClient = false; + } else { + const redisOptions: Record = { ...(clientOptions ?? {}) }; + if (url) { + redisOptions.url = url; + } + const redis = loadRedisModule(); + this.client = redis.createClient(redisOptions); + this.ownsClient = true; + } + + this.keyPrefix = keyPrefix.endsWith(':') ? keyPrefix : `${keyPrefix}:`; + } + + private storageKey(key: string): string { + return `${this.keyPrefix}${safeName(key)}`; + } + + private async ensureConnected(): Promise { + if (this.client.isOpen) { + return; + } + + if (!this.connectPromise) { + this.connectPromise = this.client.connect().finally(() => { + this.connectPromise = undefined; + }); + } + + await this.connectPromise; + } + + async set(key: string, data: T): Promise { + await this.ensureConnected(); + await this.client.set(this.storageKey(key), JSON.stringify(data)); + } + + async get(key: string): Promise { + await this.ensureConnected(); + const raw = await this.client.get(this.storageKey(key)); + + if (!raw || raw.trim().length === 0) { + return undefined; + } + + try { + return JSON.parse(raw); + } catch (error: any) { + if (error instanceof Error) { + error.message = `Unleash storage failed parsing redis value for ${this.storageKey( + key, + )}: ${error.message}`; + } + throw error; + } + } + + async destroy(): Promise { + if (!this.ownsClient) { + return; + } + + if (this.client.isOpen) { + await this.client.quit(); + } + } +} diff --git a/src/repository/storage-provider.ts b/src/repository/storage-provider.ts index a98f54b3..40e99acd 100644 --- a/src/repository/storage-provider.ts +++ b/src/repository/storage-provider.ts @@ -1,6 +1,7 @@ export interface StorageProvider { set(key: string, data: T): Promise; get(key: string): Promise; + destroy?(): Promise; } export interface StorageOptions { diff --git a/src/test/repository/storage-provider-redis.test.ts b/src/test/repository/storage-provider-redis.test.ts new file mode 100644 index 00000000..319cea46 --- /dev/null +++ b/src/test/repository/storage-provider-redis.test.ts @@ -0,0 +1,80 @@ +import test from 'ava'; +import RedisStorageProvider from '../../repository/storage-provider-redis'; + +class FakeRedisClient { + isOpen = false; + + quitCalls = 0; + + private readonly store = new Map(); + + async connect(): Promise { + this.isOpen = true; + } + + async set(key: string, value: string): Promise { + this.store.set(key, value); + } + + async get(key: string): Promise { + return this.store.has(key) ? this.store.get(key)! : null; + } + + async quit(): Promise { + this.quitCalls += 1; + this.isOpen = false; + } + + setRaw(key: string, value: string): void { + this.store.set(key, value); + } +} + +test('redis storage stores and retrieves values', async (t) => { + const fakeClient = new FakeRedisClient(); + const storageProvider = new RedisStorageProvider<{ features: any[] }>({ + client: fakeClient as unknown as any, + }); + + await storageProvider.set('my-app', { features: [{ name: 'feature' }] }); + const result = await storageProvider.get('my-app'); + + t.deepEqual(result, { features: [{ name: 'feature' }] }); + t.true(fakeClient.isOpen); +}); + +test('redis storage returns undefined when no value is stored', async (t) => { + const fakeClient = new FakeRedisClient(); + const storageProvider = new RedisStorageProvider({ + client: fakeClient as unknown as any, + }); + + const result = await storageProvider.get('unknown'); + + t.is(result, undefined); +}); + +test('redis storage surfaces parse errors with context', async (t) => { + const fakeClient = new FakeRedisClient(); + const storageProvider = new RedisStorageProvider({ + client: fakeClient as unknown as any, + }); + + fakeClient.setRaw('unleash:backup:app_test', '{broken'); + + const error = await t.throwsAsync(async () => storageProvider.get('app/test')); + + t.truthy(error); + t.regex(error!.message, /unleash:backup:app_test/); +}); + +test('destroy does not quit external redis client', async (t) => { + const fakeClient = new FakeRedisClient(); + const storageProvider = new RedisStorageProvider({ + client: fakeClient as unknown as any, + }); + + await storageProvider.destroy(); + + t.is(fakeClient.quitCalls, 0); +});