tina4-nodejs 3.13.43 → 3.13.44

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.
@@ -13,8 +13,8 @@
13
13
  * TINA4_SESSION_MONGO_DB (default: "tina4_sessions")
14
14
  * TINA4_SESSION_MONGO_COLLECTION (default: "sessions")
15
15
  */
16
- import { execFileSync } from "node:child_process";
17
16
  import type { SessionHandler } from "../session.js";
17
+ import { mongoCommandSync, type MongoTarget } from "./mongoClient.js";
18
18
 
19
19
  interface SessionData {
20
20
  _created: number;
@@ -85,221 +85,42 @@ export class MongoSessionHandler implements SessionHandler {
85
85
  ?? "sessions";
86
86
  }
87
87
 
88
- /**
89
- * Execute a MongoDB command synchronously via a child process.
90
- *
91
- * Uses the MongoDB wire protocol (OP_MSG) to communicate with the server.
92
- * For simplicity, we use the `runCommand` approach with JSON serialization
93
- * of BSON documents using a minimal BSON encoder.
94
- *
95
- * Returns the command response string (or `__EMPTY__` for an absent doc).
96
- * A transport/connection FAILURE (server unreachable, socket error, timeout)
97
- * THROWS so the Session boundary can distinguish "not found" (silent) from
98
- * "backend failed" (log-loud + degrade). Backend-failure policy parity.
99
- */
100
- private execSync(command: string, args: string): string {
101
- const script = `
102
- const net = require("node:net");
103
- const host = ${JSON.stringify(this.uri ? this.parseUri().host : this.host)};
104
- const port = ${this.uri ? this.parseUri().port : this.port};
105
- const database = ${JSON.stringify(this.database)};
106
- const collection = ${JSON.stringify(this.collection)};
107
- const command = ${JSON.stringify(command)};
108
- const args = ${JSON.stringify(args)};
109
-
110
- // Minimal BSON encoder/decoder for session operations
111
- function encodeBsonDocument(obj) {
112
- const parts = [];
113
- for (const [key, value] of Object.entries(obj)) {
114
- if (typeof value === "string") {
115
- const keyBuf = Buffer.from(key + "\\0", "utf-8");
116
- const valBuf = Buffer.from(value, "utf-8");
117
- const entry = Buffer.alloc(1 + keyBuf.length + 4 + valBuf.length + 1);
118
- entry.writeUInt8(2, 0); // string type
119
- keyBuf.copy(entry, 1);
120
- entry.writeInt32LE(valBuf.length + 1, 1 + keyBuf.length);
121
- valBuf.copy(entry, 1 + keyBuf.length + 4);
122
- entry.writeUInt8(0, entry.length - 1);
123
- parts.push(entry);
124
- } else if (typeof value === "number") {
125
- const keyBuf = Buffer.from(key + "\\0", "utf-8");
126
- if (Number.isInteger(value)) {
127
- const entry = Buffer.alloc(1 + keyBuf.length + 4);
128
- entry.writeUInt8(16, 0); // int32 type
129
- keyBuf.copy(entry, 1);
130
- entry.writeInt32LE(value, 1 + keyBuf.length);
131
- parts.push(entry);
132
- } else {
133
- const entry = Buffer.alloc(1 + keyBuf.length + 8);
134
- entry.writeUInt8(1, 0); // double type
135
- keyBuf.copy(entry, 1);
136
- entry.writeDoubleBE(value, 1 + keyBuf.length);
137
- parts.push(entry);
138
- }
139
- } else if (typeof value === "object" && value !== null) {
140
- const keyBuf = Buffer.from(key + "\\0", "utf-8");
141
- const subDoc = encodeBsonDocument(value);
142
- const entry = Buffer.alloc(1 + keyBuf.length + subDoc.length);
143
- entry.writeUInt8(3, 0); // document type
144
- keyBuf.copy(entry, 1);
145
- subDoc.copy(entry, 1 + keyBuf.length);
146
- parts.push(entry);
147
- }
148
- }
149
- const body = Buffer.concat(parts);
150
- const doc = Buffer.alloc(4 + body.length + 1);
151
- doc.writeInt32LE(doc.length, 0);
152
- body.copy(doc, 4);
153
- doc.writeUInt8(0, doc.length - 1);
154
- return doc;
155
- }
156
-
157
- // Use MongoDB OP_MSG (opcode 2013) with runCommand
158
- function buildOpMsg(dbName, cmd) {
159
- const cmdDoc = Object.assign({ "$db": dbName }, cmd);
160
- const body = encodeBsonDocument(cmdDoc);
161
-
162
- // OP_MSG: flagBits(4) + kind(1) + body
163
- const section = Buffer.alloc(1 + body.length);
164
- section.writeUInt8(0, 0); // kind 0 = body
165
- body.copy(section, 1);
166
-
167
- const msgHeader = Buffer.alloc(16 + 4 + section.length);
168
- msgHeader.writeInt32LE(msgHeader.length, 0); // messageLength
169
- msgHeader.writeInt32LE(1, 4); // requestID
170
- msgHeader.writeInt32LE(0, 8); // responseTo
171
- msgHeader.writeInt32LE(2013, 12); // opCode = OP_MSG
172
- msgHeader.writeUInt32LE(0, 16); // flagBits
173
- section.copy(msgHeader, 20);
174
-
175
- return msgHeader;
176
- }
177
-
178
- // For this simplified implementation, we use a TCP connection
179
- // and send/receive raw OP_MSG frames
180
- const sock = net.createConnection({ host, port }, () => {
181
- let cmd;
182
- const parsedArgs = JSON.parse(args);
183
-
184
- if (command === "find") {
185
- cmd = {
186
- find: collection,
187
- filter: parsedArgs.filter,
188
- limit: 1
189
- };
190
- } else if (command === "update") {
191
- cmd = {
192
- update: collection,
193
- updates: [{ q: parsedArgs.filter, u: { "$set": parsedArgs.data }, upsert: true }]
194
- };
195
- } else if (command === "delete") {
196
- cmd = {
197
- delete: collection,
198
- deletes: [{ q: parsedArgs.filter, limit: 1 }]
199
- };
200
- }
201
-
202
- const msg = buildOpMsg(database, cmd);
203
- sock.write(msg);
204
- });
205
-
206
- let buffer = Buffer.alloc(0);
207
- sock.on("data", (chunk) => {
208
- buffer = Buffer.concat([buffer, chunk]);
209
- if (buffer.length >= 4) {
210
- const msgLen = buffer.readInt32LE(0);
211
- if (buffer.length >= msgLen) {
212
- // Parse response — extract body section
213
- // Header is 16 bytes + 4 bytes flagBits + 1 byte section kind + BSON doc
214
- if (buffer.length > 21) {
215
- const bsonStart = 21;
216
- const bsonLen = buffer.readInt32LE(bsonStart);
217
- const bsonDoc = buffer.subarray(bsonStart, bsonStart + bsonLen);
218
-
219
- // Simple BSON parser — just look for the session data string
220
- const rawStr = bsonDoc.toString("utf-8", 4);
221
-
222
- if (command === "find") {
223
- // Look for cursor.firstBatch documents
224
- // For simplicity, extract JSON-like data from response
225
- process.stdout.write(rawStr);
226
- } else {
227
- process.stdout.write("__OK__");
228
- }
229
- } else {
230
- process.stdout.write("__EMPTY__");
231
- }
232
- sock.destroy();
233
- }
234
- }
235
- });
236
-
237
- sock.on("error", (err) => {
238
- process.stderr.write(err.message);
239
- process.exit(1);
240
- });
241
-
242
- setTimeout(() => { sock.destroy(); process.exit(1); }, 5000);
243
- `;
244
-
245
- try {
246
- return execFileSync(process.execPath, ["-e", script], {
247
- encoding: "utf-8",
248
- timeout: 8000,
249
- stdio: ["pipe", "pipe", "pipe"],
250
- });
251
- } catch (err) {
252
- // Non-zero exit = the child hit a socket error / timeout. That is a
253
- // transport FAILURE, not an absent document — surface it so the Session
254
- // boundary logs + degrades (or re-throws under strict mode).
255
- throw new Error(`MongoDB command failed: ${(err as Error).message}`);
256
- }
257
- }
258
-
259
- private parseUri(): { host: string; port: number } {
260
- if (!this.uri) return { host: this.host, port: this.port };
261
- try {
262
- // mongodb://host:port/db
263
- const match = this.uri.match(/mongodb:\/\/([^/:]+):?(\d+)?/);
88
+ /** Resolve the effective host/port (honours a configured mongodb:// URI). */
89
+ private target(): MongoTarget {
90
+ let host = this.host;
91
+ let port = this.port;
92
+ if (this.uri) {
93
+ const match = this.uri.match(/mongodb:\/\/(?:[^@/]+@)?([^/:]+):?(\d+)?/);
264
94
  if (match) {
265
- return {
266
- host: match[1],
267
- port: match[2] ? parseInt(match[2], 10) : 27017,
268
- };
95
+ host = match[1];
96
+ port = match[2] ? parseInt(match[2], 10) : 27017;
269
97
  }
270
- } catch { /* ignore */ }
271
- return { host: this.host, port: this.port };
98
+ }
99
+ return { host, port, database: this.database, collection: this.collection };
272
100
  }
273
101
 
274
102
  read(sessionId: string): SessionData | null {
275
- const result = this.execSync("find", JSON.stringify({
276
- filter: { _id: sessionId },
277
- }));
278
-
279
- if (!result || result === "__EMPTY__") return null;
280
-
281
- // Try to extract session data from the BSON response
103
+ const result = mongoCommandSync(this.target(), "find", { filter: { _id: sessionId } });
104
+ if (!result || result === "__EMPTY__") return null; // genuine miss
282
105
  try {
283
- // Look for the JSON data pattern in the response
284
- const dataMatch = result.match(/"data"\s*:\s*(\{[^}]+\})/);
285
- if (dataMatch) {
286
- return JSON.parse(dataMatch[1]) as SessionData;
287
- }
288
- } catch { /* ignore */ }
289
-
290
- return null;
106
+ const doc = JSON.parse(result) as { data?: unknown };
107
+ const data = doc?.data;
108
+ // Stored as a nested document (parity with the Python master); read returns it.
109
+ return data && typeof data === "object" ? (data as SessionData) : null;
110
+ } catch {
111
+ return null;
112
+ }
291
113
  }
