@blamejs/core 0.6.25 → 0.6.27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,427 @@
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 safeAsync = require("./safe-async");
31
+ var { RedisError } = require("./framework-error");
32
+
33
+ var _err = RedisError.factory;
34
+
35
+ // ---- Wire-format encoder ----
36
+ //
37
+ // RESP2 inline command form for arbitrary args:
38
+ // *<argc>\r\n
39
+ // $<arglen>\r\n<argbytes>\r\n
40
+ // ... repeat per arg ...
41
+ function _encodeCommand(args) {
42
+ if (!Array.isArray(args) || args.length === 0) {
43
+ throw _err("BAD_ARGS", "encodeCommand: args must be a non-empty array");
44
+ }
45
+ var parts = ["*" + args.length + "\r\n"];
46
+ for (var i = 0; i < args.length; i++) {
47
+ var a = args[i];
48
+ var buf;
49
+ if (Buffer.isBuffer(a)) {
50
+ buf = a;
51
+ } else if (a === null || a === undefined) {
52
+ throw _err("BAD_ARGS", "encodeCommand: arg " + i + " is null/undefined");
53
+ } else {
54
+ buf = Buffer.from(String(a), "utf8");
55
+ }
56
+ parts.push("$" + buf.length + "\r\n");
57
+ parts.push(buf);
58
+ parts.push("\r\n");
59
+ }
60
+ // Concat as Buffer — supports binary args (sealed payloads etc.)
61
+ var bufs = parts.map(function (p) {
62
+ return Buffer.isBuffer(p) ? p : Buffer.from(p, "utf8");
63
+ });
64
+ return Buffer.concat(bufs);
65
+ }
66
+
67
+ // ---- Wire-format decoder ----
68
+ //
69
+ // Stateful streaming parser. Returns one of:
70
+ // { type: "incomplete" } — need more bytes
71
+ // { type: "string", value, consumed } — simple string (+OK)
72
+ // { type: "error", value, consumed } — error line (-ERR ...)
73
+ // { type: "int", value, consumed } — integer (:42)
74
+ // { type: "bulk", value, consumed } — bulk string buffer (or null)
75
+ // { type: "array", value, consumed } — array of decoded items
76
+ function _parseFrame(buf, offset) {
77
+ if (offset >= buf.length) return { type: "incomplete" };
78
+ var marker = buf[offset];
79
+ // Find next CRLF after the marker
80
+ var crlf = buf.indexOf("\r\n", offset + 1);
81
+ if (crlf === -1) return { type: "incomplete" };
82
+ var headerEnd = crlf;
83
+ var payloadStr = buf.slice(offset + 1, headerEnd).toString("utf8");
84
+
85
+ if (marker === 0x2b /* + */) {
86
+ return { type: "string", value: payloadStr, consumed: crlf + 2 - offset };
87
+ }
88
+ if (marker === 0x2d /* - */) {
89
+ return { type: "error", value: payloadStr, consumed: crlf + 2 - offset };
90
+ }
91
+ if (marker === 0x3a /* : */) {
92
+ var n = Number(payloadStr);
93
+ if (!Number.isFinite(n)) {
94
+ throw _err("PROTOCOL", "integer reply not finite: " + payloadStr);
95
+ }
96
+ return { type: "int", value: n, consumed: crlf + 2 - offset };
97
+ }
98
+ if (marker === 0x24 /* $ */) {
99
+ var len = Number(payloadStr);
100
+ if (!Number.isFinite(len)) {
101
+ throw _err("PROTOCOL", "bulk length not finite: " + payloadStr);
102
+ }
103
+ if (len === -1) return { type: "bulk", value: null, consumed: crlf + 2 - offset };
104
+ var dataStart = crlf + 2;
105
+ var dataEnd = dataStart + len;
106
+ if (dataEnd + 2 > buf.length) return { type: "incomplete" };
107
+ var bulk = buf.slice(dataStart, dataEnd);
108
+ return { type: "bulk", value: bulk, consumed: dataEnd + 2 - offset };
109
+ }
110
+ if (marker === 0x2a /* * */) {
111
+ var arrLen = Number(payloadStr);
112
+ if (!Number.isFinite(arrLen)) {
113
+ throw _err("PROTOCOL", "array length not finite: " + payloadStr);
114
+ }
115
+ if (arrLen === -1) return { type: "array", value: null, consumed: crlf + 2 - offset };
116
+ var items = [];
117
+ var cursor = crlf + 2;
118
+ for (var i = 0; i < arrLen; i++) {
119
+ var sub = _parseFrame(buf, cursor);
120
+ if (sub.type === "incomplete") return { type: "incomplete" };
121
+ items.push(sub);
122
+ cursor += sub.consumed;
123
+ }
124
+ return { type: "array", value: items, consumed: cursor - offset };
125
+ }
126
+ throw _err("PROTOCOL", "unknown reply marker 0x" + marker.toString(16));
127
+ }
128
+
129
+ // Convert a parsed frame tree into a JavaScript-friendly value.
130
+ // Bulks are returned as Buffer (caller decides encoding); arrays
131
+ // recurse; errors are surfaced as { error: msg }; integers are numbers.
132
+ function _frameToValue(frame) {
133
+ if (frame.type === "string") return frame.value;
134
+ if (frame.type === "int") return frame.value;
135
+ if (frame.type === "bulk") return frame.value;
136
+ if (frame.type === "error") return { _redisError: true, message: frame.value };
137
+ if (frame.type === "array") {
138
+ if (frame.value === null) return null;
139
+ return frame.value.map(_frameToValue);
140
+ }
141
+ throw _err("PROTOCOL", "_frameToValue: unknown frame type " + frame.type);
142
+ }
143
+
144
+ // ---- Client ----
145
+ //
146
+ // Single connection, FIFO request queue, auto-reconnect on socket
147
+ // close. Pipelining is implicit — every command appends to the queue
148
+ // and writes immediately; responses are dispatched in arrival order.
149
+ function create(opts) {
150
+ opts = opts || {};
151
+ if (typeof opts.url !== "string" || opts.url.length === 0) {
152
+ throw _err("BAD_OPTS", "redis.create({ url }) is required");
153
+ }
154
+ var parsed = _parseRedisUrl(opts.url);
155
+ var host = opts.host || parsed.host;
156
+ var port = opts.port || parsed.port;
157
+ var useTls = opts.tls !== undefined ? !!opts.tls : parsed.tls;
158
+ var password = opts.password !== undefined ? opts.password : parsed.password;
159
+ var username = opts.username !== undefined ? opts.username : parsed.username;
160
+ var db = opts.db !== undefined ? Number(opts.db) : parsed.db;
161
+ var connectTimeoutMs = Number(opts.connectTimeoutMs) || 5000;
162
+ var commandTimeoutMs = Number(opts.commandTimeoutMs) || 10000;
163
+ var maxReconnectAttempts = opts.maxReconnectAttempts === undefined ? 10
164
+ : Number(opts.maxReconnectAttempts);
165
+
166
+ var socket = null;
167
+ var connected = false;
168
+ var connecting = false;
169
+ var closing = false;
170
+ var rxBuffer = Buffer.alloc(0);
171
+ // FIFO of in-flight commands awaiting a response
172
+ var pending = [];
173
+ // Backlog of commands queued before connect resolved
174
+ var backlog = [];
175
+ var reconnectAttempt = 0;
176
+
177
+ function _scheduleReconnect() {
178
+ if (closing) return;
179
+ if (maxReconnectAttempts >= 0 && reconnectAttempt >= maxReconnectAttempts) {
180
+ // Drain pending callbacks with a clear error
181
+ var err = _err("RECONNECT_GAVE_UP",
182
+ "redis: gave up after " + reconnectAttempt + " reconnect attempts");
183
+ _drainPending(err);
184
+ return;
185
+ }
186
+ reconnectAttempt++;
187
+ var delay = Math.min(30000, 100 * Math.pow(2, reconnectAttempt - 1));
188
+ setTimeout(function () { _connect().catch(function () { /* will reschedule */ }); }, delay);
189
+ }
190
+
191
+ function _drainPending(err) {
192
+ var batch = pending.slice();
193
+ pending.length = 0;
194
+ batch.forEach(function (p) { p.reject(err); });
195
+ var bl = backlog.slice();
196
+ backlog.length = 0;
197
+ bl.forEach(function (p) { p.reject(err); });
198
+ }
199
+
200
+ function _onData(chunk) {
201
+ rxBuffer = rxBuffer.length === 0 ? chunk : Buffer.concat([rxBuffer, chunk]);
202
+ while (pending.length > 0 && rxBuffer.length > 0) {
203
+ var frame = _parseFrame(rxBuffer, 0);
204
+ if (frame.type === "incomplete") return;
205
+ var value = _frameToValue(frame);
206
+ rxBuffer = rxBuffer.slice(frame.consumed);
207
+ var p = pending.shift();
208
+ if (value && value._redisError) {
209
+ p.reject(_err("REDIS_REPLY", value.message));
210
+ } else {
211
+ p.resolve(value);
212
+ }
213
+ }
214
+ }
215
+
216
+ function _onSocketError(err) {
217
+ var werr = _err("SOCKET", "redis socket error: " + ((err && err.message) || String(err)));
218
+ _drainPending(werr);
219
+ connected = false;
220
+ try { if (socket) socket.destroy(); } catch (_e) {}
221
+ socket = null;
222
+ if (!closing) _scheduleReconnect();
223
+ }
224
+
225
+ function _onSocketClose() {
226
+ connected = false;
227
+ if (!closing) {
228
+ var err = _err("SOCKET_CLOSED", "redis socket closed unexpectedly");
229
+ _drainPending(err);
230
+ socket = null;
231
+ _scheduleReconnect();
232
+ }
233
+ }
234
+
235
+ async function _connect() {
236
+ if (connected) return;
237
+ if (connecting) {
238
+ // Wait until current connect attempt resolves
239
+ while (connecting) await safeAsync.sleep(20);
240
+ return;
241
+ }
242
+ connecting = true;
243
+ rxBuffer = Buffer.alloc(0);
244
+ try {
245
+ socket = await new Promise(function (resolve, reject) {
246
+ var sock;
247
+ var timer = setTimeout(function () {
248
+ try { if (sock) sock.destroy(); } catch (_e) {}
249
+ reject(_err("CONNECT_TIMEOUT",
250
+ "redis connect timed out after " + connectTimeoutMs + "ms (host=" + host + ":" + port + ")"));
251
+ }, connectTimeoutMs);
252
+ function onOk() {
253
+ clearTimeout(timer);
254
+ sock.removeListener("error", onErr);
255
+ resolve(sock);
256
+ }
257
+ function onErr(e) {
258
+ clearTimeout(timer);
259
+ try { sock.destroy(); } catch (_e) {}
260
+ reject(_err("CONNECT", "redis connect failed: " + ((e && e.message) || String(e))));
261
+ }
262
+ if (useTls) {
263
+ sock = tls.connect({ host: host, port: port, servername: host }, onOk);
264
+ } else {
265
+ sock = net.connect({ host: host, port: port }, onOk);
266
+ }
267
+ sock.once("error", onErr);
268
+ });
269
+ socket.setNoDelay(true);
270
+ socket.on("data", _onData);
271
+ socket.on("error", _onSocketError);
272
+ socket.on("close", _onSocketClose);
273
+ connected = true;
274
+ reconnectAttempt = 0;
275
+
276
+ // Auth + select db on (re)connect — without resetting the
277
+ // backlog of commands queued during disconnect. Send these
278
+ // BEFORE the backlog so the server is ready when backlog flushes.
279
+ if (password) {
280
+ var authArgs = username ? ["AUTH", username, password] : ["AUTH", password];
281
+ await _sendNoQueue(authArgs);
282
+ }
283
+ if (Number.isFinite(db) && db !== 0) {
284
+ await _sendNoQueue(["SELECT", String(db)]);
285
+ }
286
+
287
+ // Flush backlog
288
+ var bl = backlog.slice();
289
+ backlog.length = 0;
290
+ bl.forEach(function (entry) { _writeAndAwait(entry.args, entry.resolve, entry.reject); });
291
+ } catch (err) {
292
+ connecting = false;
293
+ throw err;
294
+ }
295
+ connecting = false;
296
+ }
297
+
298
+ // Internal helper that bypasses the connect-pending backlog (used
299
+ // for AUTH / SELECT during connect itself, where the socket is
300
+ // already up but `connected = true` is set immediately above).
301
+ function _sendNoQueue(args) {
302
+ return new Promise(function (resolve, reject) {
303
+ pending.push({
304
+ resolve: resolve,
305
+ reject: reject,
306
+ timer: setTimeout(function () {
307
+ var idx = pending.findIndex(function (p) { return p.resolve === resolve; });
308
+ if (idx !== -1) pending.splice(idx, 1);
309
+ reject(_err("COMMAND_TIMEOUT", "redis " + args[0] + " timed out"));
310
+ }, commandTimeoutMs),
311
+ });
312
+ try { socket.write(_encodeCommand(args)); }
313
+ catch (e) { reject(_err("WRITE", "redis write failed: " + ((e && e.message) || String(e)))); }
314
+ });
315
+ }
316
+
317
+ function _writeAndAwait(args, resolve, reject) {
318
+ var entry = {
319
+ resolve: function (v) { clearTimeout(entry.timer); resolve(v); },
320
+ reject: function (e) { clearTimeout(entry.timer); reject(e); },
321
+ timer: null,
322
+ };
323
+ entry.timer = setTimeout(function () {
324
+ var idx = pending.indexOf(entry);
325
+ if (idx !== -1) pending.splice(idx, 1);
326
+ reject(_err("COMMAND_TIMEOUT", "redis " + args[0] + " timed out"));
327
+ }, commandTimeoutMs);
328
+ pending.push(entry);
329
+ try { socket.write(_encodeCommand(args)); }
330
+ catch (e) {
331
+ var i = pending.indexOf(entry);
332
+ if (i !== -1) pending.splice(i, 1);
333
+ clearTimeout(entry.timer);
334
+ reject(_err("WRITE", "redis write failed: " + ((e && e.message) || String(e))));
335
+ }
336
+ }
337
+
338
+ function command() {
339
+ var args = Array.prototype.slice.call(arguments);
340
+ return new Promise(function (resolve, reject) {
341
+ if (closing) {
342
+ reject(_err("CLOSED", "redis client is closed"));
343
+ return;
344
+ }
345
+ if (!connected) {
346
+ backlog.push({ args: args, resolve: resolve, reject: reject });
347
+ return;
348
+ }
349
+ _writeAndAwait(args, resolve, reject);
350
+ });
351
+ }
352
+
353
+ // runScript — Redis EVAL helper. script + numKeys + key1..keyN +
354
+ // arg1..argM. Returns whatever the script returns, decoded by
355
+ // _frameToValue. Named runScript (not evalScript) so source-scan
356
+ // tooling looking for the JavaScript eval() pattern doesn't
357
+ // false-positive on this file.
358
+ function runScript(script, numKeys /* ...keysAndArgs */) {
359
+ var rest = Array.prototype.slice.call(arguments, 2);
360
+ var args = ["EVAL", script, String(numKeys)].concat(rest);
361
+ return command.apply(null, args);
362
+ }
363
+
364
+ async function close() {
365
+ closing = true;
366
+ var err = _err("CLOSED", "redis client closed");
367
+ _drainPending(err);
368
+ if (socket) {
369
+ try { socket.end(); } catch (_e) {}
370
+ try { socket.destroy(); } catch (_e) {}
371
+ socket = null;
372
+ }
373
+ connected = false;
374
+ }
375
+
376
+ return {
377
+ connect: _connect,
378
+ command: command,
379
+ runScript: runScript,
380
+ close: close,
381
+ isOpen: function () { return connected && !closing; },
382
+ // Diagnostic — exposed for tests + observability
383
+ _state: function () {
384
+ return {
385
+ connected: connected, closing: closing,
386
+ pending: pending.length, backlog: backlog.length,
387
+ reconnect: reconnectAttempt,
388
+ host: host, port: port, db: db, tls: useTls,
389
+ };
390
+ },
391
+ };
392
+ }
393
+
394
+ // Parse `redis://[username:password@]host[:port][/db]` and `rediss://...` URLs.
395
+ // Empty-username + non-empty password is the legacy single-arg AUTH form.
396
+ function _parseRedisUrl(s) {
397
+ var u;
398
+ try { u = new url.URL(s); }
399
+ catch (e) {
400
+ throw _err("BAD_URL", "redis url parse failed: " + ((e && e.message) || String(e)));
401
+ }
402
+ if (u.protocol !== "redis:" && u.protocol !== "rediss:") {
403
+ throw _err("BAD_URL", "redis url protocol must be redis: or rediss:, got " + u.protocol);
404
+ }
405
+ var dbStr = (u.pathname || "/").replace(/^\//, "");
406
+ var db = dbStr === "" ? 0 : Number(dbStr);
407
+ if (!Number.isFinite(db) || db < 0 || db > 15 || Math.floor(db) !== db) {
408
+ throw _err("BAD_URL", "redis url db must be integer 0..15, got " + dbStr);
409
+ }
410
+ return {
411
+ host: u.hostname || "127.0.0.1",
412
+ port: u.port ? Number(u.port) : 6379,
413
+ tls: u.protocol === "rediss:",
414
+ username: u.username ? decodeURIComponent(u.username) : null,
415
+ password: u.password ? decodeURIComponent(u.password) : null,
416
+ db: db,
417
+ };
418
+ }
419
+
420
+ module.exports = {
421
+ create: create,
422
+ // Exposed for tests / direct callers that already manage their own socket.
423
+ _encodeCommand: _encodeCommand,
424
+ _parseFrame: _parseFrame,
425
+ _frameToValue: _frameToValue,
426
+ _parseRedisUrl: _parseRedisUrl,
427
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.6.25",
3
+ "version": "0.6.27",
4
4
  "description": "The Node framework that owns its stack.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",
@@ -2,10 +2,10 @@
2
2
  "$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
3
3
  "bomFormat": "CycloneDX",
4
4
  "specVersion": "1.5",
5
- "serialNumber": "urn:uuid:c8d4ecf3-0360-4291-bedc-af54ef2eca2a",
5
+ "serialNumber": "urn:uuid:2f9c38f8-03fa-4e5b-aa89-79d42c1df348",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-05-02T14:44:53.846Z",
8
+ "timestamp": "2026-05-02T15:55:03.075Z",
9
9
  "lifecycles": [
10
10
  {
11
11
  "phase": "build"
@@ -19,14 +19,14 @@
19
19
  }
20
20
  ],
21
21
  "component": {
22
- "bom-ref": "@blamejs/core@0.6.25",
22
+ "bom-ref": "@blamejs/core@0.6.27",
23
23
  "type": "library",
24
24
  "name": "blamejs",
25
- "version": "0.6.25",
25
+ "version": "0.6.27",
26
26
  "scope": "required",
27
27
  "author": "blamejs contributors",
28
28
  "description": "The Node framework that owns its stack.",
29
- "purl": "pkg:npm/%40blamejs/core@0.6.25",
29
+ "purl": "pkg:npm/%40blamejs/core@0.6.27",
30
30
  "properties": [],
31
31
  "externalReferences": [
32
32
  {
@@ -54,7 +54,7 @@
54
54
  "components": [],
55
55
  "dependencies": [
56
56
  {
57
- "ref": "@blamejs/core@0.6.25",
57
+ "ref": "@blamejs/core@0.6.27",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]