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.
- package/CLAUDE.md +2 -1
- package/package.json +1 -1
- package/packages/core/src/graphql.ts +23 -14
- package/packages/core/src/mcp.ts +21 -2
- package/packages/core/src/queueBackends/kafkaBackend.ts +303 -167
- package/packages/core/src/queueBackends/rabbitmqBackend.ts +97 -31
- package/packages/core/src/server.ts +12 -5
- package/packages/core/src/session.ts +11 -95
- package/packages/core/src/sessionHandlers/mongoClient.ts +238 -0
- package/packages/core/src/sessionHandlers/mongoHandler.ts +25 -204
- package/packages/core/src/sessionHandlers/redisHandler.ts +69 -114
- package/packages/core/src/sessionHandlers/respClient.ts +171 -0
- package/packages/core/src/sessionHandlers/valkeyHandler.ts +11 -95
- package/packages/orm/src/adapters/firebird.ts +20 -2
- package/packages/orm/src/adapters/mssql.ts +24 -2
- package/packages/orm/src/adapters/mysql.ts +20 -2
- package/packages/orm/src/adapters/postgres.ts +40 -12
- package/packages/orm/src/adapters/sqlite.ts +16 -2
- package/packages/orm/src/autoCrud.ts +13 -0
- package/packages/orm/src/baseModel.ts +3 -1
- package/packages/orm/src/cachedDatabase.ts +1 -1
- package/packages/orm/src/database.ts +42 -11
- package/packages/orm/src/index.ts +1 -1
- package/packages/orm/src/migration.ts +124 -45
- package/packages/orm/src/types.ts +5 -3
|
@@ -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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
266
|
-
|
|
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
|
-
}
|
|
271
|
-
return { host: this.
|
|
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.
|
|
276
|
-
|
|
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
|
-
|
|
284
|
-
const
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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.
|
|
116
|
+
mongoCommandSync(this.target(), "update", {
|
|
295
117
|
filter: { _id: sessionId },
|
|
296
|
-
data
|
|
297
|
-
|
|
118
|
+
data,
|
|
119
|
+
last_accessed: Date.now() / 1000,
|
|
120
|
+
});
|
|
298
121
|
}
|
|
299
122
|
|
|
300
123
|
destroy(sessionId: string): void {
|
|
301
|
-
this.
|
|
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
|
|
114
|
+
* Execute a Redis command synchronously.
|
|
88
115
|
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
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
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
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
|
|
100
|
-
const
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
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 "";
|
|
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
|
+
}
|