tina4-nodejs 3.13.94 → 3.13.96
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 +158 -30
- package/README.md +1 -1
- package/package.json +3 -1
- package/packages/cli/dist/bin.js +30911 -28444
- package/packages/cli/src/commands/metrics.ts +17 -11
- package/packages/cli/src/commands/serve.ts +10 -9
- package/packages/core/dist/index.js +30810 -28261
- package/packages/core/public/css/tina4.min.css +1 -1
- 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 +6 -7
- package/packages/core/src/logger.ts +257 -36
- package/packages/core/src/mcp.ts +1 -1
- package/packages/core/src/messenger.ts +294 -106
- 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 +34 -16
- package/packages/core/src/response.ts +46 -1
- package/packages/core/src/router.ts +29 -4
- package/packages/core/src/server.ts +886 -421
- 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 +22717 -20168
- 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/autoCrud.ts +12 -10
- 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 +241 -197
- package/packages/orm/src/databaseResult.ts +51 -28
- package/packages/orm/src/databaseUrl.ts +484 -0
- package/packages/orm/src/docstore.ts +386 -145
- package/packages/orm/src/index.ts +13 -6
- package/packages/orm/src/migration.ts +44 -11
- package/packages/orm/src/model.ts +4 -0
- package/packages/orm/src/queryBuilder.ts +47 -6
- package/packages/orm/src/sqlTranslator.ts +310 -4
- package/packages/orm/src/types.ts +21 -77
- package/packages/swagger/dist/index.js +78 -20
- package/packages/swagger/src/generator.ts +172 -29
- 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 +6 -9
- package/types/core/src/logger.d.ts +93 -16
- package/types/core/src/messenger.d.ts +47 -6
- 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 -4
- 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 +78 -28
- package/types/orm/src/databaseResult.d.ts +29 -15
- 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 +6 -4
- package/types/orm/src/migration.d.ts +4 -3
- 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 +21 -38
- package/packages/core/src/scss.ts +0 -623
- package/packages/core/src/sessionHandlers/redisHandler.ts +0 -219
- package/types/core/src/scss.d.ts +0 -19
- package/types/core/src/sessionHandlers/redisHandler.d.ts +0 -60
|
@@ -0,0 +1,190 @@
|
|
|
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
|
+
|
|
28
|
+
/** Reply status, written by the worker into control[IDX_STATUS]. */
|
|
29
|
+
export const STATUS_OK = 0;
|
|
30
|
+
/** A healthy server answered with an error (RESP `-ERR`, a Mongo command error). */
|
|
31
|
+
export const STATUS_ERROR = 1;
|
|
32
|
+
/** A genuine miss — no such key/document. NOT a failure. */
|
|
33
|
+
export const STATUS_NIL = 2;
|
|
34
|
+
/** The reply did not fit the shared buffer. */
|
|
35
|
+
export const STATUS_TOO_LARGE = 3;
|
|
36
|
+
/** Connection/socket/driver failure, as opposed to an answer from a healthy server. */
|
|
37
|
+
export const STATUS_TRANSPORT = 4;
|
|
38
|
+
|
|
39
|
+
export const IDX_SEQ = 0; // bumped by the worker on every reply; what Atomics.wait watches
|
|
40
|
+
export const IDX_LENGTH = 1; // reply byte length
|
|
41
|
+
export const IDX_STATUS = 2; // one of STATUS_*
|
|
42
|
+
export const IDX_READY = 3; // set to 1 by the worker as soon as its loop is running
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Reply payload ceiling. A session document beyond this is pathological, and a
|
|
46
|
+
* fixed buffer keeps the fast path allocation-free. A worker reports
|
|
47
|
+
* STATUS_TOO_LARGE rather than truncating — a silently truncated session would
|
|
48
|
+
* deserialise into garbage.
|
|
49
|
+
*/
|
|
50
|
+
export const DATA_BYTES = 8 * 1024 * 1024;
|
|
51
|
+
|
|
52
|
+
/** How long a caller blocks before giving up on a reply. */
|
|
53
|
+
export const REPLY_TIMEOUT_MS = 5000;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* How long the FIRST caller waits for a brand-new worker to come up.
|
|
57
|
+
*
|
|
58
|
+
* Boot and the command round-trip must not share one budget. Cold start is
|
|
59
|
+
* normally ~27ms, but on a loaded machine (the session suite spawns a batch of
|
|
60
|
+
* blocking Mongo children immediately beforehand) it can stretch — and when it
|
|
61
|
+
* ate into the 5s command budget the very first Valkey write failed with
|
|
62
|
+
* "timed out after 5000ms", which read exactly like the flake this transport
|
|
63
|
+
* was built to remove. Boot gets its own generous budget so the per-command
|
|
64
|
+
* timeout can stay tight and mean what it says.
|
|
65
|
+
*/
|
|
66
|
+
export const BOOT_TIMEOUT_MS = 15000;
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The worker-side helper, injected into every worker body. Kept here so the
|
|
70
|
+
* reply protocol is written once: a worker only has to call
|
|
71
|
+
* `__reply(status, payload)` and never touches Atomics itself.
|
|
72
|
+
*/
|
|
73
|
+
export const WORKER_REPLY_HELPER = `
|
|
74
|
+
const __control = new Int32Array(workerData.controlBuffer);
|
|
75
|
+
const __data = new Uint8Array(workerData.dataBuffer);
|
|
76
|
+
const __encoder = new TextEncoder();
|
|
77
|
+
|
|
78
|
+
// Announce readiness the moment this thread is executing. The parent blocks on
|
|
79
|
+
// this before its first command, so a slow boot can never be mistaken for a
|
|
80
|
+
// slow command. This is signalled by the worker's OWN bootstrap and needs no
|
|
81
|
+
// message from the parent, so it is safe even while the parent is blocked.
|
|
82
|
+
Atomics.store(__control, ${IDX_READY}, 1);
|
|
83
|
+
Atomics.notify(__control, ${IDX_READY});
|
|
84
|
+
|
|
85
|
+
function __reply(status, payload) {
|
|
86
|
+
let length = 0;
|
|
87
|
+
if (payload !== undefined && payload !== null && status !== ${STATUS_NIL}) {
|
|
88
|
+
const bytes = __encoder.encode(String(payload));
|
|
89
|
+
if (bytes.length > __data.length) {
|
|
90
|
+
Atomics.store(__control, ${IDX_STATUS}, ${STATUS_TOO_LARGE});
|
|
91
|
+
Atomics.store(__control, ${IDX_LENGTH}, 0);
|
|
92
|
+
Atomics.add(__control, ${IDX_SEQ}, 1);
|
|
93
|
+
Atomics.notify(__control, ${IDX_SEQ});
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
__data.set(bytes, 0);
|
|
97
|
+
length = bytes.length;
|
|
98
|
+
}
|
|
99
|
+
Atomics.store(__control, ${IDX_STATUS}, status);
|
|
100
|
+
Atomics.store(__control, ${IDX_LENGTH}, length);
|
|
101
|
+
Atomics.add(__control, ${IDX_SEQ}, 1);
|
|
102
|
+
Atomics.notify(__control, ${IDX_SEQ});
|
|
103
|
+
}
|
|
104
|
+
`;
|
|
105
|
+
|
|
106
|
+
export interface BridgeReply {
|
|
107
|
+
status: number;
|
|
108
|
+
payload: string;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export interface Bridge {
|
|
112
|
+
/** Send a message to the worker and BLOCK until it replies. */
|
|
113
|
+
call(message: unknown, label: string): BridgeReply;
|
|
114
|
+
worker: Worker;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const bridges = new Map<string, Bridge>();
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Get (or create) the bridge for a key. One worker per key, created once and
|
|
121
|
+
* unref'd so it can never hold the process open.
|
|
122
|
+
*
|
|
123
|
+
* @param key Identity of the connection target — same key, same worker
|
|
124
|
+
* @param workerSource The worker body; WORKER_REPLY_HELPER is prepended for it
|
|
125
|
+
* @param workerData Passed to the worker verbatim (plus the shared buffers)
|
|
126
|
+
*/
|
|
127
|
+
export function getBridge(key: string, workerSource: string, workerData: Record<string, unknown>): Bridge {
|
|
128
|
+
const existing = bridges.get(key);
|
|
129
|
+
if (existing) return existing;
|
|
130
|
+
|
|
131
|
+
const controlBuffer = new SharedArrayBuffer(4 * Int32Array.BYTES_PER_ELEMENT);
|
|
132
|
+
const dataBuffer = new SharedArrayBuffer(DATA_BYTES);
|
|
133
|
+
const control = new Int32Array(controlBuffer);
|
|
134
|
+
const data = new Uint8Array(dataBuffer);
|
|
135
|
+
|
|
136
|
+
const worker = new Worker(
|
|
137
|
+
`const { parentPort, workerData } = require("node:worker_threads");\n${WORKER_REPLY_HELPER}\n${workerSource}`,
|
|
138
|
+
{ eval: true, workerData: { ...workerData, controlBuffer, dataBuffer } },
|
|
139
|
+
);
|
|
140
|
+
// A session channel must never keep a process alive.
|
|
141
|
+
worker.unref();
|
|
142
|
+
|
|
143
|
+
// Block until the worker is up. A cold channel pays this once.
|
|
144
|
+
if (Atomics.load(control, IDX_READY) === 0) {
|
|
145
|
+
if (Atomics.wait(control, IDX_READY, 0, BOOT_TIMEOUT_MS) === "timed-out") {
|
|
146
|
+
void worker.terminate();
|
|
147
|
+
bridges.delete(key);
|
|
148
|
+
throw new Error(`session worker failed to start within ${BOOT_TIMEOUT_MS}ms`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const decoder = new TextDecoder();
|
|
153
|
+
const bridge: Bridge = {
|
|
154
|
+
worker,
|
|
155
|
+
call(message: unknown, label: string): BridgeReply {
|
|
156
|
+
// Watch a monotonic counter rather than a flag: a counter cannot race with
|
|
157
|
+
// a reset, so a late reply from a previous (timed-out) call can never be
|
|
158
|
+
// mistaken for this one's.
|
|
159
|
+
const before = Atomics.load(control, IDX_SEQ);
|
|
160
|
+
worker.postMessage(message);
|
|
161
|
+
|
|
162
|
+
if (Atomics.wait(control, IDX_SEQ, before, REPLY_TIMEOUT_MS) === "timed-out") {
|
|
163
|
+
throw new Error(`${label} command failed: timed out after ${REPLY_TIMEOUT_MS}ms`);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const status = Atomics.load(control, IDX_STATUS);
|
|
167
|
+
const length = Atomics.load(control, IDX_LENGTH);
|
|
168
|
+
|
|
169
|
+
if (status === STATUS_TOO_LARGE) {
|
|
170
|
+
throw new Error(`${label} command failed: reply exceeds the ${DATA_BYTES} byte buffer`);
|
|
171
|
+
}
|
|
172
|
+
return { status, payload: decoder.decode(data.subarray(0, length)) };
|
|
173
|
+
},
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
bridges.set(key, bridge);
|
|
177
|
+
return bridge;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Terminate every worker. Tests and short-lived scripts call this so a spawned
|
|
182
|
+
* worker never outlives the work that created it — "reap what you spawn". Normal
|
|
183
|
+
* apps do not need it: the workers are unref'd.
|
|
184
|
+
*/
|
|
185
|
+
export function closeBridges(): void {
|
|
186
|
+
for (const { worker } of bridges.values()) {
|
|
187
|
+
void worker.terminate();
|
|
188
|
+
}
|
|
189
|
+
bridges.clear();
|
|
190
|
+
}
|
|
@@ -0,0 +1,236 @@
|
|
|
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 {
|
|
17
|
+
getBridge,
|
|
18
|
+
closeBridges,
|
|
19
|
+
DATA_BYTES,
|
|
20
|
+
STATUS_ERROR,
|
|
21
|
+
STATUS_NIL,
|
|
22
|
+
STATUS_OK,
|
|
23
|
+
STATUS_TRANSPORT,
|
|
24
|
+
} from "./syncBridge.js";
|
|
25
|
+
|
|
26
|
+
export interface SyncSocketTarget {
|
|
27
|
+
host: string;
|
|
28
|
+
port: number;
|
|
29
|
+
/** AUTH password (empty/undefined = no AUTH sent). */
|
|
30
|
+
password?: string;
|
|
31
|
+
/** SELECT db index (0/undefined = no SELECT sent). */
|
|
32
|
+
db?: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* How long the worker waits for a TCP connection. Deliberately shorter than the
|
|
37
|
+
* bridge's reply timeout so an unreachable host is reported as a connect failure
|
|
38
|
+
* with a useful message, rather than as a generic reply timeout — before this
|
|
39
|
+
* existed, learning a server was down took the full 5s deadline.
|
|
40
|
+
*/
|
|
41
|
+
const CONNECT_TIMEOUT_MS = 2000;
|
|
42
|
+
|
|
43
|
+
const SOCKET_WORKER = `
|
|
44
|
+
const net = require("node:net");
|
|
45
|
+
const target = workerData.target;
|
|
46
|
+
|
|
47
|
+
let sock = null;
|
|
48
|
+
let buffer = Buffer.alloc(0);
|
|
49
|
+
let pending = null;
|
|
50
|
+
|
|
51
|
+
function encodeCommand(args) {
|
|
52
|
+
let out = "*" + args.length + "\\r\\n";
|
|
53
|
+
for (const s of args) out += "$" + Buffer.byteLength(s) + "\\r\\n" + s + "\\r\\n";
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Parse one RESP value at offset. Returns { value, next } or null when more
|
|
58
|
+
// bytes are needed, so a bulk string split across TCP chunks is reassembled
|
|
59
|
+
// rather than truncated.
|
|
60
|
+
function parse(buf, off) {
|
|
61
|
+
if (off >= buf.length) return null;
|
|
62
|
+
const type = buf[off];
|
|
63
|
+
const crlf = buf.indexOf("\\r\\n", off + 1, "utf-8");
|
|
64
|
+
if (crlf === -1) return null;
|
|
65
|
+
const line = buf.toString("utf-8", off + 1, crlf);
|
|
66
|
+
const after = crlf + 2;
|
|
67
|
+
if (type === 0x2b) return { value: line, next: after }; // '+'
|
|
68
|
+
if (type === 0x3a) return { value: line, next: after }; // ':'
|
|
69
|
+
if (type === 0x2d) return { value: { __err: line }, next: after }; // '-'
|
|
70
|
+
if (type === 0x24) { // '$'
|
|
71
|
+
const len = parseInt(line, 10);
|
|
72
|
+
if (len === -1) return { value: null, next: after };
|
|
73
|
+
if (after + len + 2 > buf.length) return null;
|
|
74
|
+
return { value: buf.toString("utf-8", after, after + len), next: after + len + 2 };
|
|
75
|
+
}
|
|
76
|
+
if (type === 0x2a) { // '*'
|
|
77
|
+
const count = parseInt(line, 10);
|
|
78
|
+
if (count === -1) return { value: null, next: after };
|
|
79
|
+
const arr = [];
|
|
80
|
+
let pos = after;
|
|
81
|
+
for (let i = 0; i < count; i++) {
|
|
82
|
+
const el = parse(buf, pos);
|
|
83
|
+
if (!el) return null;
|
|
84
|
+
arr.push(el.value);
|
|
85
|
+
pos = el.next;
|
|
86
|
+
}
|
|
87
|
+
return { value: arr, next: pos };
|
|
88
|
+
}
|
|
89
|
+
return { value: line, next: after };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function settle(status, payload) {
|
|
93
|
+
if (!pending) return;
|
|
94
|
+
pending = null;
|
|
95
|
+
__reply(status, payload);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function drop(err) {
|
|
99
|
+
// The socket is gone. Fail the in-flight command, then let the NEXT command
|
|
100
|
+
// reconnect - a transient blip must not poison the channel forever.
|
|
101
|
+
try { if (sock) sock.destroy(); } catch (e) {}
|
|
102
|
+
sock = null;
|
|
103
|
+
buffer = Buffer.alloc(0);
|
|
104
|
+
settle(${STATUS_TRANSPORT}, err || "connection lost");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function onRespData(chunk) {
|
|
108
|
+
buffer = buffer.length ? Buffer.concat([buffer, chunk]) : chunk;
|
|
109
|
+
while (pending) {
|
|
110
|
+
const p = parse(buffer, 0);
|
|
111
|
+
if (!p) break;
|
|
112
|
+
buffer = buffer.subarray(p.next);
|
|
113
|
+
pending.replies.push(p.value);
|
|
114
|
+
if (pending.replies.length < pending.expected) continue;
|
|
115
|
+
|
|
116
|
+
// A rejected AUTH/SELECT is surfaced ahead of the command's own reply.
|
|
117
|
+
for (let i = 0; i < pending.expected - 1; i++) {
|
|
118
|
+
const r = pending.replies[i];
|
|
119
|
+
if (r && typeof r === "object" && r.__err !== undefined) { settle(${STATUS_ERROR}, r.__err); return; }
|
|
120
|
+
}
|
|
121
|
+
const result = pending.replies[pending.expected - 1];
|
|
122
|
+
if (result && typeof result === "object" && result.__err !== undefined) settle(${STATUS_ERROR}, result.__err);
|
|
123
|
+
else if (result === null || result === undefined) settle(${STATUS_NIL}, null);
|
|
124
|
+
else settle(${STATUS_OK}, result);
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function onTextData(chunk) {
|
|
130
|
+
// Text protocols (memcached) carry no length prefix: the reply is complete
|
|
131
|
+
// when one of the caller's terminators appears.
|
|
132
|
+
buffer = buffer.length ? Buffer.concat([buffer, chunk]) : chunk;
|
|
133
|
+
if (!pending) return;
|
|
134
|
+
const text = buffer.toString("utf-8");
|
|
135
|
+
for (const t of pending.terminators) {
|
|
136
|
+
if (text.includes(t)) { buffer = Buffer.alloc(0); settle(${STATUS_OK}, text); return; }
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function connect(then) {
|
|
141
|
+
const s = net.createConnection({ host: target.host, port: target.port });
|
|
142
|
+
s.setNoDelay(true);
|
|
143
|
+
// An unroutable host neither connects nor errors promptly, so bound it here:
|
|
144
|
+
// otherwise the caller sits out its whole reply deadline for a host that was
|
|
145
|
+
// never going to answer.
|
|
146
|
+
const connectTimer = setTimeout(() => {
|
|
147
|
+
if (sock !== s) { try { s.destroy(); } catch (e) {} drop("connect timed out"); }
|
|
148
|
+
}, ${CONNECT_TIMEOUT_MS});
|
|
149
|
+
s.on("data", (c) => (pending && pending.text ? onTextData(c) : onRespData(c)));
|
|
150
|
+
s.on("error", (e) => { clearTimeout(connectTimer); drop(e.message); });
|
|
151
|
+
s.on("close", () => { clearTimeout(connectTimer); if (sock === s) drop("connection closed"); });
|
|
152
|
+
s.on("connect", () => { clearTimeout(connectTimer); sock = s; then(); });
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
parentPort.on("message", (msg) => {
|
|
156
|
+
// Set pending BEFORE connecting. A connect error (ECONNREFUSED) otherwise
|
|
157
|
+
// arrives with nothing in flight, and the caller blocks for its full reply
|
|
158
|
+
// timeout just to learn the server is down.
|
|
159
|
+
const fresh = !sock;
|
|
160
|
+
|
|
161
|
+
if (msg.mode === "text") {
|
|
162
|
+
pending = { text: true, terminators: msg.terminators };
|
|
163
|
+
const writeText = () => {
|
|
164
|
+
try { sock.write(msg.payload, "utf-8"); } catch (e) { drop(e.message); }
|
|
165
|
+
};
|
|
166
|
+
if (sock) writeText(); else { try { connect(writeText); } catch (e) { drop(e.message); } }
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// The handshake rides with the FIRST command on a fresh connection only;
|
|
171
|
+
// afterwards the connection is already authenticated and selected.
|
|
172
|
+
const handshake = fresh ? ((target.password ? 1 : 0) + (target.db ? 1 : 0)) : 0;
|
|
173
|
+
pending = { expected: 1 + handshake, replies: [] };
|
|
174
|
+
|
|
175
|
+
const write = () => {
|
|
176
|
+
let payload = "";
|
|
177
|
+
if (fresh) {
|
|
178
|
+
if (target.password) payload += encodeCommand(["AUTH", target.password]);
|
|
179
|
+
if (target.db) payload += encodeCommand(["SELECT", String(target.db)]);
|
|
180
|
+
}
|
|
181
|
+
payload += encodeCommand(msg.args);
|
|
182
|
+
try { sock.write(payload); } catch (e) { drop(e.message); }
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
if (sock) write(); else { try { connect(write); } catch (e) { drop(e.message); } }
|
|
186
|
+
});
|
|
187
|
+
`;
|
|
188
|
+
|
|
189
|
+
function socketBridge(target: SyncSocketTarget) {
|
|
190
|
+
const key = `sock:${target.host}:${target.port}:${target.db ?? 0}:${target.password ? "auth" : ""}`;
|
|
191
|
+
return getBridge(key, SOCKET_WORKER, { target });
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Run a single RESP command synchronously and return the reply.
|
|
196
|
+
*
|
|
197
|
+
* - A genuine nil / key-miss returns `""` (callers treat "" as "no session yet").
|
|
198
|
+
* - A transport FAILURE (unreachable, timeout, connection closed) THROWS
|
|
199
|
+
* `<label> command failed: ...`.
|
|
200
|
+
* - A RESP error reply — including a rejected AUTH/SELECT — THROWS
|
|
201
|
+
* `<label> error: ...`.
|
|
202
|
+
*
|
|
203
|
+
* The miss/failure split is the whole contract: collapsing them is how a dead
|
|
204
|
+
* backend silently logs every user out instead of surfacing an outage. A dead
|
|
205
|
+
* socket and a healthy server saying "-ERR" are different failures and read
|
|
206
|
+
* differently in a log, so they keep different wording.
|
|
207
|
+
*/
|
|
208
|
+
export function syncCommand(target: SyncSocketTarget, args: string[], label = "Redis"): string {
|
|
209
|
+
const { status, payload } = socketBridge(target).call({ args }, label);
|
|
210
|
+
|
|
211
|
+
if (status === STATUS_NIL) return ""; // genuine key miss
|
|
212
|
+
if (status === STATUS_TRANSPORT) throw new Error(`${label} command failed: ${payload}`);
|
|
213
|
+
if (status === STATUS_ERROR) throw new Error(`${label} error: ${payload}`);
|
|
214
|
+
return payload;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Run a raw text-protocol command (memcached) over the same persistent channel.
|
|
219
|
+
*
|
|
220
|
+
* The reply is whatever arrived once one of `terminators` appears — a text
|
|
221
|
+
* protocol carries no length prefix, so the caller names its own end markers.
|
|
222
|
+
* Failure semantics match {@link syncCommand}.
|
|
223
|
+
*/
|
|
224
|
+
export function syncTextCommand(
|
|
225
|
+
target: SyncSocketTarget,
|
|
226
|
+
payload: string,
|
|
227
|
+
terminators: string[],
|
|
228
|
+
label = "Memcached",
|
|
229
|
+
): string {
|
|
230
|
+
const reply = socketBridge(target).call({ mode: "text", payload, terminators }, label);
|
|
231
|
+
if (reply.status === STATUS_TRANSPORT) throw new Error(`${label} command failed: ${reply.payload}`);
|
|
232
|
+
return reply.payload;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Re-exported so callers do not need to know the bridge exists. */
|
|
236
|
+
export { closeBridges as closeSyncSockets, DATA_BYTES };
|
|
@@ -19,7 +19,7 @@ import { Socket } from "node:net";
|
|
|
19
19
|
import { createRequest } from "./request.js";
|
|
20
20
|
import { createResponse } from "./response.js";
|
|
21
21
|
import { defaultRouter, Router, runRouteMiddlewares } from "./router.js";
|
|
22
|
-
import { MiddlewareRunner } from "./middleware.js";
|
|
22
|
+
import { MiddlewareRunner, isMiddlewareClass } from "./middleware.js";
|
|
23
23
|
import { enforceRouteAuth } from "./authGate.js";
|
|
24
24
|
|
|
25
25
|
export class TestResponse {
|
|
@@ -220,10 +220,21 @@ export class TestClient {
|
|
|
220
220
|
}
|
|
221
221
|
}
|
|
222
222
|
|
|
223
|
-
// Per-route middleware,
|
|
223
|
+
// Per-route middleware: functions, string specs, and CLASSES, through the
|
|
224
|
+
// same runner the live dispatcher uses. A route class's afterX hooks join
|
|
225
|
+
// the after pass below, exactly as in server.ts.
|
|
226
|
+
//
|
|
227
|
+
// ORDER DRIFT, stated rather than hidden: the live dispatcher runs the auth
|
|
228
|
+
// gate BEFORE the route's own middleware (ADR-0012 — middleware on a
|
|
229
|
+
// secured route must never process a request that is about to be rejected);
|
|
230
|
+
// here it still runs before the gate. Aligning the two is a behaviour
|
|
231
|
+
// change to the test surface and belongs in its own change.
|
|
232
|
+
const routeMiddlewareClasses = (match.middlewares ?? []).filter(isMiddlewareClass);
|
|
224
233
|
if (match.middlewares && match.middlewares.length > 0) {
|
|
225
234
|
const proceed = await runRouteMiddlewares(match.middlewares, req, res);
|
|
226
235
|
if (!proceed || res.raw.writableEnded) {
|
|
236
|
+
await MiddlewareRunner.runAfter([...globalMiddleware, ...routeMiddlewareClasses], req, res);
|
|
237
|
+
if (!res.raw.writableEnded) res.raw.end();
|
|
227
238
|
return this._collect(rawRes, chunks, socket);
|
|
228
239
|
}
|
|
229
240
|
}
|
|
@@ -243,9 +254,11 @@ export class TestClient {
|
|
|
243
254
|
// Execute handler (only if auth passed)
|
|
244
255
|
if (!rejected) {
|
|
245
256
|
await match.handler(req, res);
|
|
246
|
-
// Global afterX hooks (logging / post-processing),
|
|
247
|
-
|
|
248
|
-
|
|
257
|
+
// Global + route-class afterX hooks (logging / post-processing),
|
|
258
|
+
// mirroring the live tail.
|
|
259
|
+
const afterMiddleware = [...globalMiddleware, ...routeMiddlewareClasses];
|
|
260
|
+
if (afterMiddleware.length > 0) {
|
|
261
|
+
await MiddlewareRunner.runAfter(afterMiddleware, req, res);
|
|
249
262
|
}
|
|
250
263
|
}
|
|
251
264
|
|