tina4-nodejs 3.13.36 → 3.13.38
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 +51 -19
- package/package.json +5 -3
- package/packages/cli/src/bin.ts +7 -0
- package/packages/cli/src/commands/init.ts +1 -0
- package/packages/cli/src/commands/metrics.ts +154 -0
- package/packages/cli/src/commands/routes.ts +3 -3
- package/packages/core/public/js/tina4-dev-admin.js +212 -212
- package/packages/core/public/js/tina4-dev-admin.min.js +212 -212
- package/packages/core/src/auth.ts +112 -2
- package/packages/core/src/cache.ts +2 -2
- package/packages/core/src/devAdmin.ts +75 -26
- package/packages/core/src/devMailbox.ts +4 -0
- package/packages/core/src/dotenv.ts +13 -4
- package/packages/core/src/events.ts +86 -4
- package/packages/core/src/graphql.ts +182 -128
- package/packages/core/src/htmlElement.ts +62 -3
- package/packages/core/src/index.ts +14 -8
- package/packages/core/src/logger.ts +1 -1
- package/packages/core/src/mcp.test.ts +1 -1
- package/packages/core/src/messenger.ts +111 -11
- package/packages/core/src/metrics.ts +232 -33
- package/packages/core/src/middleware.ts +129 -39
- package/packages/core/src/plan.ts +1 -1
- package/packages/core/src/queue.ts +1 -1
- package/packages/core/src/queueBackends/kafkaBackend.ts +1 -1
- package/packages/core/src/queueBackends/mongoBackend.ts +1 -1
- package/packages/core/src/queueBackends/rabbitmqBackend.ts +1 -1
- package/packages/core/src/rateLimiter.ts +1 -1
- package/packages/core/src/response.ts +90 -6
- package/packages/core/src/router.ts +2 -2
- package/packages/core/src/server.ts +26 -4
- package/packages/core/src/session.ts +130 -18
- package/packages/core/src/sessionHandlers/databaseHandler.ts +10 -0
- package/packages/core/src/sessionHandlers/mongoHandler.ts +21 -4
- package/packages/core/src/sessionHandlers/redisHandler.ts +28 -7
- package/packages/core/src/sessionHandlers/valkeyHandler.ts +27 -8
- package/packages/core/src/testClient.ts +1 -1
- package/packages/core/src/websocket.ts +247 -33
- package/packages/core/src/websocketBackplane.ts +210 -10
- package/packages/core/src/wsdl.ts +55 -21
- package/packages/orm/src/adapters/pg-types.d.ts +60 -0
- package/packages/orm/src/adapters/postgres.ts +26 -4
- package/packages/orm/src/adapters/sqlite.ts +112 -13
- package/packages/orm/src/baseModel.ts +8 -3
- package/packages/orm/src/cachedDatabase.ts +15 -6
- package/packages/orm/src/database.ts +257 -55
- package/packages/orm/src/index.ts +2 -1
- package/packages/orm/src/migration.ts +2 -2
- package/packages/orm/src/seeder.ts +443 -65
- package/packages/swagger/src/ui.ts +1 -1
|
@@ -28,6 +28,8 @@ import { randomBytes } from "node:crypto";
|
|
|
28
28
|
import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync, readdirSync } from "node:fs";
|
|
29
29
|
import { join } from "node:path";
|
|
30
30
|
import { execFileSync } from "node:child_process";
|
|
31
|
+
import { Log } from "./logger.js";
|
|
32
|
+
import { isTruthy } from "./dotenv.js";
|
|
31
33
|
import { RedisNpmSessionHandler } from "./sessionHandlers/redisHandler.js";
|
|
32
34
|
import { ValkeySessionHandler } from "./sessionHandlers/valkeyHandler.js";
|
|
33
35
|
import { MongoSessionHandler } from "./sessionHandlers/mongoHandler.js";
|
|
@@ -196,7 +198,10 @@ export class RedisSessionHandler implements SessionHandler {
|
|
|
196
198
|
/**
|
|
197
199
|
* Execute a Redis command synchronously via a short-lived TCP connection.
|
|
198
200
|
*
|
|
199
|
-
* Returns the
|
|
201
|
+
* Returns the command result string. A genuine key miss yields `""`. A
|
|
202
|
+
* transport/connection FAILURE (server unreachable, AUTH error, timeout)
|
|
203
|
+
* THROWS so the Session boundary can distinguish "not found" (silent) from
|
|
204
|
+
* "backend failed" (log-loud + degrade). Backend-failure policy parity.
|
|
200
205
|
*/
|
|
201
206
|
private execSync(args: string[]): string {
|
|
202
207
|
const script = `
|
|
@@ -270,18 +275,24 @@ export class RedisSessionHandler implements SessionHandler {
|
|
|
270
275
|
setTimeout(() => { sock.destroy(); process.exit(1); }, 3000);
|
|
271
276
|
`;
|
|
272
277
|
|
|
278
|
+
let result: string;
|
|
273
279
|
try {
|
|
274
|
-
|
|
280
|
+
result = execFileSync(process.execPath, ["-e", script], {
|
|
275
281
|
encoding: "utf-8",
|
|
276
282
|
timeout: 5000,
|
|
277
283
|
stdio: ["pipe", "pipe", "pipe"],
|
|
278
284
|
});
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
285
|
+
} catch (err) {
|
|
286
|
+
// Non-zero exit = the child hit a socket error / AUTH failure / timeout.
|
|
287
|
+
// That is a transport FAILURE, not a key miss — surface it so the
|
|
288
|
+
// Session boundary logs + degrades (or re-throws under strict mode).
|
|
289
|
+
throw new Error(`Redis command failed: ${(err as Error).message}`);
|
|
290
|
+
}
|
|
291
|
+
if (result === "__NULL__") return ""; // genuine key miss
|
|
292
|
+
if (result.startsWith("__ERR__")) {
|
|
293
|
+
throw new Error(`Redis error: ${result.slice("__ERR__".length)}`);
|
|
284
294
|
}
|
|
295
|
+
return result;
|
|
285
296
|
}
|
|
286
297
|
|
|
287
298
|
private key(sessionId: string): string {
|
|
@@ -290,7 +301,7 @@ export class RedisSessionHandler implements SessionHandler {
|
|
|
290
301
|
|
|
291
302
|
read(sessionId: string): SessionData | null {
|
|
292
303
|
const raw = this.execSync(["GET", this.key(sessionId)]);
|
|
293
|
-
if (!raw) return null;
|
|
304
|
+
if (!raw) return null; // key miss — normal "no session yet", NOT an error
|
|
294
305
|
try {
|
|
295
306
|
return JSON.parse(raw) as SessionData;
|
|
296
307
|
} catch {
|
|
@@ -323,6 +334,20 @@ export class Session {
|
|
|
323
334
|
private ttl: number;
|
|
324
335
|
private sessionId: string | null = null;
|
|
325
336
|
private data: SessionData | null = null;
|
|
337
|
+
/**
|
|
338
|
+
* Dirty flag — set when data changes, cleared only on a successful write.
|
|
339
|
+
* Retained on a failed write so a later save() retries once the backend
|
|
340
|
+
* recovers (mirrors the Python `_dirty` semantics).
|
|
341
|
+
*/
|
|
342
|
+
private dirty = false;
|
|
343
|
+
/**
|
|
344
|
+
* Backend-failure policy: log-loud + degrade (default), or re-raise when
|
|
345
|
+
* TINA4_SESSION_STRICT is truthy. A read failure logs + yields an empty
|
|
346
|
+
* session, a write failure logs + returns false (best-effort, dirty
|
|
347
|
+
* retained), destroy/gc failures log + swallow. Parity across all four
|
|
348
|
+
* frameworks. Strict mode is the escape hatch (same as events/seeding).
|
|
349
|
+
*/
|
|
350
|
+
private strict: boolean;
|
|
326
351
|
|
|
327
352
|
constructor(backend?: string, config?: SessionConfig) {
|
|
328
353
|
const backendType = backend
|
|
@@ -333,6 +358,8 @@ export class Session {
|
|
|
333
358
|
this.ttl = config?.ttl
|
|
334
359
|
?? (process.env.TINA4_SESSION_TTL ? parseInt(process.env.TINA4_SESSION_TTL, 10) : 3600);
|
|
335
360
|
|
|
361
|
+
this.strict = isTruthy(process.env.TINA4_SESSION_STRICT);
|
|
362
|
+
|
|
336
363
|
// Select handler based on backend type
|
|
337
364
|
switch (backendType) {
|
|
338
365
|
case "redis":
|
|
@@ -370,6 +397,57 @@ export class Session {
|
|
|
370
397
|
this.handler = handler;
|
|
371
398
|
}
|
|
372
399
|
|
|
400
|
+
// ── Backend-failure policy: log-loud + degrade ─────────────────────
|
|
401
|
+
//
|
|
402
|
+
// The handlers themselves stay honest — they raise when the backend
|
|
403
|
+
// (Redis/Valkey/Mongo/DB) is unreachable, and return null/empty WITHOUT
|
|
404
|
+
// raising for a genuine "no session yet" miss. The Session layer is the
|
|
405
|
+
// single place that decides the resilience policy so every backend behaves
|
|
406
|
+
// the same: a transient outage logs + degrades rather than 500-ing every
|
|
407
|
+
// request (cascade outage) or vanishing silently (data loss). A genuinely
|
|
408
|
+
// empty result is NOT an error and never reaches these logs.
|
|
409
|
+
|
|
410
|
+
private logBackendError(op: string, err: unknown): void {
|
|
411
|
+
const handlerName = (this.handler as object)?.constructor?.name ?? "SessionHandler";
|
|
412
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
413
|
+
Log.error(`Session backend ${op} failed (${handlerName}): ${message}`);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/** Read through the backend; on FAILURE log + degrade to empty (or re-throw under strict). */
|
|
417
|
+
private safeRead(sessionId: string): SessionData | null {
|
|
418
|
+
try {
|
|
419
|
+
return this.handler.read(sessionId);
|
|
420
|
+
} catch (err) {
|
|
421
|
+
this.logBackendError("read", err);
|
|
422
|
+
if (this.strict) throw err;
|
|
423
|
+
return null;
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/** Write through the backend; on FAILURE log + return false (or re-throw under strict). */
|
|
428
|
+
private safeWrite(sessionId: string, data: SessionData, ttl: number): boolean {
|
|
429
|
+
try {
|
|
430
|
+
this.handler.write(sessionId, data, ttl);
|
|
431
|
+
return true;
|
|
432
|
+
} catch (err) {
|
|
433
|
+
this.logBackendError("write", err);
|
|
434
|
+
if (this.strict) throw err;
|
|
435
|
+
return false;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/** Destroy through the backend; on FAILURE log + swallow (or re-throw under strict). */
|
|
440
|
+
private safeDestroy(sessionId: string): boolean {
|
|
441
|
+
try {
|
|
442
|
+
this.handler.destroy(sessionId);
|
|
443
|
+
return true;
|
|
444
|
+
} catch (err) {
|
|
445
|
+
this.logBackendError("destroy", err);
|
|
446
|
+
if (this.strict) throw err;
|
|
447
|
+
return false;
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
373
451
|
/**
|
|
374
452
|
* Start or resume a session.
|
|
375
453
|
* @param sessionId - Existing session ID to resume (optional)
|
|
@@ -377,17 +455,20 @@ export class Session {
|
|
|
377
455
|
*/
|
|
378
456
|
start(sessionId?: string): string {
|
|
379
457
|
if (sessionId) {
|
|
380
|
-
const loaded = this.
|
|
458
|
+
const loaded = this.safeRead(sessionId);
|
|
381
459
|
if (loaded) {
|
|
382
460
|
// Check TTL for file backend (Redis handles TTL natively)
|
|
383
461
|
const now = Math.floor(Date.now() / 1000);
|
|
384
462
|
if (loaded._accessed && (now - loaded._accessed) > this.ttl) {
|
|
385
|
-
this.
|
|
463
|
+
this.safeDestroy(sessionId);
|
|
386
464
|
} else {
|
|
387
465
|
this.sessionId = sessionId;
|
|
388
466
|
this.data = loaded;
|
|
389
467
|
this.data._accessed = now;
|
|
390
|
-
this.
|
|
468
|
+
this.dirty = false;
|
|
469
|
+
// Refresh the accessed timestamp; a write failure here is logged but
|
|
470
|
+
// must not abort the resume — the request still serves.
|
|
471
|
+
this.safeWrite(this.sessionId, this.data, this.ttl);
|
|
391
472
|
return sessionId;
|
|
392
473
|
}
|
|
393
474
|
}
|
|
@@ -397,7 +478,8 @@ export class Session {
|
|
|
397
478
|
this.sessionId = randomBytes(16).toString("hex");
|
|
398
479
|
const now = Math.floor(Date.now() / 1000);
|
|
399
480
|
this.data = { _created: now, _accessed: now };
|
|
400
|
-
this.
|
|
481
|
+
this.dirty = false;
|
|
482
|
+
this.safeWrite(this.sessionId, this.data, this.ttl);
|
|
401
483
|
return this.sessionId;
|
|
402
484
|
}
|
|
403
485
|
|
|
@@ -418,6 +500,7 @@ export class Session {
|
|
|
418
500
|
set(key: string, value: unknown): void {
|
|
419
501
|
if (!this.data) return;
|
|
420
502
|
this.data[key] = value;
|
|
503
|
+
this.dirty = true;
|
|
421
504
|
this.save();
|
|
422
505
|
}
|
|
423
506
|
|
|
@@ -427,18 +510,23 @@ export class Session {
|
|
|
427
510
|
delete(key: string): void {
|
|
428
511
|
if (!this.data) return;
|
|
429
512
|
delete this.data[key];
|
|
513
|
+
this.dirty = true;
|
|
430
514
|
this.save();
|
|
431
515
|
}
|
|
432
516
|
|
|
433
517
|
/**
|
|
434
518
|
* Destroy the entire session.
|
|
519
|
+
*
|
|
520
|
+
* A backend failure is logged (never silent) but does not throw under the
|
|
521
|
+
* default policy — local state is cleared regardless so the request proceeds.
|
|
435
522
|
*/
|
|
436
523
|
destroy(): void {
|
|
437
524
|
if (this.sessionId) {
|
|
438
|
-
this.
|
|
525
|
+
this.safeDestroy(this.sessionId);
|
|
439
526
|
}
|
|
440
527
|
this.sessionId = null;
|
|
441
528
|
this.data = null;
|
|
529
|
+
this.dirty = false;
|
|
442
530
|
}
|
|
443
531
|
|
|
444
532
|
/**
|
|
@@ -462,6 +550,7 @@ export class Session {
|
|
|
462
550
|
if (!this.data) return;
|
|
463
551
|
const now = Math.floor(Date.now() / 1000);
|
|
464
552
|
this.data = { _created: this.data._created, _accessed: now };
|
|
553
|
+
this.dirty = true;
|
|
465
554
|
this.save();
|
|
466
555
|
}
|
|
467
556
|
|
|
@@ -475,6 +564,11 @@ export class Session {
|
|
|
475
564
|
|
|
476
565
|
/**
|
|
477
566
|
* Regenerate the session ID (keeps data, new ID).
|
|
567
|
+
*
|
|
568
|
+
* Call this right after a successful login or any privilege change to defeat
|
|
569
|
+
* session fixation — the pre-auth ID is destroyed and the data is carried
|
|
570
|
+
* onto a fresh, unguessable ID. A backend destroy/write failure is logged
|
|
571
|
+
* (never silent) but does not throw under the default policy.
|
|
478
572
|
*/
|
|
479
573
|
regenerate(): string {
|
|
480
574
|
const oldId = this.sessionId;
|
|
@@ -482,13 +576,14 @@ export class Session {
|
|
|
482
576
|
|
|
483
577
|
// Remove old session
|
|
484
578
|
if (oldId) {
|
|
485
|
-
this.
|
|
579
|
+
this.safeDestroy(oldId);
|
|
486
580
|
}
|
|
487
581
|
|
|
488
582
|
// New ID, keep data
|
|
489
583
|
this.sessionId = randomBytes(16).toString("hex");
|
|
490
584
|
this.data = oldData ?? { _created: Math.floor(Date.now() / 1000), _accessed: Math.floor(Date.now() / 1000) };
|
|
491
585
|
this.data._accessed = Math.floor(Date.now() / 1000);
|
|
586
|
+
this.dirty = true;
|
|
492
587
|
this.save();
|
|
493
588
|
return this.sessionId;
|
|
494
589
|
}
|
|
@@ -510,6 +605,7 @@ export class Session {
|
|
|
510
605
|
if (!this.data || !(flashKey in this.data)) return undefined;
|
|
511
606
|
const stored = this.data[flashKey];
|
|
512
607
|
delete this.data[flashKey];
|
|
608
|
+
this.dirty = true;
|
|
513
609
|
this.save();
|
|
514
610
|
return stored;
|
|
515
611
|
}
|
|
@@ -545,19 +641,35 @@ export class Session {
|
|
|
545
641
|
/**
|
|
546
642
|
* Run garbage collection on the session backend.
|
|
547
643
|
* Removes expired file/database sessions. Redis/Valkey/Mongo handle TTL natively.
|
|
644
|
+
*
|
|
645
|
+
* A backend failure is logged (never silent) but does not throw under the
|
|
646
|
+
* default policy (re-raises under TINA4_SESSION_STRICT=true).
|
|
548
647
|
*/
|
|
549
648
|
gc(): void {
|
|
550
|
-
if (this.handler.gc)
|
|
649
|
+
if (!this.handler.gc) return;
|
|
650
|
+
try {
|
|
551
651
|
this.handler.gc(this.ttl);
|
|
652
|
+
} catch (err) {
|
|
653
|
+
this.logBackendError("gc", err);
|
|
654
|
+
if (this.strict) throw err;
|
|
552
655
|
}
|
|
553
656
|
}
|
|
554
657
|
|
|
555
658
|
/**
|
|
556
659
|
* Persist session data to the backend.
|
|
660
|
+
*
|
|
661
|
+
* Returns true on a successful persist, false if the backend was unreachable
|
|
662
|
+
* (logged). The dirty flag is cleared only on success so a later save()
|
|
663
|
+
* retries once the backend recovers. A nothing-to-persist call returns true.
|
|
557
664
|
*/
|
|
558
|
-
save():
|
|
559
|
-
if (!this.sessionId || !this.data) return;
|
|
560
|
-
|
|
665
|
+
save(): boolean {
|
|
666
|
+
if (!this.sessionId || !this.data) return true;
|
|
667
|
+
if (!this.dirty) return true;
|
|
668
|
+
if (this.safeWrite(this.sessionId, this.data, this.ttl)) {
|
|
669
|
+
this.dirty = false;
|
|
670
|
+
return true;
|
|
671
|
+
}
|
|
672
|
+
return false; // write failed (logged); dirty RETAINED for retry
|
|
561
673
|
}
|
|
562
674
|
}
|
|
563
675
|
|
|
@@ -22,6 +22,16 @@ interface SessionData {
|
|
|
22
22
|
export interface DatabaseSessionConfig {
|
|
23
23
|
/** SQLite database file path (default: extracted from TINA4_DATABASE_URL or "data/tina4_sessions.db") */
|
|
24
24
|
dbPath?: string;
|
|
25
|
+
// Unified SessionConfig fields are tolerated (and ignored) so the central
|
|
26
|
+
// Session can forward its config object without a structural mismatch.
|
|
27
|
+
backend?: string;
|
|
28
|
+
path?: string;
|
|
29
|
+
ttl?: number;
|
|
30
|
+
redisHost?: string;
|
|
31
|
+
redisPort?: number;
|
|
32
|
+
redisPassword?: string;
|
|
33
|
+
redisPrefix?: string;
|
|
34
|
+
redisDb?: number;
|
|
25
35
|
}
|
|
26
36
|
|
|
27
37
|
/**
|
|
@@ -30,6 +30,16 @@ export interface MongoSessionConfig {
|
|
|
30
30
|
password?: string;
|
|
31
31
|
database?: string;
|
|
32
32
|
collection?: string;
|
|
33
|
+
// Unified SessionConfig fields are tolerated (and ignored) so the central
|
|
34
|
+
// Session can forward its config object without a structural mismatch.
|
|
35
|
+
backend?: string;
|
|
36
|
+
path?: string;
|
|
37
|
+
ttl?: number;
|
|
38
|
+
redisHost?: string;
|
|
39
|
+
redisPort?: number;
|
|
40
|
+
redisPassword?: string;
|
|
41
|
+
redisPrefix?: string;
|
|
42
|
+
redisDb?: number;
|
|
33
43
|
}
|
|
34
44
|
|
|
35
45
|
/**
|
|
@@ -79,6 +89,11 @@ export class MongoSessionHandler implements SessionHandler {
|
|
|
79
89
|
* Uses the MongoDB wire protocol (OP_MSG) to communicate with the server.
|
|
80
90
|
* For simplicity, we use the `runCommand` approach with JSON serialization
|
|
81
91
|
* of BSON documents using a minimal BSON encoder.
|
|
92
|
+
*
|
|
93
|
+
* Returns the command response string (or `__EMPTY__` for an absent doc).
|
|
94
|
+
* A transport/connection FAILURE (server unreachable, socket error, timeout)
|
|
95
|
+
* THROWS so the Session boundary can distinguish "not found" (silent) from
|
|
96
|
+
* "backend failed" (log-loud + degrade). Backend-failure policy parity.
|
|
82
97
|
*/
|
|
83
98
|
private execSync(command: string, args: string): string {
|
|
84
99
|
const script = `
|
|
@@ -226,14 +241,16 @@ export class MongoSessionHandler implements SessionHandler {
|
|
|
226
241
|
`;
|
|
227
242
|
|
|
228
243
|
try {
|
|
229
|
-
|
|
244
|
+
return execFileSync(process.execPath, ["-e", script], {
|
|
230
245
|
encoding: "utf-8",
|
|
231
246
|
timeout: 8000,
|
|
232
247
|
stdio: ["pipe", "pipe", "pipe"],
|
|
233
248
|
});
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
249
|
+
} catch (err) {
|
|
250
|
+
// Non-zero exit = the child hit a socket error / timeout. That is a
|
|
251
|
+
// transport FAILURE, not an absent document — surface it so the Session
|
|
252
|
+
// boundary logs + degrades (or re-throws under strict mode).
|
|
253
|
+
throw new Error(`MongoDB command failed: ${(err as Error).message}`);
|
|
237
254
|
}
|
|
238
255
|
}
|
|
239
256
|
|
|
@@ -32,6 +32,16 @@ export interface RedisNpmSessionConfig {
|
|
|
32
32
|
password?: string;
|
|
33
33
|
prefix?: string;
|
|
34
34
|
db?: number;
|
|
35
|
+
// Unified SessionConfig fields are tolerated (and ignored) so the central
|
|
36
|
+
// Session can forward its config object without a structural mismatch.
|
|
37
|
+
backend?: string;
|
|
38
|
+
path?: string;
|
|
39
|
+
ttl?: number;
|
|
40
|
+
redisHost?: string;
|
|
41
|
+
redisPort?: number;
|
|
42
|
+
redisPassword?: string;
|
|
43
|
+
redisPrefix?: string;
|
|
44
|
+
redisDb?: number;
|
|
35
45
|
}
|
|
36
46
|
|
|
37
47
|
/**
|
|
@@ -78,6 +88,11 @@ export class RedisNpmSessionHandler implements SessionHandler {
|
|
|
78
88
|
*
|
|
79
89
|
* Attempts to use the `redis` npm package first. If unavailable, falls
|
|
80
90
|
* back to raw TCP (RESP protocol) — same as valkeyHandler.ts.
|
|
91
|
+
*
|
|
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.
|
|
81
96
|
*/
|
|
82
97
|
private execSync(args: string[]): string {
|
|
83
98
|
const script = `
|
|
@@ -187,18 +202,24 @@ export class RedisNpmSessionHandler implements SessionHandler {
|
|
|
187
202
|
}
|
|
188
203
|
`;
|
|
189
204
|
|
|
205
|
+
let result: string;
|
|
190
206
|
try {
|
|
191
|
-
|
|
207
|
+
result = execFileSync(process.execPath, ["-e", script], {
|
|
192
208
|
encoding: "utf-8",
|
|
193
209
|
timeout: 5000,
|
|
194
210
|
stdio: ["pipe", "pipe", "pipe"],
|
|
195
211
|
});
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
212
|
+
} 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
|
+
throw new Error(`Redis command failed: ${(err as Error).message}`);
|
|
217
|
+
}
|
|
218
|
+
if (result === "__NULL__") return ""; // genuine key miss
|
|
219
|
+
if (result.startsWith("__ERR__")) {
|
|
220
|
+
throw new Error(`Redis error: ${result.slice("__ERR__".length)}`);
|
|
201
221
|
}
|
|
222
|
+
return result;
|
|
202
223
|
}
|
|
203
224
|
|
|
204
225
|
private key(sessionId: string): string {
|
|
@@ -207,7 +228,7 @@ export class RedisNpmSessionHandler implements SessionHandler {
|
|
|
207
228
|
|
|
208
229
|
read(sessionId: string): SessionData | null {
|
|
209
230
|
const raw = this.execSync(["GET", this.key(sessionId)]);
|
|
210
|
-
if (!raw) return null;
|
|
231
|
+
if (!raw) return null; // key miss — normal "no session yet", NOT an error
|
|
211
232
|
try {
|
|
212
233
|
return JSON.parse(raw) as SessionData;
|
|
213
234
|
} catch {
|
|
@@ -26,6 +26,16 @@ export interface ValkeySessionConfig {
|
|
|
26
26
|
password?: string;
|
|
27
27
|
prefix?: string;
|
|
28
28
|
db?: number;
|
|
29
|
+
// Unified SessionConfig fields are tolerated (and ignored) so the central
|
|
30
|
+
// Session can forward its config object without a structural mismatch.
|
|
31
|
+
backend?: string;
|
|
32
|
+
path?: string;
|
|
33
|
+
ttl?: number;
|
|
34
|
+
redisHost?: string;
|
|
35
|
+
redisPort?: number;
|
|
36
|
+
redisPassword?: string;
|
|
37
|
+
redisPrefix?: string;
|
|
38
|
+
redisDb?: number;
|
|
29
39
|
}
|
|
30
40
|
|
|
31
41
|
/**
|
|
@@ -67,7 +77,10 @@ export class ValkeySessionHandler implements SessionHandler {
|
|
|
67
77
|
/**
|
|
68
78
|
* Execute a RESP command synchronously via a short-lived TCP connection.
|
|
69
79
|
*
|
|
70
|
-
* Returns the
|
|
80
|
+
* Returns the command result string. A genuine key miss yields `""`. A
|
|
81
|
+
* transport/connection FAILURE (server unreachable, AUTH error, timeout)
|
|
82
|
+
* THROWS so the Session boundary can distinguish "not found" (silent) from
|
|
83
|
+
* "backend failed" (log-loud + degrade). Backend-failure policy parity.
|
|
71
84
|
*/
|
|
72
85
|
private execSync(args: string[]): string {
|
|
73
86
|
const script = `
|
|
@@ -141,18 +154,24 @@ export class ValkeySessionHandler implements SessionHandler {
|
|
|
141
154
|
setTimeout(() => { sock.destroy(); process.exit(1); }, 3000);
|
|
142
155
|
`;
|
|
143
156
|
|
|
157
|
+
let result: string;
|
|
144
158
|
try {
|
|
145
|
-
|
|
159
|
+
result = execFileSync(process.execPath, ["-e", script], {
|
|
146
160
|
encoding: "utf-8",
|
|
147
161
|
timeout: 5000,
|
|
148
162
|
stdio: ["pipe", "pipe", "pipe"],
|
|
149
163
|
});
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
164
|
+
} catch (err) {
|
|
165
|
+
// Non-zero exit = the child hit a socket error / AUTH failure / timeout.
|
|
166
|
+
// That is a transport FAILURE, not a key miss — surface it so the
|
|
167
|
+
// Session boundary logs + degrades (or re-throws under strict mode).
|
|
168
|
+
throw new Error(`Valkey command failed: ${(err as Error).message}`);
|
|
169
|
+
}
|
|
170
|
+
if (result === "__NULL__") return ""; // genuine key miss
|
|
171
|
+
if (result.startsWith("__ERR__")) {
|
|
172
|
+
throw new Error(`Valkey error: ${result.slice("__ERR__".length)}`);
|
|
155
173
|
}
|
|
174
|
+
return result;
|
|
156
175
|
}
|
|
157
176
|
|
|
158
177
|
private key(sessionId: string): string {
|
|
@@ -161,7 +180,7 @@ export class ValkeySessionHandler implements SessionHandler {
|
|
|
161
180
|
|
|
162
181
|
read(sessionId: string): SessionData | null {
|
|
163
182
|
const raw = this.execSync(["GET", this.key(sessionId)]);
|
|
164
|
-
if (!raw) return null;
|
|
183
|
+
if (!raw) return null; // key miss — normal "no session yet", NOT an error
|
|
165
184
|
try {
|
|
166
185
|
return JSON.parse(raw) as SessionData;
|
|
167
186
|
} catch {
|