292
114
 
293
115
  write(sessionId: string, data: SessionData, _ttl: number): void {
294
- this.execSync("update", JSON.stringify({
116
+ mongoCommandSync(this.target(), "update", {
295
117
  filter: { _id: sessionId },
296
- data: { _id: sessionId, data: JSON.stringify(data), updatedAt: new Date().toISOString() },
297
- }));
118
+ data,
119
+ last_accessed: Date.now() / 1000,
120
+ });
298
121
  }
299
122
 
300
123
  destroy(sessionId: string): void {
301
- this.execSync("delete", JSON.stringify({
302
- filter: { _id: sessionId },
303
- }));
124
+ mongoCommandSync(this.target(), "delete", { filter: { _id: sessionId } });
304
125
  }
305
126
  }
@@ -17,7 +17,21 @@
17
17
  * TINA4_SESSION_REDIS_DB (default: 0)
18
18
  */
19
19
  import { execFileSync } from "node:child_process";
20
+ import { createRequire } from "node:module";
20
21
  import type { SessionHandler } from "../session.js";
22
+ import { respCommandSync } from "./respClient.js";
23
+
24
+ // Resolve packages relative to this module so the optional `redis` driver is
25
+ // detected exactly as a consumer would resolve it (createRequire works in ESM).
26
+ const moduleRequire = createRequire(import.meta.url);
27
+ function redisDriverAvailable(): boolean {
28
+ try {
29
+ moduleRequire.resolve("redis");
30
+ return true;
31
+ } catch {
32
+ return false;
33
+ }
34
+ }
21
35
 
