Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 39 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: '<YOUR_API_TOKEN>' },
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: '<YOUR_API_TOKEN>' },
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 {
Expand Down
9 changes: 8 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
13 changes: 12 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
10 changes: 10 additions & 0 deletions src/repository/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
118 changes: 118 additions & 0 deletions src/repository/storage-provider-redis.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { safeName } from '../helpers';
import { StorageProvider } from './storage-provider';

type RedisClient = {
isOpen: boolean;
connect(): Promise<void>;
set(key: string, value: string): Promise<unknown>;
get(key: string): Promise<string | null>;
quit(): Promise<void>;
};

type RedisModule = {
createClient(options?: Record<string, unknown>): 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<string, unknown>;
keyPrefix?: string;
}

export default class RedisStorageProvider<T> implements StorageProvider<T> {
private readonly client: RedisClient;

private readonly ownsClient: boolean;

private readonly keyPrefix: string;

private connectPromise?: Promise<void>;

constructor(options: RedisStorageProviderOptions = {}) {
const { client, url, clientOptions, keyPrefix = 'unleash:backup:' } = options;

if (client) {
this.client = client;
this.ownsClient = false;
} else {
const redisOptions: Record<string, unknown> = { ...(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<void> {
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<void> {
await this.ensureConnected();
await this.client.set(this.storageKey(key), JSON.stringify(data));
}

async get(key: string): Promise<T | undefined> {
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<void> {
if (!this.ownsClient) {
return;
}

if (this.client.isOpen) {
await this.client.quit();
}
}
}
1 change: 1 addition & 0 deletions src/repository/storage-provider.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export interface StorageProvider<T> {
set(key: string, data: T): Promise<void>;
get(key: string): Promise<T | undefined>;
destroy?(): Promise<void>;
}

export interface StorageOptions {
Expand Down
80 changes: 80 additions & 0 deletions src/test/repository/storage-provider-redis.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>();

async connect(): Promise<void> {
this.isOpen = true;
}

async set(key: string, value: string): Promise<void> {
this.store.set(key, value);
}

async get(key: string): Promise<string | null> {
return this.store.has(key) ? this.store.get(key)! : null;
}

async quit(): Promise<void> {
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);
});
Loading