@chat-adapter/state-redis 4.0.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.
@@ -0,0 +1,44 @@
1
+ import { StateAdapter, Lock } from 'chat';
2
+ import { RedisClientType } from 'redis';
3
+
4
+ interface RedisStateAdapterOptions {
5
+ /** Redis connection URL (e.g., redis://localhost:6379) */
6
+ url: string;
7
+ /** Key prefix for all Redis keys (default: "chat-sdk") */
8
+ keyPrefix?: string;
9
+ }
10
+ /**
11
+ * Redis state adapter for production use.
12
+ *
13
+ * Provides persistent subscriptions and distributed locking
14
+ * across multiple server instances.
15
+ */
16
+ declare class RedisStateAdapter implements StateAdapter {
17
+ private client;
18
+ private keyPrefix;
19
+ private connected;
20
+ private connectPromise;
21
+ constructor(options: RedisStateAdapterOptions);
22
+ private key;
23
+ private subscriptionsSetKey;
24
+ connect(): Promise<void>;
25
+ disconnect(): Promise<void>;
26
+ subscribe(threadId: string): Promise<void>;
27
+ unsubscribe(threadId: string): Promise<void>;
28
+ isSubscribed(threadId: string): Promise<boolean>;
29
+ listSubscriptions(adapterName?: string): AsyncIterable<string>;
30
+ acquireLock(threadId: string, ttlMs: number): Promise<Lock | null>;
31
+ releaseLock(lock: Lock): Promise<void>;
32
+ extendLock(lock: Lock, ttlMs: number): Promise<boolean>;
33
+ get<T = unknown>(key: string): Promise<T | null>;
34
+ set<T = unknown>(key: string, value: T, ttlMs?: number): Promise<void>;
35
+ delete(key: string): Promise<void>;
36
+ private ensureConnected;
37
+ /**
38
+ * Get the underlying Redis client for advanced usage.
39
+ */
40
+ getClient(): RedisClientType;
41
+ }
42
+ declare function createRedisState(options: RedisStateAdapterOptions): RedisStateAdapter;
43
+
44
+ export { RedisStateAdapter, type RedisStateAdapterOptions, createRedisState };
package/dist/index.js ADDED
@@ -0,0 +1,174 @@
1
+ // src/index.ts
2
+ import { createClient } from "redis";
3
+ var RedisStateAdapter = class {
4
+ client;
5
+ keyPrefix;
6
+ connected = false;
7
+ connectPromise = null;
8
+ constructor(options) {
9
+ this.client = createClient({ url: options.url });
10
+ this.keyPrefix = options.keyPrefix || "chat-sdk";
11
+ this.client.on("error", (err) => {
12
+ console.error("[chat-sdk] Redis client error:", err);
13
+ });
14
+ }
15
+ key(type, id) {
16
+ return `${this.keyPrefix}:${type}:${id}`;
17
+ }
18
+ subscriptionsSetKey() {
19
+ return `${this.keyPrefix}:subscriptions`;
20
+ }
21
+ async connect() {
22
+ if (this.connected) {
23
+ return;
24
+ }
25
+ if (!this.connectPromise) {
26
+ this.connectPromise = this.client.connect().then(() => {
27
+ this.connected = true;
28
+ });
29
+ }
30
+ await this.connectPromise;
31
+ }
32
+ async disconnect() {
33
+ if (this.connected) {
34
+ await this.client.quit();
35
+ this.connected = false;
36
+ this.connectPromise = null;
37
+ }
38
+ }
39
+ async subscribe(threadId) {
40
+ this.ensureConnected();
41
+ await this.client.sAdd(this.subscriptionsSetKey(), threadId);
42
+ }
43
+ async unsubscribe(threadId) {
44
+ this.ensureConnected();
45
+ await this.client.sRem(this.subscriptionsSetKey(), threadId);
46
+ }
47
+ async isSubscribed(threadId) {
48
+ this.ensureConnected();
49
+ return this.client.sIsMember(this.subscriptionsSetKey(), threadId);
50
+ }
51
+ async *listSubscriptions(adapterName) {
52
+ this.ensureConnected();
53
+ let cursor = 0;
54
+ do {
55
+ const result = await this.client.sScan(
56
+ this.subscriptionsSetKey(),
57
+ cursor,
58
+ {
59
+ COUNT: 100
60
+ }
61
+ );
62
+ cursor = result.cursor;
63
+ for (const threadId of result.members) {
64
+ if (adapterName) {
65
+ if (threadId.startsWith(`${adapterName}:`)) {
66
+ yield threadId;
67
+ }
68
+ } else {
69
+ yield threadId;
70
+ }
71
+ }
72
+ } while (cursor !== 0);
73
+ }
74
+ async acquireLock(threadId, ttlMs) {
75
+ this.ensureConnected();
76
+ const token = generateToken();
77
+ const lockKey = this.key("lock", threadId);
78
+ const acquired = await this.client.set(lockKey, token, {
79
+ NX: true,
80
+ PX: ttlMs
81
+ });
82
+ if (acquired) {
83
+ return {
84
+ threadId,
85
+ token,
86
+ expiresAt: Date.now() + ttlMs
87
+ };
88
+ }
89
+ return null;
90
+ }
91
+ async releaseLock(lock) {
92
+ this.ensureConnected();
93
+ const lockKey = this.key("lock", lock.threadId);
94
+ const script = `
95
+ if redis.call("get", KEYS[1]) == ARGV[1] then
96
+ return redis.call("del", KEYS[1])
97
+ else
98
+ return 0
99
+ end
100
+ `;
101
+ await this.client.eval(script, {
102
+ keys: [lockKey],
103
+ arguments: [lock.token]
104
+ });
105
+ }
106
+ async extendLock(lock, ttlMs) {
107
+ this.ensureConnected();
108
+ const lockKey = this.key("lock", lock.threadId);
109
+ const script = `
110
+ if redis.call("get", KEYS[1]) == ARGV[1] then
111
+ return redis.call("pexpire", KEYS[1], ARGV[2])
112
+ else
113
+ return 0
114
+ end
115
+ `;
116
+ const result = await this.client.eval(script, {
117
+ keys: [lockKey],
118
+ arguments: [lock.token, ttlMs.toString()]
119
+ });
120
+ return result === 1;
121
+ }
122
+ async get(key) {
123
+ this.ensureConnected();
124
+ const cacheKey = this.key("cache", key);
125
+ const value = await this.client.get(cacheKey);
126
+ if (value === null) {
127
+ return null;
128
+ }
129
+ try {
130
+ return JSON.parse(value);
131
+ } catch {
132
+ return value;
133
+ }
134
+ }
135
+ async set(key, value, ttlMs) {
136
+ this.ensureConnected();
137
+ const cacheKey = this.key("cache", key);
138
+ const serialized = JSON.stringify(value);
139
+ if (ttlMs) {
140
+ await this.client.set(cacheKey, serialized, { PX: ttlMs });
141
+ } else {
142
+ await this.client.set(cacheKey, serialized);
143
+ }
144
+ }
145
+ async delete(key) {
146
+ this.ensureConnected();
147
+ const cacheKey = this.key("cache", key);
148
+ await this.client.del(cacheKey);
149
+ }
150
+ ensureConnected() {
151
+ if (!this.connected) {
152
+ throw new Error(
153
+ "RedisStateAdapter is not connected. Call connect() first."
154
+ );
155
+ }
156
+ }
157
+ /**
158
+ * Get the underlying Redis client for advanced usage.
159
+ */
160
+ getClient() {
161
+ return this.client;
162
+ }
163
+ };
164
+ function generateToken() {
165
+ return `redis_${Date.now()}_${Math.random().toString(36).substring(2, 15)}`;
166
+ }
167
+ function createRedisState(options) {
168
+ return new RedisStateAdapter(options);
169
+ }
170
+ export {
171
+ RedisStateAdapter,
172
+ createRedisState
173
+ };
174
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/index.ts"],"sourcesContent":["import type { Lock, StateAdapter } from \"chat\";\nimport { createClient, type RedisClientType } from \"redis\";\n\nexport interface RedisStateAdapterOptions {\n /** Redis connection URL (e.g., redis://localhost:6379) */\n url: string;\n /** Key prefix for all Redis keys (default: \"chat-sdk\") */\n keyPrefix?: string;\n}\n\n/**\n * Redis state adapter for production use.\n *\n * Provides persistent subscriptions and distributed locking\n * across multiple server instances.\n */\nexport class RedisStateAdapter implements StateAdapter {\n private client: RedisClientType;\n private keyPrefix: string;\n private connected = false;\n private connectPromise: Promise<void> | null = null;\n\n constructor(options: RedisStateAdapterOptions) {\n this.client = createClient({ url: options.url });\n this.keyPrefix = options.keyPrefix || \"chat-sdk\";\n\n // Handle connection errors\n this.client.on(\"error\", (err) => {\n console.error(\"[chat-sdk] Redis client error:\", err);\n });\n }\n\n private key(type: \"sub\" | \"lock\" | \"cache\", id: string): string {\n return `${this.keyPrefix}:${type}:${id}`;\n }\n\n private subscriptionsSetKey(): string {\n return `${this.keyPrefix}:subscriptions`;\n }\n\n async connect(): Promise<void> {\n if (this.connected) {\n return;\n }\n\n // Reuse existing connection attempt to avoid race conditions\n if (!this.connectPromise) {\n this.connectPromise = this.client.connect().then(() => {\n this.connected = true;\n });\n }\n\n await this.connectPromise;\n }\n\n async disconnect(): Promise<void> {\n if (this.connected) {\n await this.client.quit();\n this.connected = false;\n this.connectPromise = null;\n }\n }\n\n async subscribe(threadId: string): Promise<void> {\n this.ensureConnected();\n await this.client.sAdd(this.subscriptionsSetKey(), threadId);\n }\n\n async unsubscribe(threadId: string): Promise<void> {\n this.ensureConnected();\n await this.client.sRem(this.subscriptionsSetKey(), threadId);\n }\n\n async isSubscribed(threadId: string): Promise<boolean> {\n this.ensureConnected();\n return this.client.sIsMember(this.subscriptionsSetKey(), threadId);\n }\n\n async *listSubscriptions(adapterName?: string): AsyncIterable<string> {\n this.ensureConnected();\n\n // Use SSCAN for large sets to avoid blocking\n let cursor = 0;\n do {\n const result = await this.client.sScan(\n this.subscriptionsSetKey(),\n cursor,\n {\n COUNT: 100,\n },\n );\n cursor = result.cursor;\n\n for (const threadId of result.members) {\n if (adapterName) {\n if (threadId.startsWith(`${adapterName}:`)) {\n yield threadId;\n }\n } else {\n yield threadId;\n }\n }\n } while (cursor !== 0);\n }\n\n async acquireLock(threadId: string, ttlMs: number): Promise<Lock | null> {\n this.ensureConnected();\n\n const token = generateToken();\n const lockKey = this.key(\"lock\", threadId);\n\n // Use SET NX EX for atomic lock acquisition\n const acquired = await this.client.set(lockKey, token, {\n NX: true,\n PX: ttlMs,\n });\n\n if (acquired) {\n return {\n threadId,\n token,\n expiresAt: Date.now() + ttlMs,\n };\n }\n\n return null;\n }\n\n async releaseLock(lock: Lock): Promise<void> {\n this.ensureConnected();\n\n const lockKey = this.key(\"lock\", lock.threadId);\n\n // Use Lua script for atomic check-and-delete\n const script = `\n if redis.call(\"get\", KEYS[1]) == ARGV[1] then\n return redis.call(\"del\", KEYS[1])\n else\n return 0\n end\n `;\n\n await this.client.eval(script, {\n keys: [lockKey],\n arguments: [lock.token],\n });\n }\n\n async extendLock(lock: Lock, ttlMs: number): Promise<boolean> {\n this.ensureConnected();\n\n const lockKey = this.key(\"lock\", lock.threadId);\n\n // Use Lua script for atomic check-and-extend\n const script = `\n if redis.call(\"get\", KEYS[1]) == ARGV[1] then\n return redis.call(\"pexpire\", KEYS[1], ARGV[2])\n else\n return 0\n end\n `;\n\n const result = await this.client.eval(script, {\n keys: [lockKey],\n arguments: [lock.token, ttlMs.toString()],\n });\n\n return result === 1;\n }\n\n async get<T = unknown>(key: string): Promise<T | null> {\n this.ensureConnected();\n\n const cacheKey = this.key(\"cache\", key);\n const value = await this.client.get(cacheKey);\n\n if (value === null) {\n return null;\n }\n\n try {\n return JSON.parse(value) as T;\n } catch {\n // If parsing fails, return as string\n return value as unknown as T;\n }\n }\n\n async set<T = unknown>(key: string, value: T, ttlMs?: number): Promise<void> {\n this.ensureConnected();\n\n const cacheKey = this.key(\"cache\", key);\n const serialized = JSON.stringify(value);\n\n if (ttlMs) {\n await this.client.set(cacheKey, serialized, { PX: ttlMs });\n } else {\n await this.client.set(cacheKey, serialized);\n }\n }\n\n async delete(key: string): Promise<void> {\n this.ensureConnected();\n\n const cacheKey = this.key(\"cache\", key);\n await this.client.del(cacheKey);\n }\n\n private ensureConnected(): void {\n if (!this.connected) {\n throw new Error(\n \"RedisStateAdapter is not connected. Call connect() first.\",\n );\n }\n }\n\n /**\n * Get the underlying Redis client for advanced usage.\n */\n getClient(): RedisClientType {\n return this.client;\n }\n}\n\nfunction generateToken(): string {\n return `redis_${Date.now()}_${Math.random().toString(36).substring(2, 15)}`;\n}\n\nexport function createRedisState(\n options: RedisStateAdapterOptions,\n): RedisStateAdapter {\n return new RedisStateAdapter(options);\n}\n"],"mappings":"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","names":[]}
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "@chat-adapter/state-redis",
3
+ "version": "4.0.0",
4
+ "description": "Redis state adapter for chat (production)",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "module": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "import": "./dist/index.js"
13
+ }
14
+ },
15
+ "files": [
16
+ "dist"
17
+ ],
18
+ "dependencies": {
19
+ "redis": "^4.7.0",
20
+ "chat": "4.0.0"
21
+ },
22
+ "publishConfig": {
23
+ "access": "public"
24
+ },
25
+ "devDependencies": {
26
+ "@types/node": "^22.10.2",
27
+ "tsup": "^8.3.5",
28
+ "typescript": "^5.7.2",
29
+ "vitest": "^2.1.8"
30
+ },
31
+ "keywords": [
32
+ "chat",
33
+ "state",
34
+ "redis",
35
+ "production"
36
+ ],
37
+ "license": "MIT",
38
+ "scripts": {
39
+ "build": "tsup",
40
+ "dev": "tsup --watch",
41
+ "test": "vitest run",
42
+ "test:watch": "vitest",
43
+ "typecheck": "tsc --noEmit",
44
+ "lint": "biome check src",
45
+ "clean": "rm -rf dist"
46
+ }
47
+ }