22
36
  interface SessionData {
23
37
  _created: number;
@@ -83,125 +97,72 @@ export class RedisNpmSessionHandler implements SessionHandler {
83
97
  : 0);
84
98
  }
85
99
 
100
+ /** Resolve a host/port for the raw-RESP path (parses TINA4_SESSION_REDIS_URL if set). */
101
+ private resolveHostPort(): { host: string; port: number } {
102
+ if (this.url) {
103
+ try {
104
+ const u = new URL(this.url);
105
+ return { host: u.hostname || "127.0.0.1", port: parseInt(u.port, 10) || 6379 };
106
+ } catch {
107
+ return { host: this.host, port: this.port };
108
+ }
109
+ }
110
+ return { host: this.host, port: this.port };
111
+ }
112
+
86
113
  /**
87
- * Execute a Redis command synchronously via a short-lived child process.
114
+ * Execute a Redis command synchronously.
88
115
  *
89
- * Attempts to use the `redis` npm package first. If unavailable, falls
90
- * back to raw TCP (RESP protocol) same as valkeyHandler.ts.
116
+ * Prefers the official `redis` driver when it is installed (the class's reason
117
+ * to exist); otherwise speaks raw RESP via the shared {@link respCommandSync}
118
+ * transport — same correct path as the Valkey handler, no `redis` dependency.
91
119
  *
92
- * Returns the command result string. A genuine key miss yields `""`. A
93
- * transport/connection FAILURE (server unreachable, AUTH error, timeout)
94
- * THROWS so the Session boundary can distinguish "not found" (silent) from
95
- * "backend failed" (log-loud + degrade). Backend-failure policy parity.
120
+ * A genuine key miss yields `""`; a transport/connection FAILURE (server
121
+ * unreachable, rejected AUTH, timeout) THROWS so the Session boundary can
122
+ * distinguish "not found" (silent) from "backend failed" (log-loud + degrade).
96
123
  */
