@bhooai/nexus-cache 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,24 @@
1
+ # @bhooai/nexus-cache
2
+
3
+ Redis wrapper, cache-aside, pub/sub, and the Redis-backed rate-limit backend.
4
+
5
+ ## Exports
6
+
7
+ - **Cache** — `new Cache({ url, keyPrefix })`. `get/set/del/incr` with TTL,
8
+ cache-aside helpers, graceful no-op when Redis is unavailable.
9
+ - **RateLimiter** — `new RateLimiter({ windowMs, max })` (Redis-backed;
10
+ in-memory fallback in `nexus-auth`).
11
+ - **PubSub** — publish/subscribe for WS fanout and inter-process events.
12
+
13
+ ## Usage
14
+
15
+ ```ts
16
+ import { Cache } from '@bhooai/nexus-cache';
17
+ const cache = new Cache({ url: 'redis://localhost:6379', keyPrefix: 'app' });
18
+ await cache.set('k', 'v', { ttl: 60 });
19
+ const v = await cache.get('k');
20
+ await cache.close();
21
+ ```
22
+
23
+ Redis is **optional**: if the URL is unreachable the cache degrades to misses and
24
+ the server keeps running. Tests use a real Redis via `.env` (`NEXUS_REDIS_URL`).
package/package.json ADDED
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "@bhooai/nexus-cache",
3
+ "version": "0.1.0",
4
+ "publishConfig": { "access": "public" },
5
+ "type": "module",
6
+ "main": "./src/index.ts",
7
+ "types": "./src/index.ts",
8
+ "scripts": {
9
+ "build": "tsc -p tsconfig.json",
10
+ "test": "vitest run"
11
+ },
12
+ "dependencies": {
13
+ "@bhooai/nexus-core": "^0.1.0",
14
+ "redis": "^4.7.0"
15
+ },
16
+ "devDependencies": {
17
+ "@types/node": "^22.5.0",
18
+ "typescript": "^5.6.2",
19
+ "vitest": "^2.1.1"
20
+ }
21
+ }
package/src/Cache.ts ADDED
@@ -0,0 +1,155 @@
1
+ import { createClient, type RedisClientType } from 'redis';
2
+ import type { CacheOptions } from './types.js';
3
+
4
+ /**
5
+ * Redis-backed cache wrapper. Lazily creates and connects a `node-redis`
6
+ * client on first use. All keys pass through {@link Cache.key} to apply an
7
+ * optional namespace/prefix.
8
+ */
9
+ export class Cache {
10
+ private client: RedisClientType;
11
+ private connectPromise: Promise<void> | null = null;
12
+ private readonly keyPrefix: string;
13
+ protected readonly url?: string;
14
+ protected readonly connectTimeout?: number;
15
+
16
+ constructor(opts: CacheOptions = {}) {
17
+ this.url = opts.url;
18
+ this.connectTimeout = opts.connectTimeout;
19
+ this.keyPrefix = opts.keyPrefix ?? (opts.namespace ? `${opts.namespace}:` : '');
20
+ // `redis` v4 createClient returns a client in a closed state; we connect
21
+ // lazily on first use via ensureConnected().
22
+ this.client = createClient({
23
+ ...(this.url ? { url: this.url } : {}),
24
+ ...(this.connectTimeout != null ? { socket: { connectTimeout: this.connectTimeout } } : {}),
25
+ });
26
+ }
27
+
28
+ /** Connect the underlying client if not already connected. Idempotent. */
29
+ async ensureConnected(): Promise<void> {
30
+ if (this.client.isOpen) return;
31
+ if (!this.connectPromise) {
32
+ this.connectPromise = this.client.connect().then(() => {
33
+ // connected
34
+ });
35
+ }
36
+ try {
37
+ await this.connectPromise;
38
+ } catch (err) {
39
+ // Reset so a later retry can attempt reconnection.
40
+ this.connectPromise = null;
41
+ throw err;
42
+ }
43
+ }
44
+
45
+ /** Build the fully-prefixed redis key for a logical name. */
46
+ key(name: string): string {
47
+ return `${this.keyPrefix}${name}`;
48
+ }
49
+
50
+ /** The underlying redis client (connected after `ensureConnected`). */
51
+ protected getClient(): RedisClientType {
52
+ return this.client;
53
+ }
54
+
55
+ /** Get a JSON-decoded value, or undefined on miss/null. */
56
+ async get<T>(name: string): Promise<T | undefined> {
57
+ await this.ensureConnected();
58
+ const raw = await this.client.get(this.key(name));
59
+ if (raw == null) return undefined;
60
+ return JSON.parse(raw) as T;
61
+ }
62
+
63
+ /** Set a JSON-encoded value, optionally with a TTL in seconds. */
64
+ async set<T>(name: string, value: T, ttlSeconds?: number): Promise<void> {
65
+ await this.ensureConnected();
66
+ const encoded = JSON.stringify(value);
67
+ if (ttlSeconds != null) {
68
+ await this.client.set(this.key(name), encoded, { EX: ttlSeconds });
69
+ } else {
70
+ await this.client.set(this.key(name), encoded);
71
+ }
72
+ }
73
+
74
+ /** Delete a key. */
75
+ async del(name: string): Promise<void> {
76
+ await this.ensureConnected();
77
+ await this.client.del(this.key(name));
78
+ }
79
+
80
+ /**
81
+ * INCR a key. When `ttlSeconds` is supplied and the result becomes 1 (first
82
+ * hit in a fresh window) the key is expired after `ttlSeconds` seconds.
83
+ * Returns the post-increment integer value.
84
+ */
85
+ async incr(name: string, ttlSeconds?: number): Promise<number> {
86
+ await this.ensureConnected();
87
+ const key = this.key(name);
88
+ const count = await this.client.incr(key);
89
+ if (ttlSeconds != null && count === 1) {
90
+ await this.client.expire(key, ttlSeconds);
91
+ }
92
+ return count;
93
+ }
94
+
95
+ /** Whether a key exists. */
96
+ async exists(name: string): Promise<boolean> {
97
+ await this.ensureConnected();
98
+ const n = await this.client.exists(this.key(name));
99
+ return n > 0;
100
+ }
101
+
102
+ /** Set an expiry (seconds) on an existing key. */
103
+ async expire(name: string, ttlSeconds: number): Promise<void> {
104
+ await this.ensureConnected();
105
+ await this.client.expire(this.key(name), ttlSeconds);
106
+ }
107
+
108
+ /** Remaining TTL in seconds (-1 no expiry, -2 missing key). */
109
+ async ttl(name: string): Promise<number> {
110
+ await this.ensureConnected();
111
+ return this.client.ttl(this.key(name));
112
+ }
113
+
114
+ /**
115
+ * Delete all keys matching a glob pattern using non-blocking SCAN + DEL.
116
+ * Returns the number of keys removed.
117
+ */
118
+ async flushPattern(pattern: string): Promise<number> {
119
+ await this.ensureConnected();
120
+ const fullPattern = `${this.keyPrefix}${pattern}`;
121
+ let cursor = 0;
122
+ let removed = 0;
123
+ do {
124
+ const reply = await this.client.scan(cursor, { MATCH: fullPattern, COUNT: 100 });
125
+ cursor = Number(reply.cursor);
126
+ const keys = reply.keys;
127
+ if (keys.length > 0) {
128
+ await this.client.del(keys);
129
+ removed += keys.length;
130
+ }
131
+ } while (cursor !== 0);
132
+ return removed;
133
+ }
134
+
135
+ /**
136
+ * Cache-aside helper: return the cached value if present, otherwise invoke
137
+ * `loader`, store its result with `ttlSeconds`, and return it. No negative
138
+ * caching in v1 — loader errors propagate to the caller.
139
+ */
140
+ async cacheAside<T>(name: string, ttlSeconds: number, loader: () => Promise<T>): Promise<T> {
141
+ const cached = await this.get<T>(name);
142
+ if (cached !== undefined) return cached;
143
+ const fresh = await loader();
144
+ await this.set(name, fresh, ttlSeconds);
145
+ return fresh;
146
+ }
147
+
148
+ /** Disconnect the underlying client. */
149
+ async close(): Promise<void> {
150
+ if (this.client.isOpen) {
151
+ await this.client.quit();
152
+ }
153
+ this.connectPromise = null;
154
+ }
155
+ }
package/src/PubSub.ts ADDED
@@ -0,0 +1,98 @@
1
+ import { createClient, type RedisClientType } from 'redis';
2
+ import type { Cache } from './Cache.js';
3
+
4
+ type Handler = (msg: unknown) => void;
5
+
6
+ /**
7
+ * Redis pub/sub helper. Uses the {@link Cache} client for publishing and a
8
+ * separate, dedicated subscriber client (required by `redis` v4) for
9
+ * subscriptions. Both clients are connected lazily.
10
+ */
11
+ export class PubSub {
12
+ private subscriber: RedisClientType;
13
+ private subscriberConnectPromise: Promise<void> | null = null;
14
+ private handlers = new Map<string, Set<Handler>>();
15
+
16
+ constructor(private cache: Cache) {
17
+ // Reuse the same URL/connection options as the cache's publisher client.
18
+ // The cache exposes its url/connectTimeout via protected fields; here we
19
+ // reconstruct from the cache's key prefix-agnostic config by reading the
20
+ // same options the cache used. We mirror the cache's createClient call.
21
+ const url = (cache as unknown as { url?: string }).url;
22
+ const connectTimeout = (cache as unknown as { connectTimeout?: number }).connectTimeout;
23
+ this.subscriber = createClient({
24
+ ...(url ? { url } : {}),
25
+ ...(connectTimeout != null ? { socket: { connectTimeout } } : {}),
26
+ });
27
+ }
28
+
29
+ private async ensureSubscriberConnected(): Promise<void> {
30
+ if (this.subscriber.isOpen) return;
31
+ if (!this.subscriberConnectPromise) {
32
+ this.subscriberConnectPromise = this.subscriber.connect().then(() => undefined);
33
+ }
34
+ try {
35
+ await this.subscriberConnectPromise;
36
+ } catch (err) {
37
+ this.subscriberConnectPromise = null;
38
+ throw err;
39
+ }
40
+ }
41
+
42
+ /** Publish a JSON-encoded message to `channel`. */
43
+ async publish(channel: string, message: unknown): Promise<void> {
44
+ // Ensure the cache (publisher) client is connected.
45
+ await this.cache.ensureConnected();
46
+ const publisher = (this.cache as unknown as { getClient: () => RedisClientType }).getClient();
47
+ await publisher.publish(channel, JSON.stringify(message));
48
+ }
49
+
50
+ /**
51
+ * Subscribe to `channel`; `handler` is invoked with the JSON-parsed payload.
52
+ * Returns an unsubscribe function that removes the handler and, when no
53
+ * handlers remain for the channel, unsubscribes from Redis.
54
+ */
55
+ async subscribe(channel: string, handler: Handler): Promise<() => Promise<void>> {
56
+ await this.ensureSubscriberConnected();
57
+ let set = this.handlers.get(channel);
58
+ if (!set) {
59
+ set = new Set();
60
+ this.handlers.set(channel, set);
61
+ // `redis` v4 subscribe(channel, callback) registers a per-channel
62
+ // listener that receives the message string.
63
+ await this.subscriber.subscribe(channel, (message) => {
64
+ const handlers = this.handlers.get(channel);
65
+ if (!handlers) return;
66
+ let parsed: unknown;
67
+ try {
68
+ parsed = JSON.parse(message);
69
+ } catch {
70
+ parsed = message;
71
+ }
72
+ for (const h of handlers) h(parsed);
73
+ });
74
+ }
75
+ set.add(handler);
76
+ return async () => {
77
+ const current = this.handlers.get(channel);
78
+ if (!current) return;
79
+ current.delete(handler);
80
+ if (current.size === 0) {
81
+ this.handlers.delete(channel);
82
+ try {
83
+ await this.subscriber.unsubscribe(channel);
84
+ } catch {
85
+ /* ignore — client may be closing */
86
+ }
87
+ }
88
+ };
89
+ }
90
+
91
+ /** Close the dedicated subscriber client. */
92
+ async close(): Promise<void> {
93
+ if (this.subscriber.isOpen) {
94
+ await this.subscriber.quit();
95
+ }
96
+ this.subscriberConnectPromise = null;
97
+ }
98
+ }
@@ -0,0 +1,43 @@
1
+ import type { Cache } from './Cache.js';
2
+ import type { RateLimitResult, RateLimitRule } from './types.js';
3
+
4
+ /**
5
+ * Fixed-window counter rate limiter backed by Redis INCR via a {@link Cache}.
6
+ * Each call to {@link RateLimiter.check} atomically increments a per-key
7
+ * counter and, on the first hit of a window, sets the window expiry.
8
+ */
9
+ export class RateLimiter {
10
+ constructor(private cache: Cache) {}
11
+
12
+ /**
13
+ * Check (and consume) one unit against the rule for `key`. Redis INCR both
14
+ * increments and returns the count, so the counter is consumed regardless of
15
+ * whether the request is allowed — matching the in-memory store semantics.
16
+ */
17
+ async check(key: string, rule: RateLimitRule): Promise<RateLimitResult> {
18
+ const redisKey = `${key}:rate`;
19
+ const count = await this.cache.incr(redisKey, rule.windowSeconds);
20
+ const limit = rule.max;
21
+ const allowed = count <= limit;
22
+ const remaining = Math.max(0, limit - count);
23
+ // Use the live TTL to compute resetAt; falls back to now + window.
24
+ let ttl = await this.cache.ttl(redisKey);
25
+ if (ttl < 0) ttl = rule.windowSeconds;
26
+ const resetAt = Date.now() + ttl * 1000;
27
+ const result: RateLimitResult = {
28
+ allowed,
29
+ limit,
30
+ remaining,
31
+ resetAt,
32
+ };
33
+ if (!allowed) {
34
+ result.retryAfter = rule.windowSeconds;
35
+ }
36
+ return result;
37
+ }
38
+
39
+ /** Alias of {@link check}; the counter is already consumed by INCR. */
40
+ async consume(key: string, rule: RateLimitRule): Promise<RateLimitResult> {
41
+ return this.check(key, rule);
42
+ }
43
+ }
package/src/index.ts ADDED
@@ -0,0 +1,9 @@
1
+ export { Cache } from './Cache.js';
2
+ export { RateLimiter } from './RateLimiter.js';
3
+ export { PubSub } from './PubSub.js';
4
+ export type {
5
+ CacheOptions,
6
+ CacheEntry,
7
+ RateLimitResult,
8
+ RateLimitRule,
9
+ } from './types.js';
package/src/types.ts ADDED
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Options for constructing a {@link Cache} instance.
3
+ */
4
+ export interface CacheOptions {
5
+ /** Redis connection URL (e.g. `redis://localhost:6379`). */
6
+ url?: string;
7
+ /** Logical namespace applied to all keys as `${namespace}:` prefix. */
8
+ namespace?: string;
9
+ /** Explicit key prefix (overrides namespace prefix when provided). */
10
+ keyPrefix?: string;
11
+ /** Connection timeout in milliseconds passed to the redis client. */
12
+ connectTimeout?: number;
13
+ }
14
+
15
+ /**
16
+ * A cached value entry (kept for symmetry; values are JSON-stringified as-is).
17
+ */
18
+ export interface CacheEntry<T> {
19
+ value: T;
20
+ expiresAt?: number;
21
+ }
22
+
23
+ /**
24
+ * Result of a rate-limit check.
25
+ */
26
+ export interface RateLimitResult {
27
+ /** Whether the request is allowed under the current window. */
28
+ allowed: boolean;
29
+ /** Maximum requests permitted in the window. */
30
+ limit: number;
31
+ /** Remaining requests in the current window (>= 0). */
32
+ remaining: number;
33
+ /** Epoch ms when the current window resets. */
34
+ resetAt: number;
35
+ /** Seconds until the next request would be allowed (present when denied). */
36
+ retryAfter?: number;
37
+ }
38
+
39
+ /**
40
+ * Fixed-window rate-limit rule.
41
+ */
42
+ export interface RateLimitRule {
43
+ /** Window length in seconds. */
44
+ windowSeconds: number;
45
+ /** Maximum requests permitted per window. */
46
+ max: number;
47
+ }
@@ -0,0 +1,185 @@
1
+ import { describe, it, expect, afterAll, afterEach } from 'vitest';
2
+ import { Cache, RateLimiter, PubSub } from '../src/index.js';
3
+ import type { Cache as CacheType } from '../src/index.js';
4
+
5
+ const REDIS_URL = process.env.REDIS_URL ?? 'redis://localhost:6379';
6
+
7
+ async function connectOrSkip(): Promise<CacheType | null> {
8
+ const c = new Cache({ url: REDIS_URL, connectTimeout: 2000, namespace: 'nexus-cache-test' });
9
+ try {
10
+ await c.ensureConnected();
11
+ return c;
12
+ } catch (e) {
13
+ // eslint-disable-next-line no-console
14
+ console.warn('[nexus-cache tests] Redis unavailable, skipping live tests:', (e as Error).message);
15
+ return null;
16
+ }
17
+ }
18
+
19
+ // Top-level await so `cache` is populated before describe bodies register tests.
20
+ const cache = await connectOrSkip();
21
+
22
+ afterEach(async () => {
23
+ if (cache) {
24
+ // Clean all test keys between tests.
25
+ try {
26
+ await cache.flushPattern('*');
27
+ } catch {
28
+ /* ignore */
29
+ }
30
+ }
31
+ });
32
+
33
+ afterAll(async () => {
34
+ if (cache) {
35
+ try {
36
+ await cache.flushPattern('*');
37
+ } catch {
38
+ /* ignore */
39
+ }
40
+ await cache.close();
41
+ }
42
+ });
43
+
44
+ /** Helper: run `fn` only when redis is available, else skip. */
45
+ function itif(name: string, fn: () => Promise<void>): void {
46
+ if (cache) {
47
+ it(name, fn);
48
+ } else {
49
+ it.skip(name, fn);
50
+ }
51
+ }
52
+
53
+ describe('Cache: get / set / del', () => {
54
+ itif('set/get round-trip of a JSON object', async () => {
55
+ const c = cache!;
56
+ await c.set('obj', { a: 1, b: ['x', 'y'] });
57
+ const got = await c.get<{ a: number; b: string[] }>('obj');
58
+ expect(got).toEqual({ a: 1, b: ['x', 'y'] });
59
+ });
60
+
61
+ itif('get miss returns undefined', async () => {
62
+ const c = cache!;
63
+ const got = await c.get<{ x: number }>('does-not-exist');
64
+ expect(got).toBeUndefined();
65
+ });
66
+
67
+ itif('del removes the key', async () => {
68
+ const c = cache!;
69
+ await c.set('todelete', { v: 1 });
70
+ expect(await c.get('todelete')).toEqual({ v: 1 });
71
+ await c.del('todelete');
72
+ expect(await c.get('todelete')).toBeUndefined();
73
+ });
74
+ });
75
+
76
+ describe('Cache: TTL', () => {
77
+ itif('set with ttl=1s expires after 1100ms', async () => {
78
+ const c = cache!;
79
+ await c.set('temp', { n: 42 }, 1);
80
+ expect(await c.get('temp')).toEqual({ n: 42 });
81
+ await new Promise((r) => setTimeout(r, 1100));
82
+ expect(await c.get('temp')).toBeUndefined();
83
+ });
84
+ });
85
+
86
+ describe('Cache: incr', () => {
87
+ itif('starts at 1, increments, sets ttl on first hit, restarts after window', async () => {
88
+ const c = cache!;
89
+ const first = await c.incr('counter', 2);
90
+ expect(first).toBe(1);
91
+ const second = await c.incr('counter', 2);
92
+ expect(second).toBe(2);
93
+ // After window expiry the key should be gone and INCR restarts at 1.
94
+ await new Promise((r) => setTimeout(r, 2200));
95
+ const restarted = await c.incr('counter', 2);
96
+ expect(restarted).toBe(1);
97
+ });
98
+ });
99
+
100
+ describe('Cache: cacheAside', () => {
101
+ itif('miss calls loader and caches; second call does not call loader', async () => {
102
+ const c = cache!;
103
+ let calls = 0;
104
+ const loader = async () => {
105
+ calls++;
106
+ return { hello: 'world' };
107
+ };
108
+ const r1 = await c.cacheAside('aside', 10, loader);
109
+ expect(r1).toEqual({ hello: 'world' });
110
+ expect(calls).toBe(1);
111
+ const r2 = await c.cacheAside('aside', 10, loader);
112
+ expect(r2).toEqual({ hello: 'world' });
113
+ expect(calls).toBe(1);
114
+ });
115
+ });
116
+
117
+ describe('RateLimiter.check', () => {
118
+ itif('allows up to max then denies; allows again after window', async () => {
119
+ const c = cache!;
120
+ const limiter = new RateLimiter(c);
121
+ const rule = { windowSeconds: 2, max: 3 };
122
+ const r1 = await limiter.check('ip', rule);
123
+ expect(r1.allowed).toBe(true);
124
+ expect(r1.remaining).toBe(2);
125
+ const r2 = await limiter.check('ip', rule);
126
+ expect(r2.allowed).toBe(true);
127
+ expect(r2.remaining).toBe(1);
128
+ const r3 = await limiter.check('ip', rule);
129
+ expect(r3.allowed).toBe(true);
130
+ expect(r3.remaining).toBe(0);
131
+ const r4 = await limiter.check('ip', rule);
132
+ expect(r4.allowed).toBe(false);
133
+ expect(r4.remaining).toBe(0);
134
+ expect(r4.retryAfter).toBeDefined();
135
+ // After window expires the counter resets and a request is allowed again.
136
+ await new Promise((r) => setTimeout(r, 2200));
137
+ const r5 = await limiter.check('ip', rule);
138
+ expect(r5.allowed).toBe(true);
139
+ expect(r5.remaining).toBe(2);
140
+ });
141
+ });
142
+
143
+ describe('PubSub', () => {
144
+ itif('publish reaches a subscribed handler with parsed payload', async () => {
145
+ const c = cache!;
146
+ const pubsub = new PubSub(c);
147
+ const received = new Promise<unknown>((resolve) => {
148
+ void pubsub.subscribe('test', (msg) => resolve(msg));
149
+ });
150
+ // Give the subscription a moment to register.
151
+ await new Promise((r) => setTimeout(r, 50));
152
+ await pubsub.publish('test', { hello: 'pubsub' });
153
+ const msg = await received;
154
+ expect(msg).toEqual({ hello: 'pubsub' });
155
+ await pubsub.close();
156
+ });
157
+ });
158
+
159
+ describe('Cache: flushPattern', () => {
160
+ itif('removes matching keys and leaves others', async () => {
161
+ const c = cache!;
162
+ // Use namespaced names so the prefix is applied.
163
+ await c.set('ns:a', 1);
164
+ await c.set('ns:b', 2);
165
+ await c.set('other', 3);
166
+ const removed = await c.flushPattern('ns:*');
167
+ expect(removed).toBeGreaterThanOrEqual(2);
168
+ expect(await c.get('ns:a')).toBeUndefined();
169
+ expect(await c.get('ns:b')).toBeUndefined();
170
+ expect(await c.get('other')).toEqual(3);
171
+ });
172
+ });
173
+
174
+ describe('Cache: exists / expire / ttl', () => {
175
+ itif('exists reports presence, expire sets ttl, ttl reports remaining', async () => {
176
+ const c = cache!;
177
+ await c.set('k', 'v');
178
+ expect(await c.exists('k')).toBe(true);
179
+ expect(await c.exists('missing')).toBe(false);
180
+ await c.expire('k', 5);
181
+ const t = await c.ttl('k');
182
+ expect(t).toBeGreaterThan(0);
183
+ expect(t).toBeLessThanOrEqual(5);
184
+ });
185
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,11 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "rootDir": "./src",
5
+ "outDir": "./dist"
6
+ },
7
+ "include": ["src/**/*"],
8
+ "references": [
9
+ { "path": "../nexus-core" }
10
+ ]
11
+ }
@@ -0,0 +1,10 @@
1
+ import { defineProject } from 'vitest/config';
2
+
3
+ export default defineProject({
4
+ test: {
5
+ environment: 'node',
6
+ include: ['tests/**/*.test.ts'],
7
+ globals: false,
8
+ testTimeout: 15_000,
9
+ },
10
+ });