tina4-nodejs 3.13.94 → 3.13.95
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 +157 -28
- package/README.md +1 -1
- package/package.json +2 -1
- package/packages/cli/dist/bin.js +32418 -29638
- package/packages/cli/src/commands/metrics.ts +17 -11
- package/packages/cli/src/commands/serve.ts +10 -9
- package/packages/core/dist/index.js +32364 -29501
- package/packages/core/src/ai.ts +7 -1
- package/packages/core/src/auth.ts +191 -39
- package/packages/core/src/background.ts +19 -19
- package/packages/core/src/cache.ts +492 -49
- package/packages/core/src/devAdmin.ts +79 -32
- package/packages/core/src/dispatchPipeline.ts +285 -0
- package/packages/core/src/dotenv.ts +185 -40
- package/packages/core/src/index.ts +5 -4
- package/packages/core/src/logger.ts +257 -36
- package/packages/core/src/mcp.ts +1 -1
- package/packages/core/src/messenger.ts +9 -13
- package/packages/core/src/metrics.ts +199 -961
- package/packages/core/src/middleware.ts +390 -123
- package/packages/core/src/queue.ts +188 -32
- package/packages/core/src/queueBackends/kafkaBackend.ts +1 -1
- package/packages/core/src/queueBackends/liteBackend.ts +13 -0
- package/packages/core/src/queueBackends/mongoBackend.ts +101 -9
- package/packages/core/src/queueBackends/rabbitmqBackend.ts +22 -4
- package/packages/core/src/rateLimiter.ts +10 -5
- package/packages/core/src/request.ts +6 -9
- package/packages/core/src/response.ts +46 -1
- package/packages/core/src/router.ts +29 -4
- package/packages/core/src/server.ts +751 -414
- package/packages/core/src/session.ts +244 -27
- package/packages/core/src/sessionHandlers/databaseHandler.ts +338 -48
- package/packages/core/src/sessionHandlers/memcachedHandler.ts +181 -0
- package/packages/core/src/sessionHandlers/mongoClient.ts +293 -208
- package/packages/core/src/sessionHandlers/mongoHandler.ts +88 -8
- package/packages/core/src/sessionHandlers/respClient.ts +16 -147
- package/packages/core/src/sessionHandlers/sqlClient.ts +290 -0
- package/packages/core/src/sessionHandlers/syncBridge.ts +190 -0
- package/packages/core/src/sessionHandlers/syncSocket.ts +236 -0
- package/packages/core/src/testClient.ts +18 -5
- package/packages/core/src/trustedProxy.ts +249 -0
- package/packages/core/src/types.ts +29 -5
- package/packages/core/src/websocket.ts +66 -0
- package/packages/orm/dist/index.js +22367 -19504
- package/packages/orm/src/adapters/firebird.ts +183 -56
- package/packages/orm/src/adapters/mongodb.ts +25 -4
- package/packages/orm/src/adapters/mssql.ts +114 -29
- package/packages/orm/src/adapters/mysql.ts +103 -40
- package/packages/orm/src/adapters/odbc.ts +44 -21
- package/packages/orm/src/adapters/postgres.ts +118 -26
- package/packages/orm/src/adapters/sqlDialect.ts +120 -0
- package/packages/orm/src/adapters/sqlite.ts +60 -24
- package/packages/orm/src/baseModel.ts +135 -40
- package/packages/orm/src/cachedDatabase.ts +43 -19
- package/packages/orm/src/connectTimeout.ts +265 -0
- package/packages/orm/src/database.ts +237 -197
- package/packages/orm/src/databaseResult.ts +65 -13
- package/packages/orm/src/databaseUrl.ts +484 -0
- package/packages/orm/src/docstore.ts +386 -145
- package/packages/orm/src/index.ts +13 -3
- package/packages/orm/src/migration.ts +18 -3
- package/packages/orm/src/queryBuilder.ts +38 -4
- package/packages/orm/src/sqlTranslator.ts +310 -4
- package/packages/orm/src/types.ts +15 -4
- package/types/core/src/ai.d.ts +1 -1
- package/types/core/src/auth.d.ts +28 -5
- package/types/core/src/background.d.ts +3 -3
- package/types/core/src/cache.d.ts +15 -12
- package/types/core/src/dispatchPipeline.d.ts +117 -0
- package/types/core/src/dotenv.d.ts +38 -16
- package/types/core/src/index.d.ts +5 -6
- package/types/core/src/logger.d.ts +93 -16
- package/types/core/src/messenger.d.ts +2 -2
- package/types/core/src/metrics.d.ts +25 -61
- package/types/core/src/middleware.d.ts +134 -11
- package/types/core/src/queue.d.ts +54 -5
- package/types/core/src/queueBackends/kafkaBackend.d.ts +1 -1
- package/types/core/src/queueBackends/liteBackend.d.ts +9 -0
- package/types/core/src/queueBackends/mongoBackend.d.ts +24 -2
- package/types/core/src/queueBackends/rabbitmqBackend.d.ts +3 -3
- package/types/core/src/router.d.ts +14 -3
- package/types/core/src/server.d.ts +15 -0
- package/types/core/src/session.d.ts +87 -2
- package/types/core/src/sessionHandlers/databaseHandler.d.ts +60 -5
- package/types/core/src/sessionHandlers/memcachedHandler.d.ts +60 -0
- package/types/core/src/sessionHandlers/mongoClient.d.ts +16 -5
- package/types/core/src/sessionHandlers/mongoHandler.d.ts +51 -3
- package/types/core/src/sessionHandlers/respClient.d.ts +2 -2
- package/types/core/src/sessionHandlers/sqlClient.d.ts +39 -0
- package/types/core/src/sessionHandlers/syncBridge.d.ts +91 -0
- package/types/core/src/sessionHandlers/syncSocket.d.ts +49 -0
- package/types/core/src/trustedProxy.d.ts +44 -0
- package/types/core/src/types.d.ts +28 -5
- package/types/core/src/websocket.d.ts +26 -0
- package/types/orm/src/adapters/firebird.d.ts +55 -10
- package/types/orm/src/adapters/mongodb.d.ts +2 -2
- package/types/orm/src/adapters/mssql.d.ts +18 -11
- package/types/orm/src/adapters/mysql.d.ts +11 -10
- package/types/orm/src/adapters/odbc.d.ts +9 -12
- package/types/orm/src/adapters/postgres.d.ts +11 -10
- package/types/orm/src/adapters/sqlDialect.d.ts +71 -0
- package/types/orm/src/adapters/sqlite.d.ts +15 -3
- package/types/orm/src/baseModel.d.ts +45 -9
- package/types/orm/src/cachedDatabase.d.ts +18 -5
- package/types/orm/src/connectTimeout.d.ts +100 -0
- package/types/orm/src/database.d.ts +72 -26
- package/types/orm/src/databaseResult.d.ts +24 -0
- package/types/orm/src/databaseUrl.d.ts +125 -0
- package/types/orm/src/docstore.d.ts +102 -43
- package/types/orm/src/index.d.ts +5 -2
- package/types/orm/src/queryBuilder.d.ts +23 -3
- package/types/orm/src/sqlTranslator.d.ts +126 -2
- package/types/orm/src/types.d.ts +14 -4
- package/packages/core/src/sessionHandlers/redisHandler.ts +0 -219
- package/types/core/src/sessionHandlers/redisHandler.d.ts +0 -60
|
@@ -5,7 +5,7 @@ interface SessionData {
|
|
|
5
5
|
[key: string]: unknown;
|
|
6
6
|
}
|
|
7
7
|
export interface DatabaseSessionConfig {
|
|
8
|
-
/** SQLite database file path
|
|
8
|
+
/** SQLite database file path. Explicit config wins over TINA4_DATABASE_URL. */
|
|
9
9
|
dbPath?: string;
|
|
10
10
|
backend?: string;
|
|
11
11
|
path?: string;
|
|
@@ -17,19 +17,74 @@ export interface DatabaseSessionConfig {
|
|
|
17
17
|
redisDb?: number;
|
|
18
18
|
}
|
|
19
19
|
/**
|
|
20
|
-
* Database session handler
|
|
20
|
+
* Database session handler.
|
|
21
21
|
*
|
|
22
22
|
* Stores session data as JSON in a `tina4_session` table.
|
|
23
23
|
* Expiry is checked on read; expired rows are cleaned up lazily.
|
|
24
24
|
*/
|
|
25
25
|
export declare class DatabaseSessionHandler implements SessionHandler {
|
|
26
|
-
private
|
|
26
|
+
private sqliteHandle;
|
|
27
|
+
/** Set for SQLite. Null when this handler talks to a networked engine. */
|
|
28
|
+
private dbPath;
|
|
29
|
+
/** Set for a networked engine. Null for SQLite. */
|
|
30
|
+
private target;
|
|
27
31
|
private initialized;
|
|
32
|
+
/**
|
|
33
|
+
* NO I/O IN A CONSTRUCTOR (ADR-0021).
|
|
34
|
+
*
|
|
35
|
+
* This used to run `new DatabaseSync(dbPath)` and a `PRAGMA journal_mode =
|
|
36
|
+
* WAL` right here. Both are real work against real storage: opening the
|
|
37
|
+
* database CREATES the file, and switching to WAL creates its `-wal` and
|
|
38
|
+
* `-shm` siblings. Measured from a clean temp cwd, merely constructing this
|
|
39
|
+
* handler left three files on disk before a single session was ever read or
|
|
40
|
+
* written.
|
|
41
|
+
*
|
|
42
|
+
* A constructor sits OUTSIDE the log-loud-and-degrade policy, so nothing it
|
|
43
|
+
* does can be logged, degraded, or re-raised by TINA4_SESSION_STRICT - the one
|
|
44
|
+
* place the policy cannot protect is the first thing that runs.
|
|
45
|
+
*
|
|
46
|
+
* Everything below is pure string work. Resolving the target parses a URL;
|
|
47
|
+
* refusing an unsupported engine is a CONFIGURATION error that must still be
|
|
48
|
+
* loud at construction. The database - file or socket - is opened on first
|
|
49
|
+
* use. Going multi-engine is the change most likely to reintroduce
|
|
50
|
+
* constructor-time I/O, which is why test/sessionHandlerConstruction.test.ts
|
|
51
|
+
* measures a real filesystem and a real listening socket rather than trusting
|
|
52
|
+
* this comment.
|
|
53
|
+
*/
|
|
28
54
|
constructor(config?: DatabaseSessionConfig);
|
|
55
|
+
/** Open the SQLite database on FIRST USE, not at construction. */
|
|
56
|
+
private get sqlite();
|
|
57
|
+
private get engine();
|
|
29
58
|
/**
|
|
30
|
-
*
|
|
59
|
+
* Decide, from TINA4_DATABASE_URL, which engine this handler talks to.
|
|
60
|
+
*
|
|
61
|
+
* A NON-SQLITE URL NOW WORKS. It used to throw, because the handler drove
|
|
62
|
+
* `node:sqlite` directly and had no way to reach anything else; the async
|
|
63
|
+
* drivers now ride the sync bridge, so the reason for the refusal is gone.
|
|
64
|
+
*
|
|
65
|
+
* AN UNSUPPORTED ENGINE STILL REFUSES, and that half is not negotiable. The
|
|
66
|
+
* original defect was worse than a refusal: an unrecognised URL fell through
|
|
67
|
+
* to the literal default `"data/tina4_sessions.db"`, so
|
|
68
|
+
* `TINA4_DATABASE_URL=postgres://...` with `TINA4_SESSION_BACKEND=database`
|
|
69
|
+
* round-tripped happily while writing SQLite files into the process working
|
|
70
|
+
* directory. Measured from a clean temp cwd: round-trip true, and `data/`
|
|
71
|
+
* contained `tina4_sessions.db`, `-shm` and `-wal`. Every horizontally-scaled
|
|
72
|
+
* instance therefore had its own private session store and a user was logged
|
|
73
|
+
* out on every request that landed elsewhere.
|
|
74
|
+
*
|
|
75
|
+
* This is the same rule `resolveBackend()` applies one layer up, where an
|
|
76
|
+
* unknown backend name raises rather than falling through to disk.
|
|
77
|
+
*
|
|
78
|
+
* @throws Error naming the offending scheme and the engines this backend
|
|
79
|
+
* speaks. The URL itself is NEVER in the message - it may carry a
|
|
80
|
+
* password.
|
|
31
81
|
*/
|
|
32
|
-
private
|
|
82
|
+
private resolveTarget;
|
|
83
|
+
private unsupportedEngine;
|
|
84
|
+
/** Run a statement that returns rows. */
|
|
85
|
+
private query;
|
|
86
|
+
/** Run a statement that returns nothing. */
|
|
87
|
+
private exec;
|
|
33
88
|
/**
|
|
34
89
|
* Ensure the session table exists (called once on first use).
|
|
35
90
|
*/
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { SessionHandler } from "../session.js";
|
|
2
|
+
interface SessionData {
|
|
3
|
+
_created: number;
|
|
4
|
+
_accessed: number;
|
|
5
|
+
[key: string]: unknown;
|
|
6
|
+
}
|
|
7
|
+
export interface MemcachedSessionConfig {
|
|
8
|
+
host?: string;
|
|
9
|
+
port?: number;
|
|
10
|
+
prefix?: string;
|
|
11
|
+
ttl?: number;
|
|
12
|
+
backend?: string;
|
|
13
|
+
path?: string;
|
|
14
|
+
}
|
|
15
|
+
export declare class MemcachedSessionHandler implements SessionHandler {
|
|
16
|
+
private host;
|
|
17
|
+
private port;
|
|
18
|
+
private prefix;
|
|
19
|
+
private ttl;
|
|
20
|
+
constructor(config?: MemcachedSessionConfig);
|
|
21
|
+
private key;
|
|
22
|
+
/**
|
|
23
|
+
* Run one memcached command synchronously and return the raw reply.
|
|
24
|
+
*
|
|
25
|
+
* Delegates to the shared persistent-connection transport (syncSocket) rather
|
|
26
|
+
* than spawning a child per command: that cost a process spawn plus a fresh
|
|
27
|
+
* TCP connection every time (p50 41ms, p99 487ms) and its tail tripped the
|
|
28
|
+
* deadline under load — the same defect that made the Valkey session tests
|
|
29
|
+
* flaky, which this handler inherited on the day it was written.
|
|
30
|
+
*
|
|
31
|
+
* @throws Error on any transport failure — never swallowed to an empty
|
|
32
|
+
* result, because for a session an outage must be distinguishable
|
|
33
|
+
* from "no session yet".
|
|
34
|
+
*/
|
|
35
|
+
private command;
|
|
36
|
+
read(sessionId: string): SessionData | null;
|
|
37
|
+
/**
|
|
38
|
+
* Convert a ttl in SECONDS to memcached's dual-meaning exptime field.
|
|
39
|
+
*
|
|
40
|
+
* memcached documents exptime as RELATIVE seconds up to 2592000 (30 days),
|
|
41
|
+
* and as an ABSOLUTE UNIX TIMESTAMP for anything larger. Sending a raw ttl of
|
|
42
|
+
* 2592001 therefore does not mean "30 days and one second" - it means
|
|
43
|
+
* 1970-01-31, which is already past, so the item expires the instant it is
|
|
44
|
+
* stored. memcached still replies STORED, so the write looks successful and
|
|
45
|
+
* the very next read is a miss: a silent logout on every request.
|
|
46
|
+
*
|
|
47
|
+
* Measured against real memcached 1.6.45: ttl=2592000 survives, ttl=2592001
|
|
48
|
+
* vanishes instantly.
|
|
49
|
+
*
|
|
50
|
+
* We CONVERT rather than CLAMP. Clamping a 60-day session down to 30 days
|
|
51
|
+
* would silently shorten a lifetime the operator explicitly asked to be
|
|
52
|
+
* longer, which is the same class of lie in the other direction.
|
|
53
|
+
*/
|
|
54
|
+
private expTime;
|
|
55
|
+
write(sessionId: string, data: SessionData, ttl: number): void;
|
|
56
|
+
destroy(sessionId: string): void;
|
|
57
|
+
/** No-op — memcached expires its own keys via the TTL set on write. */
|
|
58
|
+
gc(_maxLifetime: number): void;
|
|
59
|
+
}
|
|
60
|
+
export {};
|
|
@@ -4,21 +4,32 @@ export interface MongoTarget {
|
|
|
4
4
|
database: string;
|
|
5
5
|
collection: string;
|
|
6
6
|
}
|
|
7
|
-
/**
|
|
7
|
+
/**
|
|
8
|
+
* Command args: `filter` (always), plus `data`/`expires_at`/`last_accessed` for
|
|
9
|
+
* an update.
|
|
10
|
+
*
|
|
11
|
+
* `expires_at` is an ABSOLUTE epoch-seconds deadline computed at write time. It
|
|
12
|
+
* is what makes the session actually expire: this handler previously stored no
|
|
13
|
+
* expiry at all, created no TTL index, and never consulted anything on read, so
|
|
14
|
+
* MongoDB sessions lived forever.
|
|
15
|
+
*/
|
|
8
16
|
export interface MongoCommandArgs {
|
|
9
17
|
filter: Record<string, unknown>;
|
|
10
18
|
data?: unknown;
|
|
19
|
+
expires_at?: number;
|
|
11
20
|
last_accessed?: number;
|
|
12
21
|
}
|
|
13
22
|
/**
|
|
14
|
-
* Run a session Mongo command synchronously
|
|
23
|
+
* Run a session Mongo command synchronously.
|
|
15
24
|
*
|
|
16
25
|
* - command "find" -> the matched document as a JSON string, or "__EMPTY__".
|
|
17
26
|
* - command "update" -> "__OK__" (upsert of `{_id, data, last_accessed}`).
|
|
18
27
|
* - command "delete" -> "__OK__".
|
|
19
28
|
*
|
|
20
|
-
* THROWS `<label> command failed: ...` on a transport failure (server
|
|
21
|
-
* timeout) OR a Mongo command error (`ok != 1`), so the Session
|
|
22
|
-
* log-loud + degrade (or re-throw under strict mode).
|
|
29
|
+
* THROWS `<label> command failed: ...` on a transport failure (server
|
|
30
|
+
* unreachable, timeout) OR a Mongo command error (`ok != 1`), so the Session
|
|
31
|
+
* boundary can log-loud + degrade (or re-throw under strict mode). A genuine
|
|
32
|
+
* miss is NOT a failure and comes back as "__EMPTY__" — collapsing the two is
|
|
33
|
+
* how a dead backend silently logs every user out.
|
|
23
34
|
*/
|
|
24
35
|
export declare function mongoCommandSync(target: MongoTarget, command: "find" | "update" | "delete", args: MongoCommandArgs, label?: string): string;
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* TINA4_SESSION_MONGO_URI (overrides host/port if set)
|
|
11
11
|
* TINA4_SESSION_MONGO_USERNAME (optional)
|
|
12
12
|
* TINA4_SESSION_MONGO_PASSWORD (optional)
|
|
13
|
-
* TINA4_SESSION_MONGO_DB (default: "
|
|
13
|
+
* TINA4_SESSION_MONGO_DB (default: "tina4")
|
|
14
14
|
* TINA4_SESSION_MONGO_COLLECTION (default: "sessions")
|
|
15
15
|
*/
|
|
16
16
|
import type { SessionHandler } from "../session.js";
|
|
@@ -51,11 +51,59 @@ export declare class MongoSessionHandler implements SessionHandler {
|
|
|
51
51
|
private password;
|
|
52
52
|
private database;
|
|
53
53
|
private collection;
|
|
54
|
+
private hostExplicit;
|
|
55
|
+
private portExplicit;
|
|
56
|
+
private uriExplicit;
|
|
54
57
|
constructor(config?: MongoSessionConfig);
|
|
55
|
-
/**
|
|
58
|
+
/**
|
|
59
|
+
* Resolve the effective host/port (honours a configured mongodb:// URI).
|
|
60
|
+
*
|
|
61
|
+
* PRECEDENCE, and it runs the way every other resolver in Tina4 runs -
|
|
62
|
+
* EXPLICIT CONFIGURATION BEATS THE ENVIRONMENT:
|
|
63
|
+
*
|
|
64
|
+
* 1. an explicitly passed `uri` - the caller named a complete address
|
|
65
|
+
* 2. an explicitly passed host/port - per field, and an ENV uri may not touch them
|
|
66
|
+
* 3. TINA4_SESSION_MONGO_URI / _URL - the ambient address
|
|
67
|
+
* 4. TINA4_SESSION_MONGO_HOST/_PORT - ambient parts
|
|
68
|
+
* 5. 127.0.0.1:27017
|
|
69
|
+
*
|
|
70
|
+
* THE DEFECT THIS FIXES, measured 2026-08-05 on the lab host: the URI won
|
|
71
|
+
* UNCONDITIONALLY, including over an argument the caller had just passed by
|
|
72
|
+
* hand. With TINA4_SESSION_MONGO_URI=mongodb://127.0.0.1:27017/tina4_node
|
|
73
|
+
* exported - an entirely ordinary deployment setting -
|
|
74
|
+
*
|
|
75
|
+
* new MongoSessionHandler({ host: "127.0.0.1", port: 59999 })
|
|
76
|
+
*
|
|
77
|
+
* resolved to 127.0.0.1:27017. The handler dialled a DIFFERENT SERVER from the
|
|
78
|
+
* one it was told to use, said nothing, and a read against it came back null:
|
|
79
|
+
* indistinguishable from a genuine miss. So an app that points a handler at one
|
|
80
|
+
* Mongo while the environment names another writes its sessions to the wrong
|
|
81
|
+
* server, and the backend-failure policy cannot fire because nothing failed.
|
|
82
|
+
*
|
|
83
|
+
* It also made two suites report a framework contract as broken - the
|
|
84
|
+
* unreachable-server-must-throw cases in sessionHandlers and
|
|
85
|
+
* sessionMongoRawProtocol never reached the dead port at all, so they measured
|
|
86
|
+
* a live server and got a miss. Those cases were RIGHT; this was the bug they
|
|
87
|
+
* were catching.
|
|
88
|
+
*
|
|
89
|
+
* Same class as the TINA4_QUEUE_URL precedence inversion fixed in PHP's
|
|
90
|
+
* Queue::resolveMongoConfig earlier the same day: environment quietly beating
|
|
91
|
+
* an explicit argument.
|
|
92
|
+
*/
|
|
56
93
|
private target;
|
|
57
94
|
read(sessionId: string): SessionData | null;
|
|
58
|
-
|
|
95
|
+
/**
|
|
96
|
+
* Write session data.
|
|
97
|
+
*
|
|
98
|
+
* The ttl is consumed HERE, at write time, and baked into an absolute deadline,
|
|
99
|
+
* so nothing at read time needs to know what the ttl was. The parameter used to
|
|
100
|
+
* be named `_ttl` and thrown away.
|
|
101
|
+
*
|
|
102
|
+
* @param sessionId - the session id
|
|
103
|
+
* @param data - the payload to store
|
|
104
|
+
* @param ttl - lifetime in seconds; 0 or less means never expires
|
|
105
|
+
*/
|
|
106
|
+
write(sessionId: string, data: SessionData, ttl?: number): void;
|
|
59
107
|
destroy(sessionId: string): void;
|
|
60
108
|
}
|
|
61
109
|
export {};
|
|
@@ -16,7 +16,7 @@ export interface RespTarget {
|
|
|
16
16
|
* `<label> error: ...`. A rejected handshake is a transport failure, not a
|
|
17
17
|
* result, so it is surfaced ahead of the command reply.
|
|
18
18
|
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
19
|
+
* The miss/failure split is the whole contract: collapsing them is how a dead
|
|
20
|
+
* backend silently logs every user out instead of surfacing an outage.
|
|
21
21
|
*/
|
|
22
22
|
export declare function respCommandSync(target: RespTarget, args: string[], label?: string): string;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The SQL engines the database session backend speaks.
|
|
3
|
+
*
|
|
4
|
+
* This IS the invariant: it is the engine set of the ORM Database layer minus
|
|
5
|
+
* the two non-SQL entries (mongodb has its own session backend, odbc has no
|
|
6
|
+
* session story in any of the four frameworks). Naming it once means the
|
|
7
|
+
* refusal message and the dispatch can never disagree about what is supported.
|
|
8
|
+
*/
|
|
9
|
+
export declare const SQL_SESSION_ENGINES: readonly ["sqlite", "postgres", "mysql", "mssql", "firebird"];
|
|
10
|
+
export type SqlSessionEngine = (typeof SQL_SESSION_ENGINES)[number];
|
|
11
|
+
/** The engines that need the bridge - everything except already-sync SQLite. */
|
|
12
|
+
export type BridgedEngine = Exclude<SqlSessionEngine, "sqlite">;
|
|
13
|
+
/**
|
|
14
|
+
* A connection target for the worker.
|
|
15
|
+
*
|
|
16
|
+
* A PLAIN object, deliberately - never a `DatabaseUrl`. That class carries a
|
|
17
|
+
* cleartext password and its own docblock forbids persisting it across a
|
|
18
|
+
* structured-clone boundary (test/databaseUrlRedaction.test.ts enforces it).
|
|
19
|
+
* The worker genuinely needs credentials to authenticate, so it gets the fields
|
|
20
|
+
* it needs and nothing that renders itself.
|
|
21
|
+
*/
|
|
22
|
+
export interface SqlTarget {
|
|
23
|
+
engine: BridgedEngine;
|
|
24
|
+
host: string;
|
|
25
|
+
port: number;
|
|
26
|
+
database: string;
|
|
27
|
+
username: string | null;
|
|
28
|
+
password: string | null;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Run one SQL statement synchronously against a networked engine.
|
|
32
|
+
*
|
|
33
|
+
* @returns the result rows - always an array, empty for a write or DDL.
|
|
34
|
+
* @throws Error on ANY driver failure (server unreachable, bad credentials,
|
|
35
|
+
* SQL error). It is never swallowed into an empty result: for a session
|
|
36
|
+
* store, "the database is down" and "no session yet" must stay
|
|
37
|
+
* distinguishable, or a dead backend silently logs every user out.
|
|
38
|
+
*/
|
|
39
|
+
export declare function sqlCommandSync(target: SqlTarget, sql: string, params?: unknown[], label?: string): Record<string, unknown>[];
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tina4 sync-over-async bridge — call an ASYNC worker from SYNCHRONOUS code.
|
|
3
|
+
*
|
|
4
|
+
* The SessionHandler interface is synchronous; every backend client Node offers
|
|
5
|
+
* (node:net, the mongodb driver) is async-only. The old resolution was to run
|
|
6
|
+
* each command in a short-lived `node -e` child and block on execFileSync. That
|
|
7
|
+
* works but pays a process spawn — and a fresh connection, and for Mongo a fresh
|
|
8
|
+
* driver load — PER COMMAND. Measured on this machine: spawn min 38ms / p50 41ms
|
|
9
|
+
* / p99 487ms, with a bare TCP connect adding a ~0.5-0.9s tail of its own. That
|
|
10
|
+
* tail tripped the child's deadline under load, which is what made the Valkey
|
|
11
|
+
* session tests flaky with the signature "a value that was just written reads
|
|
12
|
+
* back null" — the write timed out for the caller while still landing on the
|
|
13
|
+
* server.
|
|
14
|
+
*
|
|
15
|
+
* This is the ONE piece of plumbing that replaces it. A Worker thread keeps its
|
|
16
|
+
* own event loop, so it can do ordinary async I/O and hold a long-lived
|
|
17
|
+
* connection. The caller hands it a message with postMessage (delivered on the
|
|
18
|
+
* WORKER's loop, so a blocked main thread cannot deadlock it), then blocks in
|
|
19
|
+
* Atomics.wait until the worker writes a reply into a SharedArrayBuffer and
|
|
20
|
+
* Atomics.notify wakes it.
|
|
21
|
+
*
|
|
22
|
+
* Every session backend that needs sync-over-async uses this — RESP
|
|
23
|
+
* (Redis/Valkey), memcached's text protocol, and MongoDB — so the blocking
|
|
24
|
+
* handshake exists once rather than once per backend.
|
|
25
|
+
*/
|
|
26
|
+
import { Worker } from "node:worker_threads";
|
|
27
|
+
/** Reply status, written by the worker into control[IDX_STATUS]. */
|
|
28
|
+
export declare const STATUS_OK = 0;
|
|
29
|
+
/** A healthy server answered with an error (RESP `-ERR`, a Mongo command error). */
|
|
30
|
+
export declare const STATUS_ERROR = 1;
|
|
31
|
+
/** A genuine miss — no such key/document. NOT a failure. */
|
|
32
|
+
export declare const STATUS_NIL = 2;
|
|
33
|
+
/** The reply did not fit the shared buffer. */
|
|
34
|
+
export declare const STATUS_TOO_LARGE = 3;
|
|
35
|
+
/** Connection/socket/driver failure, as opposed to an answer from a healthy server. */
|
|
36
|
+
export declare const STATUS_TRANSPORT = 4;
|
|
37
|
+
export declare const IDX_SEQ = 0;
|
|
38
|
+
export declare const IDX_LENGTH = 1;
|
|
39
|
+
export declare const IDX_STATUS = 2;
|
|
40
|
+
export declare const IDX_READY = 3;
|
|
41
|
+
/**
|
|
42
|
+
* Reply payload ceiling. A session document beyond this is pathological, and a
|
|
43
|
+
* fixed buffer keeps the fast path allocation-free. A worker reports
|
|
44
|
+
* STATUS_TOO_LARGE rather than truncating — a silently truncated session would
|
|
45
|
+
* deserialise into garbage.
|
|
46
|
+
*/
|
|
47
|
+
export declare const DATA_BYTES: number;
|
|
48
|
+
/** How long a caller blocks before giving up on a reply. */
|
|
49
|
+
export declare const REPLY_TIMEOUT_MS = 5000;
|
|
50
|
+
/**
|
|
51
|
+
* How long the FIRST caller waits for a brand-new worker to come up.
|
|
52
|
+
*
|
|
53
|
+
* Boot and the command round-trip must not share one budget. Cold start is
|
|
54
|
+
* normally ~27ms, but on a loaded machine (the session suite spawns a batch of
|
|
55
|
+
* blocking Mongo children immediately beforehand) it can stretch — and when it
|
|
56
|
+
* ate into the 5s command budget the very first Valkey write failed with
|
|
57
|
+
* "timed out after 5000ms", which read exactly like the flake this transport
|
|
58
|
+
* was built to remove. Boot gets its own generous budget so the per-command
|
|
59
|
+
* timeout can stay tight and mean what it says.
|
|
60
|
+
*/
|
|
61
|
+
export declare const BOOT_TIMEOUT_MS = 15000;
|
|
62
|
+
/**
|
|
63
|
+
* The worker-side helper, injected into every worker body. Kept here so the
|
|
64
|
+
* reply protocol is written once: a worker only has to call
|
|
65
|
+
* `__reply(status, payload)` and never touches Atomics itself.
|
|
66
|
+
*/
|
|
67
|
+
export declare const WORKER_REPLY_HELPER = "\nconst __control = new Int32Array(workerData.controlBuffer);\nconst __data = new Uint8Array(workerData.dataBuffer);\nconst __encoder = new TextEncoder();\n\n// Announce readiness the moment this thread is executing. The parent blocks on\n// this before its first command, so a slow boot can never be mistaken for a\n// slow command. This is signalled by the worker's OWN bootstrap and needs no\n// message from the parent, so it is safe even while the parent is blocked.\nAtomics.store(__control, 3, 1);\nAtomics.notify(__control, 3);\n\nfunction __reply(status, payload) {\n let length = 0;\n if (payload !== undefined && payload !== null && status !== 2) {\n const bytes = __encoder.encode(String(payload));\n if (bytes.length > __data.length) {\n Atomics.store(__control, 2, 3);\n Atomics.store(__control, 1, 0);\n Atomics.add(__control, 0, 1);\n Atomics.notify(__control, 0);\n return;\n }\n __data.set(bytes, 0);\n length = bytes.length;\n }\n Atomics.store(__control, 2, status);\n Atomics.store(__control, 1, length);\n Atomics.add(__control, 0, 1);\n Atomics.notify(__control, 0);\n}\n";
|
|
68
|
+
export interface BridgeReply {
|
|
69
|
+
status: number;
|
|
70
|
+
payload: string;
|
|
71
|
+
}
|
|
72
|
+
export interface Bridge {
|
|
73
|
+
/** Send a message to the worker and BLOCK until it replies. */
|
|
74
|
+
call(message: unknown, label: string): BridgeReply;
|
|
75
|
+
worker: Worker;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Get (or create) the bridge for a key. One worker per key, created once and
|
|
79
|
+
* unref'd so it can never hold the process open.
|
|
80
|
+
*
|
|
81
|
+
* @param key Identity of the connection target — same key, same worker
|
|
82
|
+
* @param workerSource The worker body; WORKER_REPLY_HELPER is prepended for it
|
|
83
|
+
* @param workerData Passed to the worker verbatim (plus the shared buffers)
|
|
84
|
+
*/
|
|
85
|
+
export declare function getBridge(key: string, workerSource: string, workerData: Record<string, unknown>): Bridge;
|
|
86
|
+
/**
|
|
87
|
+
* Terminate every worker. Tests and short-lived scripts call this so a spawned
|
|
88
|
+
* worker never outlives the work that created it — "reap what you spawn". Normal
|
|
89
|
+
* apps do not need it: the workers are unref'd.
|
|
90
|
+
*/
|
|
91
|
+
export declare function closeBridges(): void;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tina4 synchronous socket transport — ONE persistent connection per target.
|
|
3
|
+
*
|
|
4
|
+
* Serves the RESP backends (Redis, Valkey) and memcached's text protocol. The
|
|
5
|
+
* sync-over-async plumbing lives in syncBridge; this file is only the socket
|
|
6
|
+
* worker and the two protocol entry points.
|
|
7
|
+
*
|
|
8
|
+
* See syncBridge for WHY: each command used to run in a short-lived `node -e`
|
|
9
|
+
* child, paying a process spawn AND a fresh TCP connection every time (spawn p50
|
|
10
|
+
* 41ms / p99 487ms, connect tail 0.5-0.9s), and that tail tripped the child's
|
|
11
|
+
* deadline under load — the cause of the Valkey session flake.
|
|
12
|
+
*
|
|
13
|
+
* Reconnection is the worker's business: a dropped socket is re-established on
|
|
14
|
+
* the next command rather than surfacing as a caller error.
|
|
15
|
+
*/
|
|
16
|
+
import { closeBridges, DATA_BYTES } from "./syncBridge.js";
|
|
17
|
+
export interface SyncSocketTarget {
|
|
18
|
+
host: string;
|
|
19
|
+
port: number;
|
|
20
|
+
/** AUTH password (empty/undefined = no AUTH sent). */
|
|
21
|
+
password?: string;
|
|
22
|
+
/** SELECT db index (0/undefined = no SELECT sent). */
|
|
23
|
+
db?: number;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Run a single RESP command synchronously and return the reply.
|
|
27
|
+
*
|
|
28
|
+
* - A genuine nil / key-miss returns `""` (callers treat "" as "no session yet").
|
|
29
|
+
* - A transport FAILURE (unreachable, timeout, connection closed) THROWS
|
|
30
|
+
* `<label> command failed: ...`.
|
|
31
|
+
* - A RESP error reply — including a rejected AUTH/SELECT — THROWS
|
|
32
|
+
* `<label> error: ...`.
|
|
33
|
+
*
|
|
34
|
+
* The miss/failure split is the whole contract: collapsing them is how a dead
|
|
35
|
+
* backend silently logs every user out instead of surfacing an outage. A dead
|
|
36
|
+
* socket and a healthy server saying "-ERR" are different failures and read
|
|
37
|
+
* differently in a log, so they keep different wording.
|
|
38
|
+
*/
|
|
39
|
+
export declare function syncCommand(target: SyncSocketTarget, args: string[], label?: string): string;
|
|
40
|
+
/**
|
|
41
|
+
* Run a raw text-protocol command (memcached) over the same persistent channel.
|
|
42
|
+
*
|
|
43
|
+
* The reply is whatever arrived once one of `terminators` appears — a text
|
|
44
|
+
* protocol carries no length prefix, so the caller names its own end markers.
|
|
45
|
+
* Failure semantics match {@link syncCommand}.
|
|
46
|
+
*/
|
|
47
|
+
export declare function syncTextCommand(target: SyncSocketTarget, payload: string, terminators: string[], label?: string): string;
|
|
48
|
+
/** Re-exported so callers do not need to know the bridge exists. */
|
|
49
|
+
export { closeBridges as closeSyncSockets, DATA_BYTES };
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which upstream hops are allowed to speak for a client (ADR-0019).
|
|
3
|
+
*
|
|
4
|
+
* X-Forwarded-For is written by whoever sends it. Reading it unconditionally
|
|
5
|
+
* lets any client choose its own rate-limit bucket, and - worse - choose
|
|
6
|
+
* SOMEONE ELSE'S, which is a starvation primitive against a third party. So
|
|
7
|
+
* the forwarding headers are believed only when the raw socket peer is a proxy
|
|
8
|
+
* the operator has explicitly declared.
|
|
9
|
+
*
|
|
10
|
+
* Configured by TINA4_TRUSTED_PROXIES: comma-separated exact addresses and/or
|
|
11
|
+
* CIDR ranges, IPv4 and IPv6, e.g. "10.0.0.0/8, 192.168.1.5, ::1, fd00::/8".
|
|
12
|
+
* Empty or unset means trust NOTHING, which is the secure default.
|
|
13
|
+
*
|
|
14
|
+
* Zero-dependency: node: has no CIDR matcher, so the packing and prefix
|
|
15
|
+
* comparison are done here over plain byte arrays.
|
|
16
|
+
*/
|
|
17
|
+
/** [packed network address, prefix bits] */
|
|
18
|
+
type Network = [Uint8Array, number];
|
|
19
|
+
/**
|
|
20
|
+
* Pack an address to its bytes, unmapping IPv4-in-IPv6.
|
|
21
|
+
*
|
|
22
|
+
* A peer arriving as ::ffff:10.0.0.1 must match an allow-list entry of
|
|
23
|
+
* 10.0.0.0/8 - dual-stack listeners hand out the mapped form routinely, and
|
|
24
|
+
* Node's socket.remoteAddress is a common source of it.
|
|
25
|
+
*/
|
|
26
|
+
export declare function packAddress(value: string): Uint8Array | null;
|
|
27
|
+
/**
|
|
28
|
+
* The configured trusted-proxy networks, parsed once per distinct config value.
|
|
29
|
+
*/
|
|
30
|
+
export declare function trustedProxyNetworks(): Network[];
|
|
31
|
+
/** Is this address a configured trusted proxy? */
|
|
32
|
+
export declare function isTrustedProxy(address: string): boolean;
|
|
33
|
+
/** Reset the parsed cache. Test hook - config normally changes only at boot. */
|
|
34
|
+
export declare function resetTrustedProxyCache(): void;
|
|
35
|
+
/**
|
|
36
|
+
* Resolve the client IP, honouring forwarding headers ONLY behind a trusted proxy.
|
|
37
|
+
*
|
|
38
|
+
* Within the chain the RIGHTMOST entry that is not itself a trusted proxy wins.
|
|
39
|
+
* Taking the leftmost would be no safer than trusting the header outright: a
|
|
40
|
+
* client can prepend its own hop, and the proxy appends rather than replaces.
|
|
41
|
+
* This is the algorithm Rack uses (Rack::Request#ip).
|
|
42
|
+
*/
|
|
43
|
+
export declare function resolveClientIp(headers: Record<string, string | string[] | undefined>, peer: string): string;
|
|
44
|
+
export {};
|
|
@@ -42,10 +42,24 @@ export interface Tina4Request extends IncomingMessage {
|
|
|
42
42
|
url: string;
|
|
43
43
|
body: unknown;
|
|
44
44
|
ip: string;
|
|
45
|
+
/**
|
|
46
|
+
* Raw socket peer address - NEVER honours X-Forwarded-For (which any
|
|
47
|
+
* caller can spoof), so it can be trusted for security decisions.
|
|
48
|
+
* Empty for in-process / synthetic requests. Parity with Python's
|
|
49
|
+
* request.remote_ip and PHP's Request::$remoteIp.
|
|
50
|
+
*/
|
|
51
|
+
remoteIp: string;
|
|
45
52
|
files: Record<string, UploadedFile | UploadedFile[]>;
|
|
46
53
|
cookies: Record<string, string>;
|
|
47
54
|
contentType: string;
|
|
48
|
-
|
|
55
|
+
/**
|
|
56
|
+
* NULL when the session backend was unusable for this request (ADR-0021).
|
|
57
|
+
* The request path logs the failure and degrades rather than 500-ing, so a
|
|
58
|
+
* request really can arrive without a session and the type has to say so -
|
|
59
|
+
* `req.session?.get(...)`. Parity with Python, where `request.session` is
|
|
60
|
+
* `None` on the same path.
|
|
61
|
+
*/
|
|
62
|
+
session: Tina4Session | null;
|
|
49
63
|
user?: Record<string, unknown>;
|
|
50
64
|
/** Get a specific header value by name (case-insensitive). */
|
|
51
65
|
header(name: string): string | undefined;
|
|
@@ -179,8 +193,14 @@ export interface Tina4Config {
|
|
|
179
193
|
}
|
|
180
194
|
export type Middleware = (req: Tina4Request, res: Tina4Response, next: () => void) => void | Promise<void>;
|
|
181
195
|
/**
|
|
182
|
-
* A
|
|
183
|
-
*
|
|
196
|
+
* A class-based middleware: static `beforeX` / `afterX` hooks discovered by
|
|
197
|
+
* `MiddlewareRunner`. The hooks are a NAMING CONVENTION, not an interface —
|
|
198
|
+
* exactly as in Python/PHP/Ruby — so this is deliberately just "a class".
|
|
199
|
+
*/
|
|
200
|
+
export type MiddlewareClass = abstract new (...args: never[]) => unknown;
|
|
201
|
+
/**
|
|
202
|
+
* A route middleware entry: a middleware function, a middleware CLASS, or a
|
|
203
|
+
* string spec resolved by the router to a built-in middleware.
|
|
184
204
|
*
|
|
185
205
|
* String forms (parity with Python/PHP/Ruby):
|
|
186
206
|
* "ResponseCache" → responseCache() with the default/env TTL
|
|
@@ -188,9 +208,12 @@ export type Middleware = (req: Tina4Request, res: Tina4Response, next: () => voi
|
|
|
188
208
|
*
|
|
189
209
|
* The router resolves string specs to middleware functions when the route
|
|
190
210
|
* runs, so callers can register a response-cache middleware without importing
|
|
191
|
-
* `responseCache`.
|
|
211
|
+
* `responseCache`. A CLASS runs its beforeX/afterX hooks through the same
|
|
212
|
+
* `MiddlewareRunner` as global middleware — Python and PHP already ran
|
|
213
|
+
* per-route class hooks; Node used to invoke every spec as `mw(req, res, next)`
|
|
214
|
+
* and a class was therefore inert.
|
|
192
215
|
*/
|
|
193
|
-
export type MiddlewareSpec = Middleware | string;
|
|
216
|
+
export type MiddlewareSpec = Middleware | MiddlewareClass | string;
|
|
194
217
|
/**
|
|
195
218
|
* Handler for WebSocket routes.
|
|
196
219
|
* connection — object with send/broadcast/close methods and route params.
|
|
@@ -58,6 +58,13 @@ export interface WebSocketClient {
|
|
|
58
58
|
/**
|
|
59
59
|
* Compute Sec-WebSocket-Accept from Sec-WebSocket-Key per RFC 6455.
|
|
60
60
|
*/
|
|
61
|
+
/**
|
|
62
|
+
* Build an RFC 6455 close frame carrying a status code and optional reason.
|
|
63
|
+
*
|
|
64
|
+
* The code is a big-endian uint16 in the first two payload bytes (s5.5.1), so
|
|
65
|
+
* a peer that only reads the code still gets a valid one.
|
|
66
|
+
*/
|
|
67
|
+
export declare function buildCloseFrame(code: number, reason?: string): Buffer;
|
|
61
68
|
export declare function computeAcceptKey(key: string): string;
|
|
62
69
|
/**
|
|
63
70
|
* Return true if the upgrade request's `Origin` is permitted.
|
|
@@ -293,6 +300,18 @@ declare class WsRouteManager {
|
|
|
293
300
|
get size(): number;
|
|
294
301
|
add(state: RouteConnState): void;
|
|
295
302
|
remove(id: string): void;
|
|
303
|
+
/**
|
|
304
|
+
* Close every open route connection with an RFC 6455 status code.
|
|
305
|
+
*
|
|
306
|
+
* Used by graceful shutdown with 1001 "going away", which is the code RFC
|
|
307
|
+
* 6455 s7.4.1 defines for exactly this case ("a server going down"). A
|
|
308
|
+
* client that is told 1001 can reconnect on a schedule; a socket that just
|
|
309
|
+
* vanishes looks like a network fault and produces an error instead.
|
|
310
|
+
*
|
|
311
|
+
* Best-effort per connection: a dead socket is skipped, never aborting the
|
|
312
|
+
* rest. Returns how many were signalled.
|
|
313
|
+
*/
|
|
314
|
+
closeAll(code?: number, reason?: string): number;
|
|
296
315
|
/** Send a text frame to one connection (best-effort). */
|
|
297
316
|
sendTo(id: string, message: string): void;
|
|
298
317
|
/** Broadcast a text frame to every connection on the same path. */
|
|
@@ -355,6 +374,13 @@ declare class DevReloadWsManager {
|
|
|
355
374
|
setTracker(onAdd: (remoteAddress: string, path: string) => string, onRemove: (id: string) => void): void;
|
|
356
375
|
/** Number of currently-open dev-reload sockets (test/diagnostic helper). */
|
|
357
376
|
get size(): number;
|
|
377
|
+
/**
|
|
378
|
+
* Close every open dev-reload socket with an RFC 6455 status code (1001
|
|
379
|
+
* "going away" on shutdown). The browser client reconnects on a schedule
|
|
380
|
+
* when it is told the server went away, rather than reporting an error.
|
|
381
|
+
* Best-effort per socket. Returns how many were signalled.
|
|
382
|
+
*/
|
|
383
|
+
closeAll(code?: number, reason?: string): number;
|
|
358
384
|
/**
|
|
359
385
|
* Accept a WebSocket upgrade on `/__dev_reload` and hold the socket open.
|
|
360
386
|
*
|