97
124
  private execSync(args: string[]): string {
125
+ if (redisDriverAvailable()) {
126
+ return this.execViaNpm(args);
127
+ }
128
+ const { host, port } = this.resolveHostPort();
129
+ return respCommandSync({ host, port, password: this.password, db: this.db }, args, "Redis");
130
+ }
131
+
132
+ /** Drive the command through the `redis` npm client in a short-lived child. */
133
+ private execViaNpm(args: string[]): string {
98
134
  const script = `
99
- const net = require("node:net");
100
- const host = ${JSON.stringify(this.url || this.host)};
135
+ const useUrl = ${JSON.stringify(!!this.url)};
136
+ const url = ${JSON.stringify(this.url)};
137
+ const host = ${JSON.stringify(this.host)};
101
138
  const port = ${this.port};
102
139
  const password = ${JSON.stringify(this.password)};
103
140
  const db = ${this.db};
104
- const useUrl = ${JSON.stringify(!!this.url)};
105
- const url = ${JSON.stringify(this.url)};
106
141
  const args = ${JSON.stringify(args)};
107
-
108
- // Try the redis npm package first
109
- let redisAvailable = false;
110
- try {
111
- require.resolve("redis");
112
- redisAvailable = true;
113
- } catch {}
114
-
115
- if (redisAvailable) {
116
- const redis = require("redis");
117
- (async () => {
118
- try {
119
- const clientOpts = useUrl
120
- ? { url }
121
- : { socket: { host, port }, password: password || undefined, database: db };
122
- const client = redis.createClient(clientOpts);
123
- client.on("error", () => {});
124
- await client.connect();
125
-
126
- const cmd = args[0].toUpperCase();
127
- let result;
128
- if (cmd === "GET") {
129
- result = await client.get(args[1]);
130
- } else if (cmd === "SET") {
131
- result = await client.set(args[1], args[2]);
132
- } else if (cmd === "SETEX") {
133
- result = await client.setEx(args[1], parseInt(args[2], 10), args[3]);
134
- } else if (cmd === "DEL") {
135
- result = await client.del(args[1]);
136
- }
137
- await client.quit();
138
-
139
- if (result === null || result === undefined) {
140
- process.stdout.write("__NULL__");
141
- } else {
142
- process.stdout.write(String(result));
143
- }
144
- } catch (err) {
145
- process.stderr.write(err.message);
146
- process.exit(1);
147
- }
148
- })();
149
- } else {
150
- // Fallback: raw TCP RESP protocol (no redis package needed)
151
- const actualHost = useUrl ? (() => {
152
- try { const u = new URL(url); return u.hostname || "127.0.0.1"; } catch { return "127.0.0.1"; }
153
- })() : host;
154
- const actualPort = useUrl ? (() => {
155
- try { const u = new URL(url); return parseInt(u.port, 10) || 6379; } catch { return 6379; }
156
- })() : port;
157
-
158
- function buildCommand(a) {
159
- let cmd = "*" + a.length + "\\r\\n";
160
- for (const s of a) cmd += "$" + Buffer.byteLength(s) + "\\r\\n" + s + "\\r\\n";
161
- return cmd;
162
- }
163
-
164
- const sock = net.createConnection({ host: actualHost, port: actualPort }, () => {
165
- let commands = "";
166
- if (password) commands += buildCommand(["AUTH", password]);
167
- if (db !== 0) commands += buildCommand(["SELECT", String(db)]);
168
- commands += buildCommand(args);
169
- sock.write(commands);
170
- });
171
-
172
- let buffer = Buffer.alloc(0);
173
- sock.on("data", (chunk) => {
174
- buffer = Buffer.concat([buffer, chunk]);
175
- });
176
- sock.on("end", () => {
177
- const lines = buffer.toString("utf-8").split("\\r\\n");
178
- let responses = [];
179
- let i = 0;
180
- while (i < lines.length) {
181
- const line = lines[i];
182
- if (!line) { i++; continue; }
183
- if (line.startsWith("+") || line.startsWith("-") || line.startsWith(":")) {
184
- responses.push(line);
185
- i++;
186
- } else if (line.startsWith("$")) {
187
- const len = parseInt(line.slice(1), 10);
188
- if (len === -1) { responses.push(null); i++; }
189
- else { responses.push(lines[i+1] || ""); i += 2; }
190
- } else { i++; }
191
- }
192
- const result = responses[responses.length - 1];
193
- if (result === null) process.stdout.write("__NULL__");
194
- else if (typeof result === "string" && result.startsWith("-")) process.stdout.write("__ERR__" + result);
195
- else process.stdout.write(String(result ?? "__NULL__"));
196
- });
197
- sock.on("error", (err) => {
198
- process.stderr.write(err.message);
142
+ (async () => {
143
+ try {
144
+ const redis = require("redis");
145
+ const clientOpts = useUrl
146
+ ? { url }
147
+ : { socket: { host, port }, password: password || undefined, database: db };
148
+ const client = redis.createClient(clientOpts);
149
+ client.on("error", () => {});
150
+ await client.connect();
151
+ const cmd = args[0].toUpperCase();
152
+ let result;
153
+ if (cmd === "GET") result = await client.get(args[1]);
154
+ else if (cmd === "SET") result = await client.set(args[1], args[2]);
155
+ else if (cmd === "SETEX") result = await client.setEx(args[1], parseInt(args[2], 10), args[3]);
156
+ else if (cmd === "DEL") result = await client.del(args[1]);
157
+ await client.quit();
158
+ const out = (result === null || result === undefined) ? "__NULL__" : String(result);
159
+ process.stdout.write(out, () => process.exit(0));
160
+ } catch (err) {
161
+ process.stderr.write(String((err && err.message) || err));
199
162
  process.exit(1);
200
- });
201
- setTimeout(() => { sock.destroy(); process.exit(1); }, 3000);
202
- }
163
+ }
164
+ })();
203
165
  `;
204
-
205
166
  let result: string;
206
167
  try {
207
168
  result = execFileSync(process.execPath, ["-e", script], {
@@ -210,15 +171,9 @@ export class RedisNpmSessionHandler implements SessionHandler {
210
171
  stdio: ["pipe", "pipe", "pipe"],
211
172
  });
212
173
  } catch (err) {
213
- // Non-zero exit = the child hit a socket error / AUTH failure / timeout.
214
- // That is a transport FAILURE, not a key miss — surface it so the
215
- // Session boundary logs + degrades (or re-throws under strict mode).
216
174
  throw new Error(`Redis command failed: ${(err as Error).message}`);
217
175
  }
218
- if (result === "__NULL__") return ""; // genuine key miss
219
- if (result.startsWith("__ERR__")) {
220
- throw new Error(`Redis error: ${result.slice("__ERR__".length)}`);
221
- }
176
+ if (result === "__NULL__") return ""; // genuine key miss
222
177
  return result;
223
178
  }
224
179
 
@@ -0,0 +1,171 @@
1
+ /**
2
+ * Tina4 synchronous RESP transport — shared by every Redis/Valkey session handler.
3
+ *
4
+ * The session-handler interface is synchronous, but node:net is async-only, so a
5
+ * command runs in a short-lived `node -e` child (execFileSync) that blocks the
6
+ * caller until it exits. This module is the ONE correct implementation of that
7
+ * child; the Redis/Valkey handlers delegate here instead of inlining their own.
8
+ *
9
+ * The bug this replaces: the old child parsed the reply on the socket "end" event.
10
+ * Redis/Valkey keep the connection OPEN after a reply (they never half-close for a
11
+ * GET/SET/DEL), so "end" never fires — the child waited out its timeout, exited
12
+ * non-zero, and execFileSync re-threw it as a "transport failure". EVERY command
13
+ * failed against a reachable server. Here the child parses replies INCREMENTALLY on
14
+ * the "data" event (the same proven RESP parser cache.ts's RespClient uses): it
15
+ * reads exactly the replies it sent commands for (AUTH? + SELECT? + the command),
16
+ * then returns the LAST (command) reply.
17
+ */
18
+ import { execFileSync } from "node:child_process";
19
+
20
+ export interface RespTarget {
21
+ host: string;
22
+ port: number;
23
+ /** AUTH password (empty/undefined = no AUTH sent). */
24
+ password?: string;
25
+ /** SELECT db index (0/undefined = no SELECT sent). */
26
+ db?: number;
27
+ }
28
+
29
+ /**
30
+ * Run a single RESP command synchronously against host:port and return the reply.
31
+ *
32
+ * - A genuine nil / key-miss returns `""` (callers treat "" as "no session yet").
33
+ * - A transport FAILURE (server unreachable, timeout, connection closed before a
34
+ * reply) THROWS `<label> command failed: ...`.
35
+ * - A RESP error reply — including a rejected AUTH/SELECT handshake — THROWS
36
+ * `<label> error: ...`. A rejected handshake is a transport failure, not a
37
+ * result, so it is surfaced ahead of the command reply.
38
+ *
39
+ * Session values are JSON strings (they start with `{`), so they never collide
40
+ * with the `__NULL__` / `__ERR__` sentinels the child uses on stdout.
41
+ */
42
+ export function respCommandSync(target: RespTarget, args: string[], label = "Redis"): string {
43
+ const host = target.host;
44
+ const port = target.port;
45
+ const password = target.password ?? "";
46
+ const db = target.db ?? 0;
47
+
48
+ const script = `
49
+ const net = require("node:net");
50
+ const host = ${JSON.stringify(host)};
51
+ const port = ${port};
52
+ const password = ${JSON.stringify(password)};
53
+ const db = ${db};
54
+ const args = ${JSON.stringify(args)};
55
+ // Replies to consume = AUTH? + SELECT? + the command. The LAST is our result.
56
+ const expected = (password ? 1 : 0) + (db !== 0 ? 1 : 0) + 1;
57
+
58
+ function encode(a) {
59
+ let c = "*" + a.length + "\\r\\n";
60
+ for (const s of a) c += "$" + Buffer.byteLength(s) + "\\r\\n" + s + "\\r\\n";
61
+ return c;
62
+ }
63
+
64
+ // Parse one RESP value at offset. Returns { value, next } or null if more bytes
65
+ // are needed (so a bulk string split across TCP chunks is handled correctly).
66
+ function parse(buf, off) {
67
+ if (off >= buf.length) return null;
68
+ const type = buf[off];
69
+ const crlf = buf.indexOf("\\r\\n", off + 1, "utf-8");
70
+ if (crlf === -1) return null;
71
+ const line = buf.toString("utf-8", off + 1, crlf);
72
+ const after = crlf + 2;
73
+ if (type === 0x2b) return { value: line, next: after }; // '+' simple string
74
+ if (type === 0x3a) return { value: line, next: after }; // ':' integer
75
+ if (type === 0x2d) return { value: { __err: line }, next: after }; // '-' error
76
+ if (type === 0x24) { // '$' bulk string
77
+ const len = parseInt(line, 10);
78
+ if (len === -1) return { value: null, next: after };
79
+ if (after + len + 2 > buf.length) return null;
80
+ return { value: buf.toString("utf-8", after, after + len), next: after + len + 2 };
81
+ }
82
+ if (type === 0x2a) { // '*' array
83
+ const count = parseInt(line, 10);
84
+ if (count === -1) return { value: null, next: after };
85
+ const arr = [];
86
+ let pos = after;
87
+ for (let i = 0; i < count; i++) {
88
+ const el = parse(buf, pos);
89
+ if (!el) return null;
90
+ arr.push(el.value);
91
+ pos = el.next;
92
+ }
93
+ return { value: arr, next: pos };
94
+ }
95
+ return { value: line, next: after };
96
+ }
97
+
98
+ const sock = net.createConnection({ host, port });
99
+ sock.setNoDelay(true);
100
+ let buffer = Buffer.alloc(0);
101
+ const replies = [];
102
+ let done = false;
103
+ const timer = setTimeout(() => { if (!done) { done = true; try { sock.destroy(); } catch (e) {} process.stderr.write("timeout"); process.exitCode = 1; } }, 3000);
104
+
105
+ function emit(s) {
106
+ if (done) return;
107
+ done = true;
108
+ clearTimeout(timer);
109
+ // The write callback guarantees stdout is flushed before exit (a bare
110
+ // process.exit can truncate piped stdout).
111
+ process.stdout.write(s, () => { try { sock.destroy(); } catch (e) {} });
112
+ }
113
+ function fail(msg) {
114
+ if (done) return;
115
+ done = true;
116
+ clearTimeout(timer);
117
+ try { sock.destroy(); } catch (e) {}
118
+ process.stderr.write(msg || "");
119
+ process.exitCode = 1;
120
+ }
121
+
122
+ sock.on("connect", () => {
123
+ let cmds = "";
124
+ if (password) cmds += encode(["AUTH", password]);
125
+ if (db !== 0) cmds += encode(["SELECT", String(db)]);
126
+ cmds += encode(args);
127
+ sock.write(cmds);
128
+ });
129
+ sock.on("data", (chunk) => {
130
+ buffer = buffer.length ? Buffer.concat([buffer, chunk]) : chunk;
131
+ while (true) {
132
+ const p = parse(buffer, 0);
133
+ if (!p) break;
134
+ buffer = buffer.subarray(p.next);
135
+ replies.push(p.value);
136
+ if (replies.length < expected) continue;
137
+ // A rejected AUTH/SELECT is a transport failure — surface it first.
138
+ for (let i = 0; i < expected - 1; i++) {
139
+ const r = replies[i];
140
+ if (r && typeof r === "object" && r.__err !== undefined) { emit("__ERR__" + r.__err); return; }
141
+ }
142
+ const result = replies[expected - 1];
143
+ if (result && typeof result === "object" && result.__err !== undefined) emit("__ERR__" + result.__err);
144
+ else if (result === null || result === undefined) emit("__NULL__");
145
+ else emit(String(result));
146
+ return;
147
+ }
148
+ });
149
+ sock.on("error", (err) => fail(err.message));
150
+ sock.on("close", () => { if (!done) fail("connection closed before reply"); });
151
+ `;
152
+
153
+ let result: string;
154
+ try {
155
+ result = execFileSync(process.execPath, ["-e", script], {
156
+ encoding: "utf-8",
157
+ timeout: 5000,
158
+ stdio: ["pipe", "pipe", "pipe"],
159
+ });
160
+ } catch (err) {
161
+ // Non-zero exit = socket error / timeout / closed connection: a transport
162
+ // FAILURE, not a key miss. Surface it so the Session boundary logs + degrades
163
+ // (or re-throws under strict mode).
164
+ throw new Error(`${label} command failed: ${(err as Error).message}`);
165
+ }
166
+ if (result === "__NULL__") return ""; // genuine key miss
167
+ if (result.startsWith("__ERR__")) {
168
+ throw new Error(`${label} error: ${result.slice("__ERR__".length)}`);
169
+ }
170
+ return result;
171
+ }