ioredis-toolkit 0.0.1
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/LICENSE +21 -0
- package/README.md +645 -0
- package/dist/cache.d.ts +298 -0
- package/dist/cache.js +606 -0
- package/dist/client.d.ts +177 -0
- package/dist/client.js +958 -0
- package/dist/cluster-slot.d.ts +4 -0
- package/dist/cluster-slot.js +31 -0
- package/dist/cluster.d.ts +79 -0
- package/dist/cluster.js +156 -0
- package/dist/errors.d.ts +30 -0
- package/dist/errors.js +63 -0
- package/dist/health.d.ts +39 -0
- package/dist/health.js +106 -0
- package/dist/index.d.ts +51 -0
- package/dist/index.js +44 -0
- package/dist/lock.d.ts +215 -0
- package/dist/lock.js +385 -0
- package/dist/logger.d.ts +12 -0
- package/dist/logger.js +40 -0
- package/dist/pubsub.d.ts +171 -0
- package/dist/pubsub.js +285 -0
- package/dist/ratelimiter.d.ts +162 -0
- package/dist/ratelimiter.js +289 -0
- package/dist/session/index.d.ts +23 -0
- package/dist/session/index.js +16 -0
- package/dist/session/revocation-store.d.ts +171 -0
- package/dist/session/revocation-store.js +310 -0
- package/dist/session/scripts/cleanup-index.lua +21 -0
- package/dist/session/scripts/conditional-update-encrypted.lua +60 -0
- package/dist/session/scripts/conditional-update.lua +63 -0
- package/dist/session/scripts/create.lua +68 -0
- package/dist/session/scripts/delete-by-user.lua +29 -0
- package/dist/session/scripts/delete.lua +15 -0
- package/dist/session/scripts/enforce-limit.lua +38 -0
- package/dist/session/scripts/revoke.lua +61 -0
- package/dist/session/scripts/rotate-encrypted.lua +107 -0
- package/dist/session/scripts/rotate.lua +119 -0
- package/dist/session/scripts/touch-encrypted.lua +89 -0
- package/dist/session/scripts/touch.lua +72 -0
- package/dist/session/scripts/validate.lua +90 -0
- package/dist/session/session-circuit-breaker.d.ts +42 -0
- package/dist/session/session-circuit-breaker.js +129 -0
- package/dist/session/session-config.d.ts +335 -0
- package/dist/session/session-config.js +162 -0
- package/dist/session/session-cookie.d.ts +72 -0
- package/dist/session/session-cookie.js +101 -0
- package/dist/session/session-encryption.d.ts +87 -0
- package/dist/session/session-encryption.js +139 -0
- package/dist/session/session-errors.d.ts +85 -0
- package/dist/session/session-errors.js +145 -0
- package/dist/session/session-health.d.ts +38 -0
- package/dist/session/session-health.js +60 -0
- package/dist/session/session-keys.d.ts +51 -0
- package/dist/session/session-keys.js +113 -0
- package/dist/session/session-manager.d.ts +59 -0
- package/dist/session/session-manager.js +94 -0
- package/dist/session/session-metrics.d.ts +33 -0
- package/dist/session/session-metrics.js +112 -0
- package/dist/session/session-repository.d.ts +161 -0
- package/dist/session/session-repository.js +683 -0
- package/dist/session/session-scripts.d.ts +36 -0
- package/dist/session/session-scripts.js +130 -0
- package/dist/session/session-serializer.d.ts +42 -0
- package/dist/session/session-serializer.js +248 -0
- package/dist/session/session-service.d.ts +104 -0
- package/dist/session/session-service.js +611 -0
- package/dist/session/session-token.d.ts +38 -0
- package/dist/session/session-token.js +86 -0
- package/dist/session/session-types.d.ts +253 -0
- package/dist/session/session-types.js +16 -0
- package/dist/types.d.ts +782 -0
- package/dist/types.js +140 -0
- package/package.json +97 -0
package/dist/client.js
ADDED
|
@@ -0,0 +1,958 @@
|
|
|
1
|
+
import { Cluster, Redis as RedisClient, } from "ioredis";
|
|
2
|
+
import { ConfigurationError, RedisError } from "./errors.js";
|
|
3
|
+
import { RedisConfigSchema, } from "./types.js";
|
|
4
|
+
import { defaultLogger } from "./logger.js";
|
|
5
|
+
import { executeBySlot } from "./cluster.js";
|
|
6
|
+
import { calculateRedisClusterSlot } from "./cluster-slot.js";
|
|
7
|
+
import { Cache } from "./cache.js";
|
|
8
|
+
import { PubSub } from "./pubsub.js";
|
|
9
|
+
import { DistributedLock } from "./lock.js";
|
|
10
|
+
import { RateLimiter } from "./ratelimiter.js";
|
|
11
|
+
import { createSessionManager, } from "./session/session-manager.js";
|
|
12
|
+
import { prettifyError } from "zod";
|
|
13
|
+
// ============================================================================
|
|
14
|
+
// Type Guards
|
|
15
|
+
// ============================================================================
|
|
16
|
+
function isClusterConfig(config) {
|
|
17
|
+
return config.mode === "cluster";
|
|
18
|
+
}
|
|
19
|
+
function isSentinelConfig(config) {
|
|
20
|
+
return config.mode === "sentinel";
|
|
21
|
+
}
|
|
22
|
+
function isStandaloneConfig(config) {
|
|
23
|
+
return config.mode === "standalone";
|
|
24
|
+
}
|
|
25
|
+
// ============================================================================
|
|
26
|
+
// Redis Client
|
|
27
|
+
// ============================================================================
|
|
28
|
+
/**
|
|
29
|
+
* Production-grade Redis client wrapper supporting:
|
|
30
|
+
*
|
|
31
|
+
* - standalone Redis
|
|
32
|
+
* - Redis Sentinel
|
|
33
|
+
* - Redis Cluster
|
|
34
|
+
* - connection lifecycle handling
|
|
35
|
+
* - slow-command tracking
|
|
36
|
+
* - cluster-aware multi-key operations
|
|
37
|
+
* - cache
|
|
38
|
+
* - pub/sub
|
|
39
|
+
* - distributed locks
|
|
40
|
+
* - rate limiting
|
|
41
|
+
* - sessions
|
|
42
|
+
*/
|
|
43
|
+
export class RedisClientWrapper {
|
|
44
|
+
client;
|
|
45
|
+
_cache;
|
|
46
|
+
_pubsub;
|
|
47
|
+
_lock;
|
|
48
|
+
_rateLimiter;
|
|
49
|
+
_session;
|
|
50
|
+
config;
|
|
51
|
+
logger;
|
|
52
|
+
isReady = false;
|
|
53
|
+
// ========================================================================
|
|
54
|
+
// Constructor
|
|
55
|
+
// ========================================================================
|
|
56
|
+
constructor(config, logger = defaultLogger) {
|
|
57
|
+
const parsed = RedisConfigSchema.safeParse(config);
|
|
58
|
+
if (!parsed.success) {
|
|
59
|
+
throw new ConfigurationError(`Invalid Redis config: ${parsed.error.message}`, {
|
|
60
|
+
config,
|
|
61
|
+
message: prettifyError(parsed.error),
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* IMPORTANT:
|
|
66
|
+
*
|
|
67
|
+
* `parsed.data` is already `RedisConfig`.
|
|
68
|
+
*
|
|
69
|
+
* Do NOT cast it back to `RedisConfigInput`.
|
|
70
|
+
* Do NOT normalize it manually.
|
|
71
|
+
*
|
|
72
|
+
* The Zod schema is the normalization boundary.
|
|
73
|
+
*/
|
|
74
|
+
this.config = parsed.data;
|
|
75
|
+
this.logger = logger.child({
|
|
76
|
+
component: "RedisClient",
|
|
77
|
+
});
|
|
78
|
+
this.client = this.createClient();
|
|
79
|
+
this.setupEventHandlers();
|
|
80
|
+
}
|
|
81
|
+
// ========================================================================
|
|
82
|
+
// Configuration
|
|
83
|
+
// ========================================================================
|
|
84
|
+
get mode() {
|
|
85
|
+
return this.config.mode;
|
|
86
|
+
}
|
|
87
|
+
// ========================================================================
|
|
88
|
+
// Cache
|
|
89
|
+
// ========================================================================
|
|
90
|
+
get cache() {
|
|
91
|
+
if (!this._cache) {
|
|
92
|
+
this._cache = new Cache(this, this.config, this.logger);
|
|
93
|
+
}
|
|
94
|
+
return this._cache;
|
|
95
|
+
}
|
|
96
|
+
set cache(value) {
|
|
97
|
+
this._cache = value;
|
|
98
|
+
}
|
|
99
|
+
// ========================================================================
|
|
100
|
+
// Pub/Sub
|
|
101
|
+
// ========================================================================
|
|
102
|
+
get pubsub() {
|
|
103
|
+
if (!this._pubsub) {
|
|
104
|
+
this._pubsub = new PubSub(this, this.logger);
|
|
105
|
+
}
|
|
106
|
+
return this._pubsub;
|
|
107
|
+
}
|
|
108
|
+
set pubsub(value) {
|
|
109
|
+
this._pubsub = value;
|
|
110
|
+
}
|
|
111
|
+
// ========================================================================
|
|
112
|
+
// Distributed Lock
|
|
113
|
+
// ========================================================================
|
|
114
|
+
get lock() {
|
|
115
|
+
if (!this._lock) {
|
|
116
|
+
this._lock = new DistributedLock(this, this.logger);
|
|
117
|
+
}
|
|
118
|
+
return this._lock;
|
|
119
|
+
}
|
|
120
|
+
set lock(value) {
|
|
121
|
+
this._lock = value;
|
|
122
|
+
}
|
|
123
|
+
// ========================================================================
|
|
124
|
+
// Rate Limiter
|
|
125
|
+
// ========================================================================
|
|
126
|
+
get rateLimiter() {
|
|
127
|
+
if (!this._rateLimiter) {
|
|
128
|
+
this._rateLimiter = new RateLimiter(this);
|
|
129
|
+
}
|
|
130
|
+
return this._rateLimiter;
|
|
131
|
+
}
|
|
132
|
+
set rateLimiter(value) {
|
|
133
|
+
this._rateLimiter = value;
|
|
134
|
+
}
|
|
135
|
+
// ========================================================================
|
|
136
|
+
// Session
|
|
137
|
+
// ========================================================================
|
|
138
|
+
get session() {
|
|
139
|
+
if (!this._session) {
|
|
140
|
+
const sessionOptions = this.config.sessionOptions;
|
|
141
|
+
if (sessionOptions) {
|
|
142
|
+
const { client: customClient, ...options } = sessionOptions;
|
|
143
|
+
this._session = createSessionManager({
|
|
144
|
+
client: customClient ?? this,
|
|
145
|
+
...options,
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
else {
|
|
149
|
+
this._session = createSessionManager({
|
|
150
|
+
client: this,
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return this._session;
|
|
155
|
+
}
|
|
156
|
+
set session(value) {
|
|
157
|
+
this._session = value;
|
|
158
|
+
}
|
|
159
|
+
// ========================================================================
|
|
160
|
+
// Client Creation
|
|
161
|
+
// ========================================================================
|
|
162
|
+
createClient() {
|
|
163
|
+
const common = this.buildCommonRedisOptions();
|
|
164
|
+
const retryStrategy = (times) => {
|
|
165
|
+
if (times > this.config.maxRetries) {
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
return Math.min(times * this.config.retryDelay, 5_000);
|
|
169
|
+
};
|
|
170
|
+
switch (this.config.mode) {
|
|
171
|
+
case "cluster":
|
|
172
|
+
return this.createClusterClient(this.config, common, retryStrategy);
|
|
173
|
+
case "sentinel":
|
|
174
|
+
return this.createSentinelClient(this.config, common, retryStrategy);
|
|
175
|
+
case "standalone":
|
|
176
|
+
return this.createStandaloneClient(this.config, common, retryStrategy);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
createClusterClient(config, common, retryStrategy) {
|
|
180
|
+
return new Cluster(config.clusterNodes.map(({ host, port }) => ({
|
|
181
|
+
host,
|
|
182
|
+
port,
|
|
183
|
+
})), {
|
|
184
|
+
clusterRetryStrategy: retryStrategy,
|
|
185
|
+
enableOfflineQueue: true,
|
|
186
|
+
scaleReads: "master",
|
|
187
|
+
redisOptions: {
|
|
188
|
+
...common,
|
|
189
|
+
connectTimeout: config.connectionTimeout,
|
|
190
|
+
maxRetriesPerRequest: config.maxRetries,
|
|
191
|
+
},
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
createSentinelClient(config, common, retryStrategy) {
|
|
195
|
+
return new RedisClient({
|
|
196
|
+
...common,
|
|
197
|
+
connectTimeout: config.connectionTimeout,
|
|
198
|
+
maxRetriesPerRequest: config.maxRetries,
|
|
199
|
+
retryStrategy,
|
|
200
|
+
// sentinel: true,
|
|
201
|
+
sentinels: config.sentinelNodes.map(({ host, port }) => ({
|
|
202
|
+
host,
|
|
203
|
+
port,
|
|
204
|
+
})),
|
|
205
|
+
name: config.sentinelMasterName,
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
createStandaloneClient(config, common, retryStrategy) {
|
|
209
|
+
const connectionOptions = {
|
|
210
|
+
...common,
|
|
211
|
+
connectTimeout: config.connectionTimeout,
|
|
212
|
+
maxRetriesPerRequest: config.maxRetries,
|
|
213
|
+
retryStrategy,
|
|
214
|
+
};
|
|
215
|
+
if (config.url !== undefined) {
|
|
216
|
+
return new RedisClient(config.url, connectionOptions);
|
|
217
|
+
}
|
|
218
|
+
return new RedisClient({
|
|
219
|
+
...connectionOptions,
|
|
220
|
+
host: config.host,
|
|
221
|
+
port: config.port,
|
|
222
|
+
db: config.database,
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
buildCommonRedisOptions() {
|
|
226
|
+
const options = {
|
|
227
|
+
enableReadyCheck: true,
|
|
228
|
+
enableOfflineQueue: true,
|
|
229
|
+
lazyConnect: false,
|
|
230
|
+
};
|
|
231
|
+
if (this.config.username !== undefined) {
|
|
232
|
+
options.username = this.config.username;
|
|
233
|
+
}
|
|
234
|
+
if (this.config.password !== undefined) {
|
|
235
|
+
options.password = this.config.password;
|
|
236
|
+
}
|
|
237
|
+
if (this.config.tls) {
|
|
238
|
+
options.tls =
|
|
239
|
+
this.config.tlsOptions ?? {
|
|
240
|
+
rejectUnauthorized: true,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
return options;
|
|
244
|
+
}
|
|
245
|
+
// ========================================================================
|
|
246
|
+
// Events
|
|
247
|
+
// ========================================================================
|
|
248
|
+
setupEventHandlers() {
|
|
249
|
+
this.client.on("connect", () => {
|
|
250
|
+
this.logger.info("Redis connected");
|
|
251
|
+
});
|
|
252
|
+
this.client.on("ready", () => {
|
|
253
|
+
this.isReady = true;
|
|
254
|
+
this.logger.info("Redis ready");
|
|
255
|
+
});
|
|
256
|
+
this.client.on("error", (error) => {
|
|
257
|
+
this.isReady = false;
|
|
258
|
+
this.logger.error("Redis error:", {
|
|
259
|
+
error,
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
this.client.on("close", () => {
|
|
263
|
+
this.isReady = false;
|
|
264
|
+
this.logger.warn("Redis connection closed");
|
|
265
|
+
});
|
|
266
|
+
this.client.on("reconnecting", () => {
|
|
267
|
+
this.isReady = false;
|
|
268
|
+
this.logger.info("Redis reconnecting...");
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
// ========================================================================
|
|
272
|
+
// Raw Client
|
|
273
|
+
// ========================================================================
|
|
274
|
+
getRawClient() {
|
|
275
|
+
return this.client;
|
|
276
|
+
}
|
|
277
|
+
get raw() {
|
|
278
|
+
return this.client;
|
|
279
|
+
}
|
|
280
|
+
// ========================================================================
|
|
281
|
+
// Connection
|
|
282
|
+
// ========================================================================
|
|
283
|
+
async ping() {
|
|
284
|
+
try {
|
|
285
|
+
const result = await this.exec("PING", [], () => this.client.ping());
|
|
286
|
+
return result === "PONG";
|
|
287
|
+
}
|
|
288
|
+
catch {
|
|
289
|
+
return false;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
async close() {
|
|
293
|
+
try {
|
|
294
|
+
await this.client.quit();
|
|
295
|
+
}
|
|
296
|
+
finally {
|
|
297
|
+
this.isReady = false;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
// ========================================================================
|
|
301
|
+
// Internal Helpers
|
|
302
|
+
// ========================================================================
|
|
303
|
+
isClusterClient(client) {
|
|
304
|
+
return client instanceof Cluster;
|
|
305
|
+
}
|
|
306
|
+
async exec(command, args, operation) {
|
|
307
|
+
const startedAt = Date.now();
|
|
308
|
+
try {
|
|
309
|
+
const result = await operation();
|
|
310
|
+
const duration = Date.now() - startedAt;
|
|
311
|
+
if (duration >
|
|
312
|
+
this.config.slowCommandThreshold) {
|
|
313
|
+
this.logger.warn(`Slow command: ${command} took ${duration}ms`, {
|
|
314
|
+
command,
|
|
315
|
+
args: args.slice(0, 5),
|
|
316
|
+
duration,
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
return result;
|
|
320
|
+
}
|
|
321
|
+
catch (error) {
|
|
322
|
+
this.logger.error(`Command failed: ${command}`, {
|
|
323
|
+
command,
|
|
324
|
+
error,
|
|
325
|
+
});
|
|
326
|
+
throw error;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
// ========================================================================
|
|
330
|
+
// Basic Commands
|
|
331
|
+
// ========================================================================
|
|
332
|
+
async get(key) {
|
|
333
|
+
return this.exec("GET", [key], () => this.client.get(key));
|
|
334
|
+
}
|
|
335
|
+
async set(key, value, ttl) {
|
|
336
|
+
if (ttl !== undefined) {
|
|
337
|
+
return this.exec("SET", [key, value, "EX", ttl], () => this.client.set(key, value, "EX", ttl));
|
|
338
|
+
}
|
|
339
|
+
return this.exec("SET", [key, value], () => this.client.set(key, value));
|
|
340
|
+
}
|
|
341
|
+
async setexnx(key, value, ttl) {
|
|
342
|
+
if (ttl !== undefined) {
|
|
343
|
+
return this.exec("SET", [key, value, "EX", ttl, "NX"], () => this.client.set(key, value, "EX", ttl, "NX"));
|
|
344
|
+
}
|
|
345
|
+
return this.exec("SET", [key, value, "NX"], () => this.client.set(key, value, "NX"));
|
|
346
|
+
}
|
|
347
|
+
defineCommand(...args) {
|
|
348
|
+
return this.client.defineCommand(...args);
|
|
349
|
+
}
|
|
350
|
+
async eval(script, numKeys, ...args) {
|
|
351
|
+
return this.exec("EVAL", [script, numKeys, ...args], () => this.client.eval(script, numKeys, ...args));
|
|
352
|
+
}
|
|
353
|
+
async evalsha(sha, script, numKeys, ...args) {
|
|
354
|
+
try {
|
|
355
|
+
return await this.exec("EVALSHA", [sha, numKeys, ...args], () => this.client.evalsha(sha, numKeys, ...args));
|
|
356
|
+
}
|
|
357
|
+
catch (error) {
|
|
358
|
+
const code = error instanceof Error &&
|
|
359
|
+
"code" in error &&
|
|
360
|
+
typeof error.code === "string"
|
|
361
|
+
? error.code
|
|
362
|
+
: undefined;
|
|
363
|
+
const message = error instanceof Error
|
|
364
|
+
? error.message
|
|
365
|
+
: String(error);
|
|
366
|
+
if (code === "NOSCRIPT" ||
|
|
367
|
+
message.includes("NOSCRIPT")) {
|
|
368
|
+
return this.eval(script, numKeys, ...args);
|
|
369
|
+
}
|
|
370
|
+
throw error;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
async scriptLoad(script) {
|
|
374
|
+
return this.exec("SCRIPT LOAD", [script], () => this.client.script("LOAD", script));
|
|
375
|
+
}
|
|
376
|
+
async setnx(key, value, ttl) {
|
|
377
|
+
const result = ttl !== undefined
|
|
378
|
+
? await this.exec("SET", [key, value, "EX", ttl, "NX"], () => this.client.set(key, value, "EX", ttl, "NX"))
|
|
379
|
+
: await this.exec("SET", [key, value, "NX"], () => this.client.set(key, value, "NX"));
|
|
380
|
+
return result === "OK" ? 1 : 0;
|
|
381
|
+
}
|
|
382
|
+
async del(...keys) {
|
|
383
|
+
if (keys.length === 0) {
|
|
384
|
+
return 0;
|
|
385
|
+
}
|
|
386
|
+
return this.exec("DEL", keys, () => this.client.del(...keys));
|
|
387
|
+
}
|
|
388
|
+
async getdel(key) {
|
|
389
|
+
return this.exec("GETDEL", [key], () => this.client.getdel(key));
|
|
390
|
+
}
|
|
391
|
+
async exists(key) {
|
|
392
|
+
return this.exec("EXISTS", [key], () => this.client.exists(key));
|
|
393
|
+
}
|
|
394
|
+
async expire(key, ttl) {
|
|
395
|
+
return this.exec("EXPIRE", [key, ttl], () => this.client.expire(key, ttl));
|
|
396
|
+
}
|
|
397
|
+
async ttl(key) {
|
|
398
|
+
return this.exec("TTL", [key], () => this.client.ttl(key));
|
|
399
|
+
}
|
|
400
|
+
async incr(key) {
|
|
401
|
+
return this.exec("INCR", [key], () => this.client.incr(key));
|
|
402
|
+
}
|
|
403
|
+
async decr(key) {
|
|
404
|
+
return this.exec("DECR", [key], () => this.client.decr(key));
|
|
405
|
+
}
|
|
406
|
+
async incrby(key, amount) {
|
|
407
|
+
if (!Number.isSafeInteger(amount) ||
|
|
408
|
+
amount <= 0) {
|
|
409
|
+
throw new RedisError("INCRBY amount must be a positive safe integer", "INVALID_AMOUNT");
|
|
410
|
+
}
|
|
411
|
+
return this.exec("INCRBY", [key, amount], () => this.client.incrby(key, amount));
|
|
412
|
+
}
|
|
413
|
+
async decrby(key, amount) {
|
|
414
|
+
if (!Number.isSafeInteger(amount) ||
|
|
415
|
+
amount <= 0) {
|
|
416
|
+
throw new RedisError("DECRBY amount must be a positive safe integer", "INVALID_AMOUNT");
|
|
417
|
+
}
|
|
418
|
+
return this.exec("DECRBY", [key, amount], () => this.client.decrby(key, amount));
|
|
419
|
+
}
|
|
420
|
+
async time() {
|
|
421
|
+
const result = await this.exec("TIME", [], () => this.client.time());
|
|
422
|
+
if (Array.isArray(result) &&
|
|
423
|
+
result.length > 0) {
|
|
424
|
+
const seconds = Number(result[0]);
|
|
425
|
+
if (Number.isSafeInteger(seconds)) {
|
|
426
|
+
return seconds;
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
throw new RedisError("Unexpected TIME response from Redis", "TIME_ERROR");
|
|
430
|
+
}
|
|
431
|
+
// ========================================================================
|
|
432
|
+
// Multi-Key Commands
|
|
433
|
+
// ========================================================================
|
|
434
|
+
async mget(...keys) {
|
|
435
|
+
if (keys.length === 0) {
|
|
436
|
+
return [];
|
|
437
|
+
}
|
|
438
|
+
if (this.isClusterClient(this.client)) {
|
|
439
|
+
return this.mgetClusterAware(keys);
|
|
440
|
+
}
|
|
441
|
+
return this.exec("MGET", keys, () => this.client.mget(...keys));
|
|
442
|
+
}
|
|
443
|
+
async mset(...pairs) {
|
|
444
|
+
if (pairs.length === 0) {
|
|
445
|
+
return "OK";
|
|
446
|
+
}
|
|
447
|
+
if (this.isClusterClient(this.client)) {
|
|
448
|
+
const groups = new Map();
|
|
449
|
+
for (const [key, value,] of pairs) {
|
|
450
|
+
const slot = this.calculateSlot(key);
|
|
451
|
+
const group = groups.get(slot) ?? [];
|
|
452
|
+
group.push(key, value);
|
|
453
|
+
groups.set(slot, group);
|
|
454
|
+
}
|
|
455
|
+
const tasks = [...groups.values()];
|
|
456
|
+
await this.runWithConcurrency(tasks, this.config.maxFanOutConcurrency, async (flat) => {
|
|
457
|
+
await this.exec("MSET", flat, () => this.client.mset(flat));
|
|
458
|
+
});
|
|
459
|
+
return "OK";
|
|
460
|
+
}
|
|
461
|
+
const flat = pairs.flat();
|
|
462
|
+
return this.exec("MSET", flat, () => this.client.mset(flat));
|
|
463
|
+
}
|
|
464
|
+
// ========================================================================
|
|
465
|
+
// Hash Commands
|
|
466
|
+
// ========================================================================
|
|
467
|
+
async hget(key, field) {
|
|
468
|
+
return this.exec("HGET", [key, field], () => this.client.hget(key, field));
|
|
469
|
+
}
|
|
470
|
+
async hset(key, field, value) {
|
|
471
|
+
return this.exec("HSET", [key, field, value], () => this.client.hset(key, field, value));
|
|
472
|
+
}
|
|
473
|
+
async hgetall(key) {
|
|
474
|
+
return this.exec("HGETALL", [key], () => this.client.hgetall(key));
|
|
475
|
+
}
|
|
476
|
+
async hdel(key, ...fields) {
|
|
477
|
+
if (fields.length === 0) {
|
|
478
|
+
return 0;
|
|
479
|
+
}
|
|
480
|
+
return this.exec("HDEL", [key, ...fields], () => this.client.hdel(key, ...fields));
|
|
481
|
+
}
|
|
482
|
+
// ========================================================================
|
|
483
|
+
// Set Commands
|
|
484
|
+
// ========================================================================
|
|
485
|
+
async sadd(key, ...members) {
|
|
486
|
+
if (members.length === 0) {
|
|
487
|
+
return 0;
|
|
488
|
+
}
|
|
489
|
+
return this.exec("SADD", [key, ...members], () => this.client.sadd(key, ...members));
|
|
490
|
+
}
|
|
491
|
+
async srem(key, ...members) {
|
|
492
|
+
if (members.length === 0) {
|
|
493
|
+
return 0;
|
|
494
|
+
}
|
|
495
|
+
return this.exec("SREM", [key, ...members], () => this.client.srem(key, ...members));
|
|
496
|
+
}
|
|
497
|
+
async smembers(key) {
|
|
498
|
+
return this.exec("SMEMBERS", [key], () => this.client.smembers(key));
|
|
499
|
+
}
|
|
500
|
+
async sismember(key, member) {
|
|
501
|
+
return this.exec("SISMEMBER", [key, member], () => this.client.sismember(key, member));
|
|
502
|
+
}
|
|
503
|
+
// ========================================================================
|
|
504
|
+
// Sorted Set Commands
|
|
505
|
+
// ========================================================================
|
|
506
|
+
async zadd(key, score, member) {
|
|
507
|
+
return this.exec("ZADD", [key, score, member], () => this.client.zadd(key, score, member));
|
|
508
|
+
}
|
|
509
|
+
async zrange(key, start, stop) {
|
|
510
|
+
return this.exec("ZRANGE", [key, start, stop], () => this.client.zrange(key, start, stop));
|
|
511
|
+
}
|
|
512
|
+
async zcard(key) {
|
|
513
|
+
return this.exec("ZCARD", [key], () => this.client.zcard(key));
|
|
514
|
+
}
|
|
515
|
+
async zrem(key, ...members) {
|
|
516
|
+
if (members.length === 0) {
|
|
517
|
+
return 0;
|
|
518
|
+
}
|
|
519
|
+
return this.exec("ZREM", [key, ...members], () => this.client.zrem(key, ...members));
|
|
520
|
+
}
|
|
521
|
+
// ========================================================================
|
|
522
|
+
// Pipeline
|
|
523
|
+
// ========================================================================
|
|
524
|
+
pipeline() {
|
|
525
|
+
return this.client.pipeline();
|
|
526
|
+
}
|
|
527
|
+
// ========================================================================
|
|
528
|
+
// Scanning
|
|
529
|
+
// ========================================================================
|
|
530
|
+
async *scanIterator(pattern, count = 100) {
|
|
531
|
+
for await (const batch of this.scanCluster(pattern, {
|
|
532
|
+
count,
|
|
533
|
+
batchSize: 1,
|
|
534
|
+
})) {
|
|
535
|
+
for (const key of batch) {
|
|
536
|
+
yield key;
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
async *scanCluster(pattern, options = {}) {
|
|
541
|
+
const count = options.count ?? 100;
|
|
542
|
+
const batchSize = Math.max(1, options.batchSize ?? 100);
|
|
543
|
+
const scanNode = async function* (node) {
|
|
544
|
+
let cursor = "0";
|
|
545
|
+
let buffer = [];
|
|
546
|
+
do {
|
|
547
|
+
const [nextCursor, keys,] = await node.scan(cursor, "MATCH", pattern, "COUNT", count);
|
|
548
|
+
cursor = nextCursor;
|
|
549
|
+
buffer.push(...keys);
|
|
550
|
+
while (buffer.length >=
|
|
551
|
+
batchSize) {
|
|
552
|
+
yield buffer.splice(0, batchSize);
|
|
553
|
+
}
|
|
554
|
+
} while (cursor !== "0");
|
|
555
|
+
if (buffer.length > 0) {
|
|
556
|
+
yield buffer;
|
|
557
|
+
}
|
|
558
|
+
};
|
|
559
|
+
const nodes = this.isClusterClient(this.client)
|
|
560
|
+
? this.getClusterNodes()
|
|
561
|
+
: [this.client];
|
|
562
|
+
for (const node of nodes) {
|
|
563
|
+
yield* scanNode(node);
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
// ========================================================================
|
|
567
|
+
// Cluster
|
|
568
|
+
// ========================================================================
|
|
569
|
+
isCluster() {
|
|
570
|
+
return this.isClusterClient(this.client);
|
|
571
|
+
}
|
|
572
|
+
getClusterNodes() {
|
|
573
|
+
if (!this.isClusterClient(this.client)) {
|
|
574
|
+
return [];
|
|
575
|
+
}
|
|
576
|
+
return this.client.nodes("master");
|
|
577
|
+
}
|
|
578
|
+
async getClusterSlots() {
|
|
579
|
+
if (!this.isClusterClient(this.client)) {
|
|
580
|
+
return [];
|
|
581
|
+
}
|
|
582
|
+
const raw = (await this.exec("CLUSTER SLOTS", [], () => this.client.cluster("SLOTS")));
|
|
583
|
+
if (!Array.isArray(raw)) {
|
|
584
|
+
return [];
|
|
585
|
+
}
|
|
586
|
+
return raw.flatMap((entry) => {
|
|
587
|
+
if (!Array.isArray(entry) ||
|
|
588
|
+
entry.length < 3) {
|
|
589
|
+
return [];
|
|
590
|
+
}
|
|
591
|
+
const [start, end, master, ...replicas] = entry;
|
|
592
|
+
if (!Number.isInteger(start) ||
|
|
593
|
+
!Number.isInteger(end) ||
|
|
594
|
+
!Array.isArray(master)) {
|
|
595
|
+
return [];
|
|
596
|
+
}
|
|
597
|
+
const parseNode = (node) => {
|
|
598
|
+
if (!Array.isArray(node) ||
|
|
599
|
+
node.length < 2) {
|
|
600
|
+
return null;
|
|
601
|
+
}
|
|
602
|
+
const host = typeof node[0] === "string"
|
|
603
|
+
? node[0]
|
|
604
|
+
: String(node[0]);
|
|
605
|
+
const port = typeof node[1] === "number"
|
|
606
|
+
? node[1]
|
|
607
|
+
: Number(node[1]);
|
|
608
|
+
if (host.length === 0 ||
|
|
609
|
+
!Number.isInteger(port) ||
|
|
610
|
+
port < 1 ||
|
|
611
|
+
port > 65_535) {
|
|
612
|
+
return null;
|
|
613
|
+
}
|
|
614
|
+
const nodeId = node[2] === undefined
|
|
615
|
+
? undefined
|
|
616
|
+
: String(node[2]);
|
|
617
|
+
return nodeId === undefined
|
|
618
|
+
? {
|
|
619
|
+
host,
|
|
620
|
+
port,
|
|
621
|
+
}
|
|
622
|
+
: {
|
|
623
|
+
host,
|
|
624
|
+
port,
|
|
625
|
+
nodeId,
|
|
626
|
+
};
|
|
627
|
+
};
|
|
628
|
+
const parsedMaster = parseNode(master);
|
|
629
|
+
if (!parsedMaster) {
|
|
630
|
+
return [];
|
|
631
|
+
}
|
|
632
|
+
const parsedReplicas = replicas
|
|
633
|
+
.map(parseNode)
|
|
634
|
+
.filter((node) => node !== null);
|
|
635
|
+
return [
|
|
636
|
+
{
|
|
637
|
+
start,
|
|
638
|
+
end,
|
|
639
|
+
master: parsedMaster,
|
|
640
|
+
replicas: parsedReplicas,
|
|
641
|
+
},
|
|
642
|
+
];
|
|
643
|
+
});
|
|
644
|
+
}
|
|
645
|
+
calculateSlot(key) {
|
|
646
|
+
return calculateRedisClusterSlot(key);
|
|
647
|
+
}
|
|
648
|
+
async getNodeForKey(key) {
|
|
649
|
+
if (!this.isClusterClient(this.client)) {
|
|
650
|
+
return null;
|
|
651
|
+
}
|
|
652
|
+
const nodes = this.client.nodes("master");
|
|
653
|
+
if (nodes.length === 0) {
|
|
654
|
+
return null;
|
|
655
|
+
}
|
|
656
|
+
try {
|
|
657
|
+
const slot = this.calculateSlot(key);
|
|
658
|
+
const raw = await this.client.cluster("SLOTS");
|
|
659
|
+
if (!Array.isArray(raw)) {
|
|
660
|
+
return null;
|
|
661
|
+
}
|
|
662
|
+
for (const entry of raw) {
|
|
663
|
+
if (!Array.isArray(entry) ||
|
|
664
|
+
entry.length < 3) {
|
|
665
|
+
continue;
|
|
666
|
+
}
|
|
667
|
+
const [startSlot, endSlot, master,] = entry;
|
|
668
|
+
if (typeof startSlot !==
|
|
669
|
+
"number" ||
|
|
670
|
+
typeof endSlot !==
|
|
671
|
+
"number" ||
|
|
672
|
+
!Array.isArray(master)) {
|
|
673
|
+
continue;
|
|
674
|
+
}
|
|
675
|
+
if (slot < startSlot ||
|
|
676
|
+
slot > endSlot) {
|
|
677
|
+
continue;
|
|
678
|
+
}
|
|
679
|
+
const host = String(master[2] ?? "");
|
|
680
|
+
const port = Number(master[1]);
|
|
681
|
+
if (host.length === 0 ||
|
|
682
|
+
!Number.isInteger(port)) {
|
|
683
|
+
continue;
|
|
684
|
+
}
|
|
685
|
+
const node = nodes.find((candidate) => candidate.options
|
|
686
|
+
.host === host &&
|
|
687
|
+
candidate.options
|
|
688
|
+
.port === port);
|
|
689
|
+
if (node) {
|
|
690
|
+
return node;
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
catch (error) {
|
|
695
|
+
this.logger.debug("Unable to resolve cluster node for key", {
|
|
696
|
+
error,
|
|
697
|
+
});
|
|
698
|
+
}
|
|
699
|
+
return null;
|
|
700
|
+
}
|
|
701
|
+
async getSlotRanges() {
|
|
702
|
+
const result = new Map();
|
|
703
|
+
const ranges = await this.getClusterSlots();
|
|
704
|
+
for (const range of ranges) {
|
|
705
|
+
const nodeId = `${range.master.host}:${range.master.port}`;
|
|
706
|
+
for (let slot = range.start; slot <= range.end; slot++) {
|
|
707
|
+
result.set(slot, [
|
|
708
|
+
nodeId,
|
|
709
|
+
]);
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
return result;
|
|
713
|
+
}
|
|
714
|
+
async isKeyServed(key) {
|
|
715
|
+
if (!this.isClusterClient(this.client)) {
|
|
716
|
+
return true;
|
|
717
|
+
}
|
|
718
|
+
try {
|
|
719
|
+
const node = await this.getNodeForKey(key);
|
|
720
|
+
return node !== null;
|
|
721
|
+
}
|
|
722
|
+
catch {
|
|
723
|
+
return false;
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
async executeOnNode(key, command, ...args) {
|
|
727
|
+
if (this.isClusterClient(this.client)) {
|
|
728
|
+
const node = await this.getNodeForKey(key);
|
|
729
|
+
if (node) {
|
|
730
|
+
return this.executeCommandOnClient(node, command, args);
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
return this.executeCommandOnClient(this.client, command, args);
|
|
734
|
+
}
|
|
735
|
+
executeCommandOnClient(client, command, args) {
|
|
736
|
+
const method = Reflect.get(client, command);
|
|
737
|
+
if (typeof method !==
|
|
738
|
+
"function") {
|
|
739
|
+
throw new RedisError(`Unsupported Redis command: ${command}`, "UNSUPPORTED_COMMAND");
|
|
740
|
+
}
|
|
741
|
+
return Reflect.apply(method, client, args);
|
|
742
|
+
}
|
|
743
|
+
// ========================================================================
|
|
744
|
+
// Cluster-Aware MGET
|
|
745
|
+
// ========================================================================
|
|
746
|
+
async mgetClusterAware(keys) {
|
|
747
|
+
if (keys.length === 0) {
|
|
748
|
+
return [];
|
|
749
|
+
}
|
|
750
|
+
if (!this.isClusterClient(this.client)) {
|
|
751
|
+
return this.mget(...keys);
|
|
752
|
+
}
|
|
753
|
+
const groups = new Map();
|
|
754
|
+
for (const key of keys) {
|
|
755
|
+
const slot = this.calculateSlot(key);
|
|
756
|
+
const group = groups.get(slot) ?? [];
|
|
757
|
+
group.push(key);
|
|
758
|
+
groups.set(slot, group);
|
|
759
|
+
}
|
|
760
|
+
const values = new Map();
|
|
761
|
+
await this.runWithConcurrency([...groups.values()], this.config
|
|
762
|
+
.maxFanOutConcurrency, async (slotKeys) => {
|
|
763
|
+
const result = await this.exec("MGET", slotKeys, () => this.client.mget(...slotKeys));
|
|
764
|
+
slotKeys.forEach((key, index) => {
|
|
765
|
+
values.set(key, result[index] ??
|
|
766
|
+
null);
|
|
767
|
+
});
|
|
768
|
+
});
|
|
769
|
+
return keys.map((key) => values.get(key) ??
|
|
770
|
+
null);
|
|
771
|
+
}
|
|
772
|
+
// ========================================================================
|
|
773
|
+
// Namespace Operations
|
|
774
|
+
// ========================================================================
|
|
775
|
+
async deletePattern(pattern, options = {}) {
|
|
776
|
+
const batchSize = Math.max(1, options.batchSize ??
|
|
777
|
+
100);
|
|
778
|
+
const scanCount = Math.max(1, options.scanCount ??
|
|
779
|
+
100);
|
|
780
|
+
let deleted = 0;
|
|
781
|
+
for await (const batch of this.scanCluster(pattern, {
|
|
782
|
+
count: scanCount,
|
|
783
|
+
batchSize,
|
|
784
|
+
})) {
|
|
785
|
+
if (batch.length === 0) {
|
|
786
|
+
continue;
|
|
787
|
+
}
|
|
788
|
+
const commands = batch.map((key) => ({
|
|
789
|
+
command: "del",
|
|
790
|
+
args: [key],
|
|
791
|
+
slot: this.calculateSlot(key),
|
|
792
|
+
}));
|
|
793
|
+
const results = await executeBySlot(this, commands, {
|
|
794
|
+
concurrency: this.config
|
|
795
|
+
.maxFanOutConcurrency,
|
|
796
|
+
retry: 1,
|
|
797
|
+
});
|
|
798
|
+
for (const result of results) {
|
|
799
|
+
if (!result[0] &&
|
|
800
|
+
typeof result[1] ===
|
|
801
|
+
"number") {
|
|
802
|
+
deleted += result[1];
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
return deleted;
|
|
807
|
+
}
|
|
808
|
+
async clearNamespace(prefix, options = {}) {
|
|
809
|
+
return this.deletePattern(`${prefix}*`, options);
|
|
810
|
+
}
|
|
811
|
+
async clearNamespaceClusterAware(prefix, options = {}) {
|
|
812
|
+
return this.clearNamespace(prefix, options);
|
|
813
|
+
}
|
|
814
|
+
// ========================================================================
|
|
815
|
+
// Cluster Information
|
|
816
|
+
// ========================================================================
|
|
817
|
+
getClusterInfo() {
|
|
818
|
+
if (this.isClusterClient(this.client)) {
|
|
819
|
+
try {
|
|
820
|
+
const nodes = this.client.nodes("master");
|
|
821
|
+
return {
|
|
822
|
+
mode: "cluster",
|
|
823
|
+
status: this.isReady
|
|
824
|
+
? "ready"
|
|
825
|
+
: "connecting",
|
|
826
|
+
nodeCount: nodes.length,
|
|
827
|
+
nodes: nodes.map((node) => ({
|
|
828
|
+
host: String(node.options
|
|
829
|
+
.host ??
|
|
830
|
+
""),
|
|
831
|
+
port: Number(node.options
|
|
832
|
+
.port),
|
|
833
|
+
role: "master",
|
|
834
|
+
})),
|
|
835
|
+
};
|
|
836
|
+
}
|
|
837
|
+
catch (error) {
|
|
838
|
+
return {
|
|
839
|
+
mode: "cluster",
|
|
840
|
+
status: "error",
|
|
841
|
+
error: error instanceof Error
|
|
842
|
+
? error.message
|
|
843
|
+
: String(error),
|
|
844
|
+
};
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
if (isStandaloneConfig(this.config)) {
|
|
848
|
+
return {
|
|
849
|
+
mode: "standalone",
|
|
850
|
+
host: this.config.host,
|
|
851
|
+
port: this.config.port,
|
|
852
|
+
status: this.isReady
|
|
853
|
+
? "ready"
|
|
854
|
+
: "connecting",
|
|
855
|
+
};
|
|
856
|
+
}
|
|
857
|
+
return {
|
|
858
|
+
mode: this.config.mode,
|
|
859
|
+
status: this.isReady
|
|
860
|
+
? "ready"
|
|
861
|
+
: "connecting",
|
|
862
|
+
};
|
|
863
|
+
}
|
|
864
|
+
// ========================================================================
|
|
865
|
+
// INFO
|
|
866
|
+
// ========================================================================
|
|
867
|
+
async info(section) {
|
|
868
|
+
if (section !== undefined) {
|
|
869
|
+
return this.exec("INFO", [section], () => this.client.info(section));
|
|
870
|
+
}
|
|
871
|
+
return this.exec("INFO", [], () => this.client.info());
|
|
872
|
+
}
|
|
873
|
+
// ========================================================================
|
|
874
|
+
// SELECT
|
|
875
|
+
// ========================================================================
|
|
876
|
+
async select(database) {
|
|
877
|
+
if (this.isClusterClient(this.client)) {
|
|
878
|
+
throw new RedisError("SELECT is not supported in Redis Cluster mode", "CLUSTER_MODE");
|
|
879
|
+
}
|
|
880
|
+
if (!Number.isInteger(database) ||
|
|
881
|
+
database < 0 ||
|
|
882
|
+
database > 15) {
|
|
883
|
+
throw new RedisError("Database must be an integer between 0 and 15", "INVALID_DATABASE");
|
|
884
|
+
}
|
|
885
|
+
return this.client.select(database);
|
|
886
|
+
}
|
|
887
|
+
// ========================================================================
|
|
888
|
+
// Connection Status
|
|
889
|
+
// ========================================================================
|
|
890
|
+
getConnectionStatus() {
|
|
891
|
+
const state = this.isReady
|
|
892
|
+
? "connected"
|
|
893
|
+
: this.client.status ===
|
|
894
|
+
"connecting"
|
|
895
|
+
? "connecting"
|
|
896
|
+
: this.client.status ===
|
|
897
|
+
"end"
|
|
898
|
+
? "closed"
|
|
899
|
+
: this.client.status ===
|
|
900
|
+
"ready"
|
|
901
|
+
? "connected"
|
|
902
|
+
: "disconnected";
|
|
903
|
+
return {
|
|
904
|
+
state,
|
|
905
|
+
connected: this.isReady,
|
|
906
|
+
ready: this.isReady,
|
|
907
|
+
reconnectAttempts: 0,
|
|
908
|
+
uptime: 0,
|
|
909
|
+
};
|
|
910
|
+
}
|
|
911
|
+
// ========================================================================
|
|
912
|
+
// Concurrency Helper
|
|
913
|
+
// ========================================================================
|
|
914
|
+
async runWithConcurrency(items, concurrency, worker) {
|
|
915
|
+
if (items.length === 0) {
|
|
916
|
+
return;
|
|
917
|
+
}
|
|
918
|
+
const limit = Math.max(1, Math.min(concurrency, items.length));
|
|
919
|
+
let cursor = 0;
|
|
920
|
+
const workers = Array.from({
|
|
921
|
+
length: limit,
|
|
922
|
+
}, async () => {
|
|
923
|
+
while (true) {
|
|
924
|
+
const index = cursor++;
|
|
925
|
+
if (index >=
|
|
926
|
+
items.length) {
|
|
927
|
+
return;
|
|
928
|
+
}
|
|
929
|
+
await worker(items[index]);
|
|
930
|
+
}
|
|
931
|
+
});
|
|
932
|
+
await Promise.all(workers);
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
/**
|
|
936
|
+
* Generic runtime factory.
|
|
937
|
+
*/
|
|
938
|
+
export function createRedisClient(config, logger) {
|
|
939
|
+
const client = new RedisClientWrapper(config, logger);
|
|
940
|
+
/**
|
|
941
|
+
* The runtime configuration has already been validated by
|
|
942
|
+
* RedisConfigSchema inside RedisClientWrapper.
|
|
943
|
+
*
|
|
944
|
+
* The specialized public API is therefore safe to expose according to
|
|
945
|
+
* the validated discriminator.
|
|
946
|
+
*
|
|
947
|
+
* The cast is isolated to this factory boundary instead of being spread
|
|
948
|
+
* throughout the implementation.
|
|
949
|
+
*/
|
|
950
|
+
switch (client.mode) {
|
|
951
|
+
case "cluster":
|
|
952
|
+
return client;
|
|
953
|
+
case "sentinel":
|
|
954
|
+
return client;
|
|
955
|
+
case "standalone":
|
|
956
|
+
return client;
|
|
957
|
+
}
|
|
958
|
+
}
|