@blamejs/core 0.7.18 → 0.7.20
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/CHANGELOG.md +427 -423
- package/README.md +150 -150
- package/bin/blamejs.js +0 -0
- package/index.js +310 -308
- package/lib/api-key.js +660 -660
- package/lib/api-snapshot.js +338 -338
- package/lib/app-shutdown.js +385 -385
- package/lib/app.js +365 -365
- package/lib/archive.js +250 -250
- package/lib/atomic-file.js +544 -544
- package/lib/audit-chain.js +177 -177
- package/lib/audit-sign.js +344 -344
- package/lib/audit-tools.js +677 -677
- package/lib/audit.js +766 -766
- package/lib/auth/jwt-external.js +365 -0
- package/lib/auth/jwt.js +337 -311
- package/lib/auth/lockout.js +436 -436
- package/lib/auth/oauth.js +721 -721
- package/lib/auth/passkey.js +181 -181
- package/lib/auth/password.js +628 -594
- package/lib/backup/bundle.js +217 -217
- package/lib/backup/crypto.js +176 -176
- package/lib/backup/index.js +515 -515
- package/lib/backup/manifest.js +282 -282
- package/lib/break-glass.js +1338 -1338
- package/lib/bundler.js +441 -441
- package/lib/cache-redis.js +256 -256
- package/lib/cache.js +1206 -1206
- package/lib/canonical-json.js +115 -115
- package/lib/chain-writer.js +234 -234
- package/lib/cli-helpers.js +206 -206
- package/lib/cli.js +2334 -2334
- package/lib/cluster-provider-db.js +317 -317
- package/lib/cluster-storage.js +226 -226
- package/lib/cluster.js +703 -703
- package/lib/config-drift.js +301 -301
- package/lib/consent.js +222 -222
- package/lib/constants.js +191 -191
- package/lib/cookies.js +350 -315
- package/lib/credential-hash.js +322 -322
- package/lib/crypto.js +266 -266
- package/lib/csv.js +275 -275
- package/lib/db-declare-row-policy.js +267 -267
- package/lib/db-declare-view.js +420 -420
- package/lib/db-query.js +406 -406
- package/lib/db-schema.js +319 -319
- package/lib/db.js +1288 -1288
- package/lib/deprecate.js +222 -222
- package/lib/dev.js +335 -335
- package/lib/dual-control.js +473 -473
- package/lib/error-page.js +420 -420
- package/lib/external-db-migrate.js +441 -441
- package/lib/external-db.js +1061 -1061
- package/lib/file-type.js +273 -273
- package/lib/forms.js +422 -422
- package/lib/framework-error.js +293 -293
- package/lib/framework-schema.js +717 -717
- package/lib/handlers.js +350 -350
- package/lib/http-client-cookie-jar.js +508 -508
- package/lib/http-client.js +1195 -1195
- package/lib/i18n.js +878 -878
- package/lib/jobs.js +185 -185
- package/lib/log-stream-cloudwatch.js +369 -369
- package/lib/log-stream-local.js +146 -146
- package/lib/log-stream-otlp-grpc.js +410 -410
- package/lib/log-stream-otlp.js +286 -286
- package/lib/log-stream-syslog.js +302 -302
- package/lib/log-stream-webhook.js +199 -199
- package/lib/log-stream.js +330 -330
- package/lib/log.js +500 -500
- package/lib/mail-bounce.js +528 -528
- package/lib/mail-dkim.js +369 -369
- package/lib/mail.js +981 -981
- package/lib/metrics.js +683 -683
- package/lib/middleware/api-encrypt.js +936 -936
- package/lib/middleware/attach-user.js +157 -157
- package/lib/middleware/bearer-auth.js +152 -0
- package/lib/middleware/body-parser.js +1170 -1170
- package/lib/middleware/bot-guard.js +178 -178
- package/lib/middleware/compression.js +452 -452
- package/lib/middleware/cors.js +314 -314
- package/lib/middleware/csp-nonce.js +348 -348
- package/lib/middleware/csrf-protect.js +399 -316
- package/lib/middleware/db-role-for.js +264 -264
- package/lib/middleware/fetch-metadata.js +129 -0
- package/lib/middleware/health.js +392 -392
- package/lib/middleware/index.js +85 -79
- package/lib/middleware/rate-limit.js +358 -358
- package/lib/middleware/request-id.js +61 -61
- package/lib/middleware/request-log.js +168 -168
- package/lib/middleware/require-auth.js +104 -104
- package/lib/middleware/security-headers.js +121 -116
- package/lib/middleware/sse.js +166 -166
- package/lib/migrations.js +383 -383
- package/lib/mtls-ca.js +518 -518
- package/lib/mtls-engine-default.js +481 -481
- package/lib/network-dns.js +632 -632
- package/lib/network-heartbeat.js +290 -290
- package/lib/network-nts.js +574 -574
- package/lib/network-proxy.js +265 -265
- package/lib/network-tls.js +328 -328
- package/lib/network.js +233 -233
- package/lib/notify.js +612 -612
- package/lib/ntp-check.js +229 -229
- package/lib/numeric-bounds.js +111 -111
- package/lib/object-store/azure-blob-bucket-ops.js +349 -349
- package/lib/object-store/azure-blob.js +488 -488
- package/lib/object-store/gcs-bucket-ops.js +351 -351
- package/lib/object-store/gcs.js +519 -519
- package/lib/object-store/http-put.js +153 -153
- package/lib/object-store/index.js +197 -197
- package/lib/object-store/sigv4-bucket-ops.js +1092 -1092
- package/lib/object-store/sigv4.js +903 -903
- package/lib/observability.js +151 -151
- package/lib/otel-export.js +269 -269
- package/lib/pagination.js +464 -464
- package/lib/parsers/index.js +80 -80
- package/lib/parsers/safe-env.js +642 -642
- package/lib/parsers/safe-ini.js +292 -292
- package/lib/parsers/safe-toml.js +784 -784
- package/lib/parsers/safe-xml.js +390 -390
- package/lib/parsers/safe-yaml.js +1015 -1015
- package/lib/permissions.js +708 -708
- package/lib/pqc-agent.js +87 -87
- package/lib/pqc-gate.js +279 -279
- package/lib/protobuf-encoder.js +190 -190
- package/lib/protocol-dispatcher.js +161 -161
- package/lib/pubsub-redis.js +167 -167
- package/lib/pubsub.js +429 -429
- package/lib/queue-local.js +476 -476
- package/lib/queue-redis.js +745 -745
- package/lib/queue-sqs.js +319 -319
- package/lib/queue.js +695 -695
- package/lib/redis-client.js +519 -519
- package/lib/request-helpers.js +340 -340
- package/lib/restore-bundle.js +237 -237
- package/lib/restore-rollback.js +259 -259
- package/lib/restore.js +409 -409
- package/lib/retry.js +376 -376
- package/lib/router.js +748 -748
- package/lib/safe-async.js +735 -735
- package/lib/safe-buffer.js +237 -237
- package/lib/safe-json.js +541 -541
- package/lib/safe-schema.js +1266 -1266
- package/lib/safe-url.js +159 -159
- package/lib/scheduler.js +706 -706
- package/lib/security-assert.js +373 -373
- package/lib/seeders.js +618 -618
- package/lib/session.js +535 -478
- package/lib/slug.js +269 -269
- package/lib/ssrf-guard.js +401 -401
- package/lib/static.js +7 -5
- package/lib/storage.js +471 -471
- package/lib/subject.js +281 -281
- package/lib/template.js +791 -791
- package/lib/testing.js +798 -798
- package/lib/time.js +310 -310
- package/lib/totp.js +302 -302
- package/lib/tracing.js +494 -494
- package/lib/uuid.js +132 -132
- package/lib/validate-opts.js +340 -340
- package/lib/vault/index.js +308 -308
- package/lib/vault/rotate.js +784 -784
- package/lib/vault/wrap.js +296 -296
- package/lib/vendor/noble-ciphers.cjs +9 -9
- package/lib/webhook.js +595 -595
- package/lib/websocket.js +1048 -1048
- package/package.json +77 -77
- package/sbom.cyclonedx.json +7 -7
package/lib/redis-client.js
CHANGED
|
@@ -1,519 +1,519 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
/**
|
|
3
|
-
* Bespoke RESP2 Redis client — zero npm runtime deps.
|
|
4
|
-
*
|
|
5
|
-
* Single-connection client with auto-reconnect, auth, optional TLS,
|
|
6
|
-
* and request/response pipelining. Scope:
|
|
7
|
-
* - RESP2 protocol only (RESP3 not needed for queue-redis ops)
|
|
8
|
-
* - Single-node mode (no Cluster, no Sentinel)
|
|
9
|
-
* - TCP via node:net OR TLS via node:tls (rediss:// auto-detected)
|
|
10
|
-
* - AUTH (legacy single-arg AND ACL-style username + password)
|
|
11
|
-
* - SELECT db
|
|
12
|
-
* - Pipelining (writes are FIFO; responses dispatched in arrival order)
|
|
13
|
-
* - Lua scripting (EVAL / EVALSHA via runScript())
|
|
14
|
-
* - Reconnect with exponential backoff
|
|
15
|
-
*
|
|
16
|
-
* Operator API:
|
|
17
|
-
* var c = redis.create({ url: "redis://localhost:6379/0", password: "..." });
|
|
18
|
-
* await c.connect();
|
|
19
|
-
* var pong = await c.command("PING"); // "PONG"
|
|
20
|
-
* var n = await c.command("ZADD", "key", "1", "m"); // 1
|
|
21
|
-
* var rv = await c.runScript(luaSrc, 1, "k1", "arg1");
|
|
22
|
-
* await c.close();
|
|
23
|
-
*
|
|
24
|
-
* Error convention: every failure throws a RedisError with .code so
|
|
25
|
-
* callers can branch on transport vs server-side errors.
|
|
26
|
-
*/
|
|
27
|
-
var net = require("node:net");
|
|
28
|
-
var tls = require("node:tls");
|
|
29
|
-
var url = require("node:url");
|
|
30
|
-
var C = require("./constants");
|
|
31
|
-
var safeAsync = require("./safe-async");
|
|
32
|
-
var validateOpts = require("./validate-opts");
|
|
33
|
-
var { RedisError } = require("./framework-error");
|
|
34
|
-
|
|
35
|
-
var _err = RedisError.factory;
|
|
36
|
-
|
|
37
|
-
// Radix for `.toString(N)` hex conversion. Numeric, not a byte count.
|
|
38
|
-
var HEX_RADIX = 16;
|
|
39
|
-
|
|
40
|
-
// ---- Wire-format encoder ----
|
|
41
|
-
//
|
|
42
|
-
// RESP2 inline command form for arbitrary args:
|
|
43
|
-
// *<argc>\r\n
|
|
44
|
-
// $<arglen>\r\n<argbytes>\r\n
|
|
45
|
-
// ... repeat per arg ...
|
|
46
|
-
function _encodeCommand(args) {
|
|
47
|
-
if (!Array.isArray(args) || args.length === 0) {
|
|
48
|
-
throw _err("BAD_ARGS", "encodeCommand: args must be a non-empty array");
|
|
49
|
-
}
|
|
50
|
-
var parts = ["*" + args.length + "\r\n"];
|
|
51
|
-
for (var i = 0; i < args.length; i++) {
|
|
52
|
-
var a = args[i];
|
|
53
|
-
var buf;
|
|
54
|
-
if (Buffer.isBuffer(a)) {
|
|
55
|
-
buf = a;
|
|
56
|
-
} else if (a === null || a === undefined) {
|
|
57
|
-
throw _err("BAD_ARGS", "encodeCommand: arg " + i + " is null/undefined");
|
|
58
|
-
} else {
|
|
59
|
-
buf = Buffer.from(String(a), "utf8");
|
|
60
|
-
}
|
|
61
|
-
parts.push("$" + buf.length + "\r\n");
|
|
62
|
-
parts.push(buf);
|
|
63
|
-
parts.push("\r\n");
|
|
64
|
-
}
|
|
65
|
-
// Concat as Buffer — supports binary args (sealed payloads etc.)
|
|
66
|
-
var bufs = parts.map(function (p) {
|
|
67
|
-
return Buffer.isBuffer(p) ? p : Buffer.from(p, "utf8");
|
|
68
|
-
});
|
|
69
|
-
return Buffer.concat(bufs);
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
// ---- Wire-format decoder ----
|
|
73
|
-
//
|
|
74
|
-
// Stateful streaming parser. Returns one of:
|
|
75
|
-
// { type: "incomplete" } — need more bytes
|
|
76
|
-
// { type: "string", value, consumed } — simple string (+OK)
|
|
77
|
-
// { type: "error", value, consumed } — error line (-ERR ...)
|
|
78
|
-
// { type: "int", value, consumed } — integer (:42)
|
|
79
|
-
// { type: "bulk", value, consumed } — bulk string buffer (or null)
|
|
80
|
-
// { type: "array", value, consumed } — array of decoded items
|
|
81
|
-
function _parseFrame(buf, offset) {
|
|
82
|
-
if (offset >= buf.length) return { type: "incomplete" };
|
|
83
|
-
var marker = buf[offset];
|
|
84
|
-
// Find next CRLF after the marker
|
|
85
|
-
var crlf = buf.indexOf("\r\n", offset + 1);
|
|
86
|
-
if (crlf === -1) return { type: "incomplete" };
|
|
87
|
-
var headerEnd = crlf;
|
|
88
|
-
var payloadStr = buf.slice(offset + 1, headerEnd).toString("utf8");
|
|
89
|
-
|
|
90
|
-
if (marker === 0x2b /* + */) {
|
|
91
|
-
return { type: "string", value: payloadStr, consumed: crlf + 2 - offset };
|
|
92
|
-
}
|
|
93
|
-
if (marker === 0x2d /* - */) {
|
|
94
|
-
return { type: "error", value: payloadStr, consumed: crlf + 2 - offset };
|
|
95
|
-
}
|
|
96
|
-
if (marker === 0x3a /* : */) {
|
|
97
|
-
var n = Number(payloadStr);
|
|
98
|
-
if (!Number.isFinite(n)) {
|
|
99
|
-
throw _err("PROTOCOL", "integer reply not finite: " + payloadStr);
|
|
100
|
-
}
|
|
101
|
-
return { type: "int", value: n, consumed: crlf + 2 - offset };
|
|
102
|
-
}
|
|
103
|
-
if (marker === 0x24 /* $ */) {
|
|
104
|
-
var len = Number(payloadStr);
|
|
105
|
-
if (!Number.isFinite(len)) {
|
|
106
|
-
throw _err("PROTOCOL", "bulk length not finite: " + payloadStr);
|
|
107
|
-
}
|
|
108
|
-
if (len === -1) return { type: "bulk", value: null, consumed: crlf + 2 - offset };
|
|
109
|
-
var dataStart = crlf + 2;
|
|
110
|
-
var dataEnd = dataStart + len;
|
|
111
|
-
if (dataEnd + 2 > buf.length) return { type: "incomplete" };
|
|
112
|
-
var bulk = buf.slice(dataStart, dataEnd);
|
|
113
|
-
return { type: "bulk", value: bulk, consumed: dataEnd + 2 - offset };
|
|
114
|
-
}
|
|
115
|
-
if (marker === 0x2a /* * */) {
|
|
116
|
-
var arrLen = Number(payloadStr);
|
|
117
|
-
if (!Number.isFinite(arrLen)) {
|
|
118
|
-
throw _err("PROTOCOL", "array length not finite: " + payloadStr);
|
|
119
|
-
}
|
|
120
|
-
if (arrLen === -1) return { type: "array", value: null, consumed: crlf + 2 - offset };
|
|
121
|
-
var items = [];
|
|
122
|
-
var cursor = crlf + 2;
|
|
123
|
-
for (var i = 0; i < arrLen; i++) {
|
|
124
|
-
var sub = _parseFrame(buf, cursor);
|
|
125
|
-
if (sub.type === "incomplete") return { type: "incomplete" };
|
|
126
|
-
items.push(sub);
|
|
127
|
-
cursor += sub.consumed;
|
|
128
|
-
}
|
|
129
|
-
return { type: "array", value: items, consumed: cursor - offset };
|
|
130
|
-
}
|
|
131
|
-
throw _err("PROTOCOL", "unknown reply marker 0x" + marker.toString(HEX_RADIX));
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
// Convert a parsed frame tree into a JavaScript-friendly value.
|
|
135
|
-
// Bulks are returned as Buffer (caller decides encoding); arrays
|
|
136
|
-
// recurse; errors are surfaced as { error: msg }; integers are numbers.
|
|
137
|
-
function _frameToValue(frame) {
|
|
138
|
-
if (frame.type === "string") return frame.value;
|
|
139
|
-
if (frame.type === "int") return frame.value;
|
|
140
|
-
if (frame.type === "bulk") return frame.value;
|
|
141
|
-
if (frame.type === "error") return { _redisError: true, message: frame.value };
|
|
142
|
-
if (frame.type === "array") {
|
|
143
|
-
if (frame.value === null) return null;
|
|
144
|
-
return frame.value.map(_frameToValue);
|
|
145
|
-
}
|
|
146
|
-
throw _err("PROTOCOL", "_frameToValue: unknown frame type " + frame.type);
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
// ---- Client ----
|
|
150
|
-
//
|
|
151
|
-
// Single connection, FIFO request queue, auto-reconnect on socket
|
|
152
|
-
// close. Pipelining is implicit — every command appends to the queue
|
|
153
|
-
// and writes immediately; responses are dispatched in arrival order.
|
|
154
|
-
function create(opts) {
|
|
155
|
-
opts = opts || {};
|
|
156
|
-
validateOpts.requireNonEmptyString(opts.url, "redis.create: opts.url", RedisError, "BAD_OPTS");
|
|
157
|
-
var parsed = _parseRedisUrl(opts.url);
|
|
158
|
-
var host = opts.host || parsed.host;
|
|
159
|
-
var port = opts.port || parsed.port;
|
|
160
|
-
var useTls = opts.tls !== undefined ? !!opts.tls : parsed.tls;
|
|
161
|
-
var password = opts.password !== undefined ? opts.password : parsed.password;
|
|
162
|
-
var username = opts.username !== undefined ? opts.username : parsed.username;
|
|
163
|
-
var db = opts.db !== undefined ? Number(opts.db) : parsed.db;
|
|
164
|
-
var connectTimeoutMs = Number(opts.connectTimeoutMs) || 5000;
|
|
165
|
-
var commandTimeoutMs = Number(opts.commandTimeoutMs) || 10000;
|
|
166
|
-
var maxReconnectAttempts = opts.maxReconnectAttempts === undefined ? 10
|
|
167
|
-
: Number(opts.maxReconnectAttempts);
|
|
168
|
-
// TLS verification controls. Operators using rediss:// against private
|
|
169
|
-
// CAs (managed Redis services, on-prem clusters with internal PKI)
|
|
170
|
-
// pin the trust roots via opts.ca; rejectUnauthorized stays on by
|
|
171
|
-
// default — never weaken verification to make a connection succeed.
|
|
172
|
-
var caBundle = opts.ca || null;
|
|
173
|
-
// SNI is only legal for hostnames; IP literals must omit servername.
|
|
174
|
-
var servername = opts.servername;
|
|
175
|
-
if (servername === undefined) {
|
|
176
|
-
servername = (/^\d+\.\d+\.\d+\.\d+$/.test(host) || host.indexOf(":") !== -1)
|
|
177
|
-
? undefined : host;
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
var socket = null;
|
|
181
|
-
var connected = false;
|
|
182
|
-
var connecting = false;
|
|
183
|
-
var closing = false;
|
|
184
|
-
var rxBuffer = Buffer.alloc(0);
|
|
185
|
-
// FIFO of in-flight commands awaiting a response
|
|
186
|
-
var pending = [];
|
|
187
|
-
// Backlog of commands queued before connect resolved
|
|
188
|
-
var backlog = [];
|
|
189
|
-
var reconnectAttempt = 0;
|
|
190
|
-
// Pub/sub demultiplex hook. When set, the receive path dispatches
|
|
191
|
-
// server-pushed "message" / "pmessage" frames (RESP arrays whose
|
|
192
|
-
// first element is one of those literals) to onPushMessage instead
|
|
193
|
-
// of consuming a pending request slot. SUBSCRIBE / UNSUBSCRIBE /
|
|
194
|
-
// PSUBSCRIBE / PUNSUBSCRIBE acks still flow through pending — they
|
|
195
|
-
// ARE responses to caller-issued commands. The split lets a single
|
|
196
|
-
// socket handle subscribe-mode acks AND the asynchronous fan-out
|
|
197
|
-
// events without confusing the FIFO.
|
|
198
|
-
var onPushMessage = typeof opts.onPushMessage === "function"
|
|
199
|
-
? opts.onPushMessage : null;
|
|
200
|
-
|
|
201
|
-
function _scheduleReconnect() {
|
|
202
|
-
if (closing) return;
|
|
203
|
-
if (maxReconnectAttempts >= 0 && reconnectAttempt >= maxReconnectAttempts) {
|
|
204
|
-
// Drain pending callbacks with a clear error
|
|
205
|
-
var err = _err("RECONNECT_GAVE_UP",
|
|
206
|
-
"redis: gave up after " + reconnectAttempt + " reconnect attempts");
|
|
207
|
-
_drainPending(err);
|
|
208
|
-
return;
|
|
209
|
-
}
|
|
210
|
-
reconnectAttempt++;
|
|
211
|
-
var delay = Math.min(C.TIME.seconds(30), 100 * Math.pow(2, reconnectAttempt - 1));
|
|
212
|
-
setTimeout(function () { _connect().catch(function () { /* will reschedule */ }); }, delay);
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
function _drainPending(err) {
|
|
216
|
-
var batch = pending.slice();
|
|
217
|
-
pending.length = 0;
|
|
218
|
-
batch.forEach(function (p) { p.reject(err); });
|
|
219
|
-
var bl = backlog.slice();
|
|
220
|
-
backlog.length = 0;
|
|
221
|
-
bl.forEach(function (p) { p.reject(err); });
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
function _onData(chunk) {
|
|
225
|
-
rxBuffer = rxBuffer.length === 0 ? chunk : Buffer.concat([rxBuffer, chunk]);
|
|
226
|
-
while (rxBuffer.length > 0) {
|
|
227
|
-
var frame = _parseFrame(rxBuffer, 0);
|
|
228
|
-
if (frame.type === "incomplete") return;
|
|
229
|
-
var value = _frameToValue(frame);
|
|
230
|
-
rxBuffer = rxBuffer.slice(frame.consumed);
|
|
231
|
-
|
|
232
|
-
// Pub/sub push detection — server-initiated arrays beginning with
|
|
233
|
-
// "message" (3-tuple: type / channel / payload) or "pmessage"
|
|
234
|
-
// (4-tuple: type / pattern / channel / payload). Routed to
|
|
235
|
-
// onPushMessage; do not consume a pending entry.
|
|
236
|
-
if (onPushMessage && Array.isArray(value) && value.length >= 3 &&
|
|
237
|
-
Buffer.isBuffer(value[0])) {
|
|
238
|
-
var typeStr = value[0].toString("utf8");
|
|
239
|
-
if (typeStr === "message" && value.length === 3) {
|
|
240
|
-
onPushMessage({
|
|
241
|
-
pattern: null,
|
|
242
|
-
channel: Buffer.isBuffer(value[1]) ? value[1].toString("utf8") : String(value[1]),
|
|
243
|
-
payload: value[2],
|
|
244
|
-
});
|
|
245
|
-
continue;
|
|
246
|
-
}
|
|
247
|
-
if (typeStr === "pmessage" && value.length === 4) {
|
|
248
|
-
onPushMessage({
|
|
249
|
-
pattern: Buffer.isBuffer(value[1]) ? value[1].toString("utf8") : String(value[1]),
|
|
250
|
-
channel: Buffer.isBuffer(value[2]) ? value[2].toString("utf8") : String(value[2]),
|
|
251
|
-
payload: value[3],
|
|
252
|
-
});
|
|
253
|
-
continue;
|
|
254
|
-
}
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
if (pending.length === 0) {
|
|
258
|
-
// Orphan frame with no pending request — drop. This is the
|
|
259
|
-
// expected path for subscribe/unsubscribe acks if onPushMessage
|
|
260
|
-
// is wired but the caller didn't await them.
|
|
261
|
-
continue;
|
|
262
|
-
}
|
|
263
|
-
var p = pending.shift();
|
|
264
|
-
if (value && value._redisError) {
|
|
265
|
-
p.reject(_err("REDIS_REPLY", value.message));
|
|
266
|
-
} else {
|
|
267
|
-
p.resolve(value);
|
|
268
|
-
}
|
|
269
|
-
}
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
function _onSocketError(err) {
|
|
273
|
-
var werr = _err("SOCKET", "redis socket error: " + ((err && err.message) || String(err)));
|
|
274
|
-
_drainPending(werr);
|
|
275
|
-
connected = false;
|
|
276
|
-
try { if (socket) socket.destroy(); } catch (_e) { /* best-effort socket teardown */ }
|
|
277
|
-
socket = null;
|
|
278
|
-
if (!closing) _scheduleReconnect();
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
function _onSocketClose() {
|
|
282
|
-
connected = false;
|
|
283
|
-
if (!closing) {
|
|
284
|
-
var err = _err("SOCKET_CLOSED", "redis socket closed unexpectedly");
|
|
285
|
-
_drainPending(err);
|
|
286
|
-
socket = null;
|
|
287
|
-
_scheduleReconnect();
|
|
288
|
-
}
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
async function _connect() {
|
|
292
|
-
if (connected) return;
|
|
293
|
-
if (connecting) {
|
|
294
|
-
// Wait until current connect attempt resolves
|
|
295
|
-
while (connecting) await safeAsync.sleep(20);
|
|
296
|
-
return;
|
|
297
|
-
}
|
|
298
|
-
connecting = true;
|
|
299
|
-
rxBuffer = Buffer.alloc(0);
|
|
300
|
-
try {
|
|
301
|
-
socket = await new Promise(function (resolve, reject) {
|
|
302
|
-
var sock;
|
|
303
|
-
var timer = setTimeout(function () {
|
|
304
|
-
try { if (sock) sock.destroy(); } catch (_e) { /* best-effort socket teardown */ }
|
|
305
|
-
reject(_err("CONNECT_TIMEOUT",
|
|
306
|
-
"redis connect timed out after " + connectTimeoutMs + "ms (host=" + host + ":" + port + ")"));
|
|
307
|
-
}, connectTimeoutMs);
|
|
308
|
-
function onOk() {
|
|
309
|
-
clearTimeout(timer);
|
|
310
|
-
sock.removeListener("error", onErr);
|
|
311
|
-
resolve(sock);
|
|
312
|
-
}
|
|
313
|
-
function onErr(e) {
|
|
314
|
-
clearTimeout(timer);
|
|
315
|
-
try { sock.destroy(); } catch (_e) { /* best-effort socket teardown */ }
|
|
316
|
-
reject(_err("CONNECT", "redis connect failed: " + ((e && e.message) || String(e))));
|
|
317
|
-
}
|
|
318
|
-
if (useTls) {
|
|
319
|
-
var tlsConnectOpts = { host: host, port: port };
|
|
320
|
-
if (servername) tlsConnectOpts.servername = servername;
|
|
321
|
-
if (caBundle) tlsConnectOpts.ca = caBundle;
|
|
322
|
-
sock = tls.connect(tlsConnectOpts, onOk);
|
|
323
|
-
} else {
|
|
324
|
-
sock = net.connect({ host: host, port: port }, onOk);
|
|
325
|
-
}
|
|
326
|
-
sock.once("error", onErr);
|
|
327
|
-
});
|
|
328
|
-
socket.setNoDelay(true);
|
|
329
|
-
socket.on("data", _onData);
|
|
330
|
-
socket.on("error", _onSocketError);
|
|
331
|
-
socket.on("close", _onSocketClose);
|
|
332
|
-
connected = true;
|
|
333
|
-
reconnectAttempt = 0;
|
|
334
|
-
|
|
335
|
-
// Auth + select db on (re)connect — without resetting the
|
|
336
|
-
// backlog of commands queued during disconnect. Send these
|
|
337
|
-
// BEFORE the backlog so the server is ready when backlog flushes.
|
|
338
|
-
if (password) {
|
|
339
|
-
var authArgs = username ? ["AUTH", username, password] : ["AUTH", password];
|
|
340
|
-
await _sendNoQueue(authArgs);
|
|
341
|
-
}
|
|
342
|
-
if (Number.isFinite(db) && db !== 0) {
|
|
343
|
-
await _sendNoQueue(["SELECT", String(db)]);
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
// Flush backlog
|
|
347
|
-
var bl = backlog.slice();
|
|
348
|
-
backlog.length = 0;
|
|
349
|
-
bl.forEach(function (entry) { _writeAndAwait(entry.args, entry.resolve, entry.reject); });
|
|
350
|
-
} catch (err) {
|
|
351
|
-
connecting = false;
|
|
352
|
-
throw err;
|
|
353
|
-
}
|
|
354
|
-
connecting = false;
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
// Internal helper that bypasses the connect-pending backlog (used
|
|
358
|
-
// for AUTH / SELECT during connect itself, where the socket is
|
|
359
|
-
// already up but `connected = true` is set immediately above).
|
|
360
|
-
function _sendNoQueue(args) {
|
|
361
|
-
return new Promise(function (resolve, reject) {
|
|
362
|
-
pending.push({
|
|
363
|
-
resolve: resolve,
|
|
364
|
-
reject: reject,
|
|
365
|
-
timer: setTimeout(function () {
|
|
366
|
-
var idx = pending.findIndex(function (p) { return p.resolve === resolve; });
|
|
367
|
-
if (idx !== -1) pending.splice(idx, 1);
|
|
368
|
-
reject(_err("COMMAND_TIMEOUT", "redis " + args[0] + " timed out"));
|
|
369
|
-
}, commandTimeoutMs),
|
|
370
|
-
});
|
|
371
|
-
try { socket.write(_encodeCommand(args)); }
|
|
372
|
-
catch (e) { reject(_err("WRITE", "redis write failed: " + ((e && e.message) || String(e)))); }
|
|
373
|
-
});
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
function _writeAndAwait(args, resolve, reject) {
|
|
377
|
-
var entry = {
|
|
378
|
-
resolve: function (v) { clearTimeout(entry.timer); resolve(v); },
|
|
379
|
-
reject: function (e) { clearTimeout(entry.timer); reject(e); },
|
|
380
|
-
timer: null,
|
|
381
|
-
};
|
|
382
|
-
entry.timer = setTimeout(function () {
|
|
383
|
-
var idx = pending.indexOf(entry);
|
|
384
|
-
if (idx !== -1) pending.splice(idx, 1);
|
|
385
|
-
reject(_err("COMMAND_TIMEOUT", "redis " + args[0] + " timed out"));
|
|
386
|
-
}, commandTimeoutMs);
|
|
387
|
-
pending.push(entry);
|
|
388
|
-
try { socket.write(_encodeCommand(args)); }
|
|
389
|
-
catch (e) {
|
|
390
|
-
var i = pending.indexOf(entry);
|
|
391
|
-
if (i !== -1) pending.splice(i, 1);
|
|
392
|
-
clearTimeout(entry.timer);
|
|
393
|
-
reject(_err("WRITE", "redis write failed: " + ((e && e.message) || String(e))));
|
|
394
|
-
}
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
function command() {
|
|
398
|
-
var args = Array.prototype.slice.call(arguments);
|
|
399
|
-
return new Promise(function (resolve, reject) {
|
|
400
|
-
if (closing) {
|
|
401
|
-
reject(_err("CLOSED", "redis client is closed"));
|
|
402
|
-
return;
|
|
403
|
-
}
|
|
404
|
-
if (!connected) {
|
|
405
|
-
backlog.push({ args: args, resolve: resolve, reject: reject });
|
|
406
|
-
return;
|
|
407
|
-
}
|
|
408
|
-
_writeAndAwait(args, resolve, reject);
|
|
409
|
-
});
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
// runScript — Redis EVAL helper. script + numKeys + key1..keyN +
|
|
413
|
-
// arg1..argM. Returns whatever the script returns, decoded by
|
|
414
|
-
// _frameToValue. Named runScript (not evalScript) so source-scan
|
|
415
|
-
// tooling looking for the JavaScript eval() pattern doesn't
|
|
416
|
-
// false-positive on this file.
|
|
417
|
-
function runScript(script, numKeys /* ...keysAndArgs */) {
|
|
418
|
-
var rest = Array.prototype.slice.call(arguments, 2);
|
|
419
|
-
var args = ["EVAL", script, String(numKeys)].concat(rest);
|
|
420
|
-
return command.apply(null, args);
|
|
421
|
-
}
|
|
422
|
-
|
|
423
|
-
async function close() {
|
|
424
|
-
closing = true;
|
|
425
|
-
var err = _err("CLOSED", "redis client closed");
|
|
426
|
-
_drainPending(err);
|
|
427
|
-
if (socket) {
|
|
428
|
-
try { socket.end(); } catch (_e) { /* best-effort socket close */ }
|
|
429
|
-
try { socket.destroy(); } catch (_e) { /* best-effort socket teardown */ }
|
|
430
|
-
socket = null;
|
|
431
|
-
}
|
|
432
|
-
connected = false;
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
return {
|
|
436
|
-
connect: _connect,
|
|
437
|
-
command: command,
|
|
438
|
-
runScript: runScript,
|
|
439
|
-
close: close,
|
|
440
|
-
isOpen: function () { return connected && !closing; },
|
|
441
|
-
setOnPushMessage: function (fn) {
|
|
442
|
-
onPushMessage = typeof fn === "function" ? fn : null;
|
|
443
|
-
},
|
|
444
|
-
// Diagnostic — exposed for tests + observability
|
|
445
|
-
_state: function () {
|
|
446
|
-
return {
|
|
447
|
-
connected: connected, closing: closing,
|
|
448
|
-
pending: pending.length, backlog: backlog.length,
|
|
449
|
-
reconnect: reconnectAttempt,
|
|
450
|
-
host: host, port: port, db: db, tls: useTls,
|
|
451
|
-
};
|
|
452
|
-
},
|
|
453
|
-
};
|
|
454
|
-
}
|
|
455
|
-
|
|
456
|
-
// Parse `redis://[username:password@]host[:port][/db]` and `rediss://...` URLs.
|
|
457
|
-
// Empty-username + non-empty password is the legacy single-arg AUTH form.
|
|
458
|
-
function _parseRedisUrl(s) {
|
|
459
|
-
var u;
|
|
460
|
-
try { u = new url.URL(s); }
|
|
461
|
-
catch (e) {
|
|
462
|
-
throw _err("BAD_URL", "redis url parse failed: " + ((e && e.message) || String(e)));
|
|
463
|
-
}
|
|
464
|
-
if (u.protocol !== "redis:" && u.protocol !== "rediss:") {
|
|
465
|
-
throw _err("BAD_URL", "redis url protocol must be redis: or rediss:, got " + u.protocol);
|
|
466
|
-
}
|
|
467
|
-
var dbStr = (u.pathname || "/").replace(/^\//, "");
|
|
468
|
-
var db = dbStr === "" ? 0 : Number(dbStr);
|
|
469
|
-
if (!Number.isFinite(db) || db < 0 || db > 15 || Math.floor(db) !== db) {
|
|
470
|
-
throw _err("BAD_URL", "redis url db must be integer 0..15, got " + dbStr);
|
|
471
|
-
}
|
|
472
|
-
return {
|
|
473
|
-
host: u.hostname || "127.0.0.1",
|
|
474
|
-
port: u.port ? Number(u.port) : 6379,
|
|
475
|
-
tls: u.protocol === "rediss:",
|
|
476
|
-
username: u.username ? decodeURIComponent(u.username) : null,
|
|
477
|
-
password: u.password ? decodeURIComponent(u.password) : null,
|
|
478
|
-
db: db,
|
|
479
|
-
};
|
|
480
|
-
}
|
|
481
|
-
|
|
482
|
-
// pickClientOpts(cfg, prefix?) — extract the standard redis-client opts
|
|
483
|
-
// from a larger config bag. Lets cache-redis / pubsub-redis / queue-redis
|
|
484
|
-
// / etc. forward to redisClient.create without each repeating the 9-key
|
|
485
|
-
// list. The optional `prefix` lets callers whose operator-facing opts
|
|
486
|
-
// are namespaced (`redisUrl`, `redisPassword`, ...) reuse the same
|
|
487
|
-
// helper by passing prefix="redis" — the helper camel-cases the prefix
|
|
488
|
-
// onto each key.
|
|
489
|
-
//
|
|
490
|
-
// var opts = redisClient.pickClientOpts(cfg); // unprefixed
|
|
491
|
-
// var opts = redisClient.pickClientOpts(operatorOpts, "redis"); // redisUrl etc.
|
|
492
|
-
function pickClientOpts(cfg, prefix) {
|
|
493
|
-
if (!cfg || typeof cfg !== "object") return {};
|
|
494
|
-
function pick(name) {
|
|
495
|
-
if (!prefix) return cfg[name];
|
|
496
|
-
return cfg[prefix + name.charAt(0).toUpperCase() + name.slice(1)];
|
|
497
|
-
}
|
|
498
|
-
return {
|
|
499
|
-
url: pick("url"),
|
|
500
|
-
password: pick("password"),
|
|
501
|
-
username: pick("username"),
|
|
502
|
-
tls: pick("tls"),
|
|
503
|
-
ca: pick("ca"),
|
|
504
|
-
servername: pick("servername"),
|
|
505
|
-
connectTimeoutMs: pick("connectTimeoutMs"),
|
|
506
|
-
commandTimeoutMs: pick("commandTimeoutMs"),
|
|
507
|
-
maxReconnectAttempts: pick("maxReconnectAttempts"),
|
|
508
|
-
};
|
|
509
|
-
}
|
|
510
|
-
|
|
511
|
-
module.exports = {
|
|
512
|
-
create: create,
|
|
513
|
-
pickClientOpts: pickClientOpts,
|
|
514
|
-
// Exposed for tests / direct callers that already manage their own socket.
|
|
515
|
-
_encodeCommand: _encodeCommand,
|
|
516
|
-
_parseFrame: _parseFrame,
|
|
517
|
-
_frameToValue: _frameToValue,
|
|
518
|
-
_parseRedisUrl: _parseRedisUrl,
|
|
519
|
-
};
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Bespoke RESP2 Redis client — zero npm runtime deps.
|
|
4
|
+
*
|
|
5
|
+
* Single-connection client with auto-reconnect, auth, optional TLS,
|
|
6
|
+
* and request/response pipelining. Scope:
|
|
7
|
+
* - RESP2 protocol only (RESP3 not needed for queue-redis ops)
|
|
8
|
+
* - Single-node mode (no Cluster, no Sentinel)
|
|
9
|
+
* - TCP via node:net OR TLS via node:tls (rediss:// auto-detected)
|
|
10
|
+
* - AUTH (legacy single-arg AND ACL-style username + password)
|
|
11
|
+
* - SELECT db
|
|
12
|
+
* - Pipelining (writes are FIFO; responses dispatched in arrival order)
|
|
13
|
+
* - Lua scripting (EVAL / EVALSHA via runScript())
|
|
14
|
+
* - Reconnect with exponential backoff
|
|
15
|
+
*
|
|
16
|
+
* Operator API:
|
|
17
|
+
* var c = redis.create({ url: "redis://localhost:6379/0", password: "..." });
|
|
18
|
+
* await c.connect();
|
|
19
|
+
* var pong = await c.command("PING"); // "PONG"
|
|
20
|
+
* var n = await c.command("ZADD", "key", "1", "m"); // 1
|
|
21
|
+
* var rv = await c.runScript(luaSrc, 1, "k1", "arg1");
|
|
22
|
+
* await c.close();
|
|
23
|
+
*
|
|
24
|
+
* Error convention: every failure throws a RedisError with .code so
|
|
25
|
+
* callers can branch on transport vs server-side errors.
|
|
26
|
+
*/
|
|
27
|
+
var net = require("node:net");
|
|
28
|
+
var tls = require("node:tls");
|
|
29
|
+
var url = require("node:url");
|
|
30
|
+
var C = require("./constants");
|
|
31
|
+
var safeAsync = require("./safe-async");
|
|
32
|
+
var validateOpts = require("./validate-opts");
|
|
33
|
+
var { RedisError } = require("./framework-error");
|
|
34
|
+
|
|
35
|
+
var _err = RedisError.factory;
|
|
36
|
+
|
|
37
|
+
// Radix for `.toString(N)` hex conversion. Numeric, not a byte count.
|
|
38
|
+
var HEX_RADIX = 16;
|
|
39
|
+
|
|
40
|
+
// ---- Wire-format encoder ----
|
|
41
|
+
//
|
|
42
|
+
// RESP2 inline command form for arbitrary args:
|
|
43
|
+
// *<argc>\r\n
|
|
44
|
+
// $<arglen>\r\n<argbytes>\r\n
|
|
45
|
+
// ... repeat per arg ...
|
|
46
|
+
function _encodeCommand(args) {
|
|
47
|
+
if (!Array.isArray(args) || args.length === 0) {
|
|
48
|
+
throw _err("BAD_ARGS", "encodeCommand: args must be a non-empty array");
|
|
49
|
+
}
|
|
50
|
+
var parts = ["*" + args.length + "\r\n"];
|
|
51
|
+
for (var i = 0; i < args.length; i++) {
|
|
52
|
+
var a = args[i];
|
|
53
|
+
var buf;
|
|
54
|
+
if (Buffer.isBuffer(a)) {
|
|
55
|
+
buf = a;
|
|
56
|
+
} else if (a === null || a === undefined) {
|
|
57
|
+
throw _err("BAD_ARGS", "encodeCommand: arg " + i + " is null/undefined");
|
|
58
|
+
} else {
|
|
59
|
+
buf = Buffer.from(String(a), "utf8");
|
|
60
|
+
}
|
|
61
|
+
parts.push("$" + buf.length + "\r\n");
|
|
62
|
+
parts.push(buf);
|
|
63
|
+
parts.push("\r\n");
|
|
64
|
+
}
|
|
65
|
+
// Concat as Buffer — supports binary args (sealed payloads etc.)
|
|
66
|
+
var bufs = parts.map(function (p) {
|
|
67
|
+
return Buffer.isBuffer(p) ? p : Buffer.from(p, "utf8");
|
|
68
|
+
});
|
|
69
|
+
return Buffer.concat(bufs);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ---- Wire-format decoder ----
|
|
73
|
+
//
|
|
74
|
+
// Stateful streaming parser. Returns one of:
|
|
75
|
+
// { type: "incomplete" } — need more bytes
|
|
76
|
+
// { type: "string", value, consumed } — simple string (+OK)
|
|
77
|
+
// { type: "error", value, consumed } — error line (-ERR ...)
|
|
78
|
+
// { type: "int", value, consumed } — integer (:42)
|
|
79
|
+
// { type: "bulk", value, consumed } — bulk string buffer (or null)
|
|
80
|
+
// { type: "array", value, consumed } — array of decoded items
|
|
81
|
+
function _parseFrame(buf, offset) {
|
|
82
|
+
if (offset >= buf.length) return { type: "incomplete" };
|
|
83
|
+
var marker = buf[offset];
|
|
84
|
+
// Find next CRLF after the marker
|
|
85
|
+
var crlf = buf.indexOf("\r\n", offset + 1);
|
|
86
|
+
if (crlf === -1) return { type: "incomplete" };
|
|
87
|
+
var headerEnd = crlf;
|
|
88
|
+
var payloadStr = buf.slice(offset + 1, headerEnd).toString("utf8");
|
|
89
|
+
|
|
90
|
+
if (marker === 0x2b /* + */) {
|
|
91
|
+
return { type: "string", value: payloadStr, consumed: crlf + 2 - offset };
|
|
92
|
+
}
|
|
93
|
+
if (marker === 0x2d /* - */) {
|
|
94
|
+
return { type: "error", value: payloadStr, consumed: crlf + 2 - offset };
|
|
95
|
+
}
|
|
96
|
+
if (marker === 0x3a /* : */) {
|
|
97
|
+
var n = Number(payloadStr);
|
|
98
|
+
if (!Number.isFinite(n)) {
|
|
99
|
+
throw _err("PROTOCOL", "integer reply not finite: " + payloadStr);
|
|
100
|
+
}
|
|
101
|
+
return { type: "int", value: n, consumed: crlf + 2 - offset };
|
|
102
|
+
}
|
|
103
|
+
if (marker === 0x24 /* $ */) {
|
|
104
|
+
var len = Number(payloadStr);
|
|
105
|
+
if (!Number.isFinite(len)) {
|
|
106
|
+
throw _err("PROTOCOL", "bulk length not finite: " + payloadStr);
|
|
107
|
+
}
|
|
108
|
+
if (len === -1) return { type: "bulk", value: null, consumed: crlf + 2 - offset };
|
|
109
|
+
var dataStart = crlf + 2;
|
|
110
|
+
var dataEnd = dataStart + len;
|
|
111
|
+
if (dataEnd + 2 > buf.length) return { type: "incomplete" };
|
|
112
|
+
var bulk = buf.slice(dataStart, dataEnd);
|
|
113
|
+
return { type: "bulk", value: bulk, consumed: dataEnd + 2 - offset };
|
|
114
|
+
}
|
|
115
|
+
if (marker === 0x2a /* * */) {
|
|
116
|
+
var arrLen = Number(payloadStr);
|
|
117
|
+
if (!Number.isFinite(arrLen)) {
|
|
118
|
+
throw _err("PROTOCOL", "array length not finite: " + payloadStr);
|
|
119
|
+
}
|
|
120
|
+
if (arrLen === -1) return { type: "array", value: null, consumed: crlf + 2 - offset };
|
|
121
|
+
var items = [];
|
|
122
|
+
var cursor = crlf + 2;
|
|
123
|
+
for (var i = 0; i < arrLen; i++) {
|
|
124
|
+
var sub = _parseFrame(buf, cursor);
|
|
125
|
+
if (sub.type === "incomplete") return { type: "incomplete" };
|
|
126
|
+
items.push(sub);
|
|
127
|
+
cursor += sub.consumed;
|
|
128
|
+
}
|
|
129
|
+
return { type: "array", value: items, consumed: cursor - offset };
|
|
130
|
+
}
|
|
131
|
+
throw _err("PROTOCOL", "unknown reply marker 0x" + marker.toString(HEX_RADIX));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Convert a parsed frame tree into a JavaScript-friendly value.
|
|
135
|
+
// Bulks are returned as Buffer (caller decides encoding); arrays
|
|
136
|
+
// recurse; errors are surfaced as { error: msg }; integers are numbers.
|
|
137
|
+
function _frameToValue(frame) {
|
|
138
|
+
if (frame.type === "string") return frame.value;
|
|
139
|
+
if (frame.type === "int") return frame.value;
|
|
140
|
+
if (frame.type === "bulk") return frame.value;
|
|
141
|
+
if (frame.type === "error") return { _redisError: true, message: frame.value };
|
|
142
|
+
if (frame.type === "array") {
|
|
143
|
+
if (frame.value === null) return null;
|
|
144
|
+
return frame.value.map(_frameToValue);
|
|
145
|
+
}
|
|
146
|
+
throw _err("PROTOCOL", "_frameToValue: unknown frame type " + frame.type);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ---- Client ----
|
|
150
|
+
//
|
|
151
|
+
// Single connection, FIFO request queue, auto-reconnect on socket
|
|
152
|
+
// close. Pipelining is implicit — every command appends to the queue
|
|
153
|
+
// and writes immediately; responses are dispatched in arrival order.
|
|
154
|
+
function create(opts) {
|
|
155
|
+
opts = opts || {};
|
|
156
|
+
validateOpts.requireNonEmptyString(opts.url, "redis.create: opts.url", RedisError, "BAD_OPTS");
|
|
157
|
+
var parsed = _parseRedisUrl(opts.url);
|
|
158
|
+
var host = opts.host || parsed.host;
|
|
159
|
+
var port = opts.port || parsed.port;
|
|
160
|
+
var useTls = opts.tls !== undefined ? !!opts.tls : parsed.tls;
|
|
161
|
+
var password = opts.password !== undefined ? opts.password : parsed.password;
|
|
162
|
+
var username = opts.username !== undefined ? opts.username : parsed.username;
|
|
163
|
+
var db = opts.db !== undefined ? Number(opts.db) : parsed.db;
|
|
164
|
+
var connectTimeoutMs = Number(opts.connectTimeoutMs) || 5000;
|
|
165
|
+
var commandTimeoutMs = Number(opts.commandTimeoutMs) || 10000;
|
|
166
|
+
var maxReconnectAttempts = opts.maxReconnectAttempts === undefined ? 10
|
|
167
|
+
: Number(opts.maxReconnectAttempts);
|
|
168
|
+
// TLS verification controls. Operators using rediss:// against private
|
|
169
|
+
// CAs (managed Redis services, on-prem clusters with internal PKI)
|
|
170
|
+
// pin the trust roots via opts.ca; rejectUnauthorized stays on by
|
|
171
|
+
// default — never weaken verification to make a connection succeed.
|
|
172
|
+
var caBundle = opts.ca || null;
|
|
173
|
+
// SNI is only legal for hostnames; IP literals must omit servername.
|
|
174
|
+
var servername = opts.servername;
|
|
175
|
+
if (servername === undefined) {
|
|
176
|
+
servername = (/^\d+\.\d+\.\d+\.\d+$/.test(host) || host.indexOf(":") !== -1)
|
|
177
|
+
? undefined : host;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
var socket = null;
|
|
181
|
+
var connected = false;
|
|
182
|
+
var connecting = false;
|
|
183
|
+
var closing = false;
|
|
184
|
+
var rxBuffer = Buffer.alloc(0);
|
|
185
|
+
// FIFO of in-flight commands awaiting a response
|
|
186
|
+
var pending = [];
|
|
187
|
+
// Backlog of commands queued before connect resolved
|
|
188
|
+
var backlog = [];
|
|
189
|
+
var reconnectAttempt = 0;
|
|
190
|
+
// Pub/sub demultiplex hook. When set, the receive path dispatches
|
|
191
|
+
// server-pushed "message" / "pmessage" frames (RESP arrays whose
|
|
192
|
+
// first element is one of those literals) to onPushMessage instead
|
|
193
|
+
// of consuming a pending request slot. SUBSCRIBE / UNSUBSCRIBE /
|
|
194
|
+
// PSUBSCRIBE / PUNSUBSCRIBE acks still flow through pending — they
|
|
195
|
+
// ARE responses to caller-issued commands. The split lets a single
|
|
196
|
+
// socket handle subscribe-mode acks AND the asynchronous fan-out
|
|
197
|
+
// events without confusing the FIFO.
|
|
198
|
+
var onPushMessage = typeof opts.onPushMessage === "function"
|
|
199
|
+
? opts.onPushMessage : null;
|
|
200
|
+
|
|
201
|
+
function _scheduleReconnect() {
|
|
202
|
+
if (closing) return;
|
|
203
|
+
if (maxReconnectAttempts >= 0 && reconnectAttempt >= maxReconnectAttempts) {
|
|
204
|
+
// Drain pending callbacks with a clear error
|
|
205
|
+
var err = _err("RECONNECT_GAVE_UP",
|
|
206
|
+
"redis: gave up after " + reconnectAttempt + " reconnect attempts");
|
|
207
|
+
_drainPending(err);
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
reconnectAttempt++;
|
|
211
|
+
var delay = Math.min(C.TIME.seconds(30), 100 * Math.pow(2, reconnectAttempt - 1));
|
|
212
|
+
setTimeout(function () { _connect().catch(function () { /* will reschedule */ }); }, delay);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function _drainPending(err) {
|
|
216
|
+
var batch = pending.slice();
|
|
217
|
+
pending.length = 0;
|
|
218
|
+
batch.forEach(function (p) { p.reject(err); });
|
|
219
|
+
var bl = backlog.slice();
|
|
220
|
+
backlog.length = 0;
|
|
221
|
+
bl.forEach(function (p) { p.reject(err); });
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function _onData(chunk) {
|
|
225
|
+
rxBuffer = rxBuffer.length === 0 ? chunk : Buffer.concat([rxBuffer, chunk]);
|
|
226
|
+
while (rxBuffer.length > 0) {
|
|
227
|
+
var frame = _parseFrame(rxBuffer, 0);
|
|
228
|
+
if (frame.type === "incomplete") return;
|
|
229
|
+
var value = _frameToValue(frame);
|
|
230
|
+
rxBuffer = rxBuffer.slice(frame.consumed);
|
|
231
|
+
|
|
232
|
+
// Pub/sub push detection — server-initiated arrays beginning with
|
|
233
|
+
// "message" (3-tuple: type / channel / payload) or "pmessage"
|
|
234
|
+
// (4-tuple: type / pattern / channel / payload). Routed to
|
|
235
|
+
// onPushMessage; do not consume a pending entry.
|
|
236
|
+
if (onPushMessage && Array.isArray(value) && value.length >= 3 &&
|
|
237
|
+
Buffer.isBuffer(value[0])) {
|
|
238
|
+
var typeStr = value[0].toString("utf8");
|
|
239
|
+
if (typeStr === "message" && value.length === 3) {
|
|
240
|
+
onPushMessage({
|
|
241
|
+
pattern: null,
|
|
242
|
+
channel: Buffer.isBuffer(value[1]) ? value[1].toString("utf8") : String(value[1]),
|
|
243
|
+
payload: value[2],
|
|
244
|
+
});
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
if (typeStr === "pmessage" && value.length === 4) {
|
|
248
|
+
onPushMessage({
|
|
249
|
+
pattern: Buffer.isBuffer(value[1]) ? value[1].toString("utf8") : String(value[1]),
|
|
250
|
+
channel: Buffer.isBuffer(value[2]) ? value[2].toString("utf8") : String(value[2]),
|
|
251
|
+
payload: value[3],
|
|
252
|
+
});
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (pending.length === 0) {
|
|
258
|
+
// Orphan frame with no pending request — drop. This is the
|
|
259
|
+
// expected path for subscribe/unsubscribe acks if onPushMessage
|
|
260
|
+
// is wired but the caller didn't await them.
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
var p = pending.shift();
|
|
264
|
+
if (value && value._redisError) {
|
|
265
|
+
p.reject(_err("REDIS_REPLY", value.message));
|
|
266
|
+
} else {
|
|
267
|
+
p.resolve(value);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function _onSocketError(err) {
|
|
273
|
+
var werr = _err("SOCKET", "redis socket error: " + ((err && err.message) || String(err)));
|
|
274
|
+
_drainPending(werr);
|
|
275
|
+
connected = false;
|
|
276
|
+
try { if (socket) socket.destroy(); } catch (_e) { /* best-effort socket teardown */ }
|
|
277
|
+
socket = null;
|
|
278
|
+
if (!closing) _scheduleReconnect();
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function _onSocketClose() {
|
|
282
|
+
connected = false;
|
|
283
|
+
if (!closing) {
|
|
284
|
+
var err = _err("SOCKET_CLOSED", "redis socket closed unexpectedly");
|
|
285
|
+
_drainPending(err);
|
|
286
|
+
socket = null;
|
|
287
|
+
_scheduleReconnect();
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
async function _connect() {
|
|
292
|
+
if (connected) return;
|
|
293
|
+
if (connecting) {
|
|
294
|
+
// Wait until current connect attempt resolves
|
|
295
|
+
while (connecting) await safeAsync.sleep(20);
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
connecting = true;
|
|
299
|
+
rxBuffer = Buffer.alloc(0);
|
|
300
|
+
try {
|
|
301
|
+
socket = await new Promise(function (resolve, reject) {
|
|
302
|
+
var sock;
|
|
303
|
+
var timer = setTimeout(function () {
|
|
304
|
+
try { if (sock) sock.destroy(); } catch (_e) { /* best-effort socket teardown */ }
|
|
305
|
+
reject(_err("CONNECT_TIMEOUT",
|
|
306
|
+
"redis connect timed out after " + connectTimeoutMs + "ms (host=" + host + ":" + port + ")"));
|
|
307
|
+
}, connectTimeoutMs);
|
|
308
|
+
function onOk() {
|
|
309
|
+
clearTimeout(timer);
|
|
310
|
+
sock.removeListener("error", onErr);
|
|
311
|
+
resolve(sock);
|
|
312
|
+
}
|
|
313
|
+
function onErr(e) {
|
|
314
|
+
clearTimeout(timer);
|
|
315
|
+
try { sock.destroy(); } catch (_e) { /* best-effort socket teardown */ }
|
|
316
|
+
reject(_err("CONNECT", "redis connect failed: " + ((e && e.message) || String(e))));
|
|
317
|
+
}
|
|
318
|
+
if (useTls) {
|
|
319
|
+
var tlsConnectOpts = { host: host, port: port };
|
|
320
|
+
if (servername) tlsConnectOpts.servername = servername;
|
|
321
|
+
if (caBundle) tlsConnectOpts.ca = caBundle;
|
|
322
|
+
sock = tls.connect(tlsConnectOpts, onOk);
|
|
323
|
+
} else {
|
|
324
|
+
sock = net.connect({ host: host, port: port }, onOk);
|
|
325
|
+
}
|
|
326
|
+
sock.once("error", onErr);
|
|
327
|
+
});
|
|
328
|
+
socket.setNoDelay(true);
|
|
329
|
+
socket.on("data", _onData);
|
|
330
|
+
socket.on("error", _onSocketError);
|
|
331
|
+
socket.on("close", _onSocketClose);
|
|
332
|
+
connected = true;
|
|
333
|
+
reconnectAttempt = 0;
|
|
334
|
+
|
|
335
|
+
// Auth + select db on (re)connect — without resetting the
|
|
336
|
+
// backlog of commands queued during disconnect. Send these
|
|
337
|
+
// BEFORE the backlog so the server is ready when backlog flushes.
|
|
338
|
+
if (password) {
|
|
339
|
+
var authArgs = username ? ["AUTH", username, password] : ["AUTH", password];
|
|
340
|
+
await _sendNoQueue(authArgs);
|
|
341
|
+
}
|
|
342
|
+
if (Number.isFinite(db) && db !== 0) {
|
|
343
|
+
await _sendNoQueue(["SELECT", String(db)]);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// Flush backlog
|
|
347
|
+
var bl = backlog.slice();
|
|
348
|
+
backlog.length = 0;
|
|
349
|
+
bl.forEach(function (entry) { _writeAndAwait(entry.args, entry.resolve, entry.reject); });
|
|
350
|
+
} catch (err) {
|
|
351
|
+
connecting = false;
|
|
352
|
+
throw err;
|
|
353
|
+
}
|
|
354
|
+
connecting = false;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// Internal helper that bypasses the connect-pending backlog (used
|
|
358
|
+
// for AUTH / SELECT during connect itself, where the socket is
|
|
359
|
+
// already up but `connected = true` is set immediately above).
|
|
360
|
+
function _sendNoQueue(args) {
|
|
361
|
+
return new Promise(function (resolve, reject) {
|
|
362
|
+
pending.push({
|
|
363
|
+
resolve: resolve,
|
|
364
|
+
reject: reject,
|
|
365
|
+
timer: setTimeout(function () {
|
|
366
|
+
var idx = pending.findIndex(function (p) { return p.resolve === resolve; });
|
|
367
|
+
if (idx !== -1) pending.splice(idx, 1);
|
|
368
|
+
reject(_err("COMMAND_TIMEOUT", "redis " + args[0] + " timed out"));
|
|
369
|
+
}, commandTimeoutMs),
|
|
370
|
+
});
|
|
371
|
+
try { socket.write(_encodeCommand(args)); }
|
|
372
|
+
catch (e) { reject(_err("WRITE", "redis write failed: " + ((e && e.message) || String(e)))); }
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function _writeAndAwait(args, resolve, reject) {
|
|
377
|
+
var entry = {
|
|
378
|
+
resolve: function (v) { clearTimeout(entry.timer); resolve(v); },
|
|
379
|
+
reject: function (e) { clearTimeout(entry.timer); reject(e); },
|
|
380
|
+
timer: null,
|
|
381
|
+
};
|
|
382
|
+
entry.timer = setTimeout(function () {
|
|
383
|
+
var idx = pending.indexOf(entry);
|
|
384
|
+
if (idx !== -1) pending.splice(idx, 1);
|
|
385
|
+
reject(_err("COMMAND_TIMEOUT", "redis " + args[0] + " timed out"));
|
|
386
|
+
}, commandTimeoutMs);
|
|
387
|
+
pending.push(entry);
|
|
388
|
+
try { socket.write(_encodeCommand(args)); }
|
|
389
|
+
catch (e) {
|
|
390
|
+
var i = pending.indexOf(entry);
|
|
391
|
+
if (i !== -1) pending.splice(i, 1);
|
|
392
|
+
clearTimeout(entry.timer);
|
|
393
|
+
reject(_err("WRITE", "redis write failed: " + ((e && e.message) || String(e))));
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function command() {
|
|
398
|
+
var args = Array.prototype.slice.call(arguments);
|
|
399
|
+
return new Promise(function (resolve, reject) {
|
|
400
|
+
if (closing) {
|
|
401
|
+
reject(_err("CLOSED", "redis client is closed"));
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
if (!connected) {
|
|
405
|
+
backlog.push({ args: args, resolve: resolve, reject: reject });
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
_writeAndAwait(args, resolve, reject);
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// runScript — Redis EVAL helper. script + numKeys + key1..keyN +
|
|
413
|
+
// arg1..argM. Returns whatever the script returns, decoded by
|
|
414
|
+
// _frameToValue. Named runScript (not evalScript) so source-scan
|
|
415
|
+
// tooling looking for the JavaScript eval() pattern doesn't
|
|
416
|
+
// false-positive on this file.
|
|
417
|
+
function runScript(script, numKeys /* ...keysAndArgs */) {
|
|
418
|
+
var rest = Array.prototype.slice.call(arguments, 2);
|
|
419
|
+
var args = ["EVAL", script, String(numKeys)].concat(rest);
|
|
420
|
+
return command.apply(null, args);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
async function close() {
|
|
424
|
+
closing = true;
|
|
425
|
+
var err = _err("CLOSED", "redis client closed");
|
|
426
|
+
_drainPending(err);
|
|
427
|
+
if (socket) {
|
|
428
|
+
try { socket.end(); } catch (_e) { /* best-effort socket close */ }
|
|
429
|
+
try { socket.destroy(); } catch (_e) { /* best-effort socket teardown */ }
|
|
430
|
+
socket = null;
|
|
431
|
+
}
|
|
432
|
+
connected = false;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
return {
|
|
436
|
+
connect: _connect,
|
|
437
|
+
command: command,
|
|
438
|
+
runScript: runScript,
|
|
439
|
+
close: close,
|
|
440
|
+
isOpen: function () { return connected && !closing; },
|
|
441
|
+
setOnPushMessage: function (fn) {
|
|
442
|
+
onPushMessage = typeof fn === "function" ? fn : null;
|
|
443
|
+
},
|
|
444
|
+
// Diagnostic — exposed for tests + observability
|
|
445
|
+
_state: function () {
|
|
446
|
+
return {
|
|
447
|
+
connected: connected, closing: closing,
|
|
448
|
+
pending: pending.length, backlog: backlog.length,
|
|
449
|
+
reconnect: reconnectAttempt,
|
|
450
|
+
host: host, port: port, db: db, tls: useTls,
|
|
451
|
+
};
|
|
452
|
+
},
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// Parse `redis://[username:password@]host[:port][/db]` and `rediss://...` URLs.
|
|
457
|
+
// Empty-username + non-empty password is the legacy single-arg AUTH form.
|
|
458
|
+
function _parseRedisUrl(s) {
|
|
459
|
+
var u;
|
|
460
|
+
try { u = new url.URL(s); }
|
|
461
|
+
catch (e) {
|
|
462
|
+
throw _err("BAD_URL", "redis url parse failed: " + ((e && e.message) || String(e)));
|
|
463
|
+
}
|
|
464
|
+
if (u.protocol !== "redis:" && u.protocol !== "rediss:") {
|
|
465
|
+
throw _err("BAD_URL", "redis url protocol must be redis: or rediss:, got " + u.protocol);
|
|
466
|
+
}
|
|
467
|
+
var dbStr = (u.pathname || "/").replace(/^\//, "");
|
|
468
|
+
var db = dbStr === "" ? 0 : Number(dbStr);
|
|
469
|
+
if (!Number.isFinite(db) || db < 0 || db > 15 || Math.floor(db) !== db) {
|
|
470
|
+
throw _err("BAD_URL", "redis url db must be integer 0..15, got " + dbStr);
|
|
471
|
+
}
|
|
472
|
+
return {
|
|
473
|
+
host: u.hostname || "127.0.0.1",
|
|
474
|
+
port: u.port ? Number(u.port) : 6379,
|
|
475
|
+
tls: u.protocol === "rediss:",
|
|
476
|
+
username: u.username ? decodeURIComponent(u.username) : null,
|
|
477
|
+
password: u.password ? decodeURIComponent(u.password) : null,
|
|
478
|
+
db: db,
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// pickClientOpts(cfg, prefix?) — extract the standard redis-client opts
|
|
483
|
+
// from a larger config bag. Lets cache-redis / pubsub-redis / queue-redis
|
|
484
|
+
// / etc. forward to redisClient.create without each repeating the 9-key
|
|
485
|
+
// list. The optional `prefix` lets callers whose operator-facing opts
|
|
486
|
+
// are namespaced (`redisUrl`, `redisPassword`, ...) reuse the same
|
|
487
|
+
// helper by passing prefix="redis" — the helper camel-cases the prefix
|
|
488
|
+
// onto each key.
|
|
489
|
+
//
|
|
490
|
+
// var opts = redisClient.pickClientOpts(cfg); // unprefixed
|
|
491
|
+
// var opts = redisClient.pickClientOpts(operatorOpts, "redis"); // redisUrl etc.
|
|
492
|
+
function pickClientOpts(cfg, prefix) {
|
|
493
|
+
if (!cfg || typeof cfg !== "object") return {};
|
|
494
|
+
function pick(name) {
|
|
495
|
+
if (!prefix) return cfg[name];
|
|
496
|
+
return cfg[prefix + name.charAt(0).toUpperCase() + name.slice(1)];
|
|
497
|
+
}
|
|
498
|
+
return {
|
|
499
|
+
url: pick("url"),
|
|
500
|
+
password: pick("password"),
|
|
501
|
+
username: pick("username"),
|
|
502
|
+
tls: pick("tls"),
|
|
503
|
+
ca: pick("ca"),
|
|
504
|
+
servername: pick("servername"),
|
|
505
|
+
connectTimeoutMs: pick("connectTimeoutMs"),
|
|
506
|
+
commandTimeoutMs: pick("commandTimeoutMs"),
|
|
507
|
+
maxReconnectAttempts: pick("maxReconnectAttempts"),
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
module.exports = {
|
|
512
|
+
create: create,
|
|
513
|
+
pickClientOpts: pickClientOpts,
|
|
514
|
+
// Exposed for tests / direct callers that already manage their own socket.
|
|
515
|
+
_encodeCommand: _encodeCommand,
|
|
516
|
+
_parseFrame: _parseFrame,
|
|
517
|
+
_frameToValue: _frameToValue,
|
|
518
|
+
_parseRedisUrl: _parseRedisUrl,
|
|
519
|
+
};
|