tina4-nodejs 3.13.92 → 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 +170 -28
- package/README.md +2 -2
- package/package.json +13 -9
- package/packages/cli/dist/bin.js +33126 -30055
- package/packages/cli/src/commands/metrics.ts +17 -11
- package/packages/cli/src/commands/serve.ts +10 -9
- package/packages/core/dist/index.js +33062 -29908
- 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/devMailbox.ts +20 -44
- package/packages/core/src/dispatchPipeline.ts +285 -0
- package/packages/core/src/dotenv.ts +185 -40
- package/packages/core/src/index.ts +7 -6
- package/packages/core/src/logger.ts +257 -36
- package/packages/core/src/mcp.ts +1 -1
- package/packages/core/src/messenger.ts +81 -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 +109 -13
- 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/childError.ts +72 -0
- 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 -202
- package/packages/core/src/sessionHandlers/mongoHandler.ts +88 -8
- package/packages/core/src/sessionHandlers/respClient.ts +16 -143
- 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/frond/dist/index.js +74 -31
- package/packages/frond/src/engine.ts +99 -33
- package/packages/orm/dist/index.js +26554 -23400
- 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 +64 -25
- 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 +338 -198
- 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/cli/src/bin.d.ts +92 -0
- package/types/cli/src/commands/build.d.ts +2 -0
- package/types/cli/src/commands/generate.d.ts +47 -0
- package/types/cli/src/commands/init.d.ts +1 -0
- package/types/cli/src/commands/metrics.d.ts +6 -0
- package/types/cli/src/commands/migrate.d.ts +1 -0
- package/types/cli/src/commands/migrateCreate.d.ts +1 -0
- package/types/cli/src/commands/migrateRollback.d.ts +1 -0
- package/types/cli/src/commands/migrateStatus.d.ts +1 -0
- package/types/cli/src/commands/queue.d.ts +20 -0
- package/types/cli/src/commands/routes.d.ts +1 -0
- package/types/cli/src/commands/seed.d.ts +1 -0
- package/types/cli/src/commands/serve.d.ts +6 -0
- package/types/cli/src/commands/test.d.ts +1 -0
- package/types/core/src/ai.d.ts +64 -0
- package/types/core/src/api.d.ts +262 -0
- package/types/core/src/auth.d.ts +177 -0
- package/types/core/src/authGate.d.ts +20 -0
- package/types/core/src/background.d.ts +34 -0
- package/types/core/src/cache.d.ts +163 -0
- package/types/core/src/constants.d.ts +38 -0
- package/types/core/src/container.d.ts +44 -0
- package/types/core/src/context/chunker.d.ts +31 -0
- package/types/core/src/context/index.d.ts +93 -0
- package/types/core/src/devAdmin.d.ts +179 -0
- package/types/core/src/devMailbox.d.ts +54 -0
- package/types/core/src/dispatchPipeline.d.ts +117 -0
- package/types/core/src/docs.d.ts +141 -0
- package/types/core/src/docsAutoDiscovery.d.ts +6 -0
- package/types/core/src/dotenv.d.ts +87 -0
- package/types/core/src/env.d.ts +28 -0
- package/types/core/src/errorOverlay.d.ts +36 -0
- package/types/core/src/events.d.ts +75 -0
- package/types/core/src/fakeData.d.ts +55 -0
- package/types/core/src/feedback.d.ts +90 -0
- package/types/core/src/graphql.d.ts +207 -0
- package/types/core/src/health.d.ts +22 -0
- package/types/core/src/htmlElement.d.ts +75 -0
- package/types/core/src/i18n.d.ts +37 -0
- package/types/core/src/index.d.ts +92 -0
- package/types/core/src/job.d.ts +39 -0
- package/types/core/src/logger.d.ts +200 -0
- package/types/core/src/mcp.d.ts +248 -0
- package/types/core/src/messenger.d.ts +191 -0
- package/types/core/src/metrics.d.ts +41 -0
- package/types/core/src/middleware.d.ts +330 -0
- package/types/core/src/mqtt.d.ts +257 -0
- package/types/core/src/mqttMessage.d.ts +67 -0
- package/types/core/src/plan.d.ts +96 -0
- package/types/core/src/projectIndex.d.ts +56 -0
- package/types/core/src/queue.d.ts +268 -0
- package/types/core/src/queueBackends/kafkaBackend.d.ts +117 -0
- package/types/core/src/queueBackends/liteBackend.d.ts +128 -0
- package/types/core/src/queueBackends/mongoBackend.d.ts +119 -0
- package/types/core/src/queueBackends/rabbitmqBackend.d.ts +55 -0
- package/types/core/src/rateLimiter.d.ts +49 -0
- package/types/core/src/request.d.ts +25 -0
- package/types/core/src/response.d.ts +28 -0
- package/types/core/src/routeDiscovery.d.ts +12 -0
- package/types/core/src/router.d.ts +366 -0
- package/types/core/src/scss.d.ts +19 -0
- package/types/core/src/server.d.ts +146 -0
- package/types/core/src/service.d.ts +115 -0
- package/types/core/src/session.d.ts +341 -0
- package/types/core/src/sessionHandlers/childError.d.ts +34 -0
- package/types/core/src/sessionHandlers/databaseHandler.d.ts +97 -0
- package/types/core/src/sessionHandlers/memcachedHandler.d.ts +60 -0
- package/types/core/src/sessionHandlers/mongoClient.d.ts +35 -0
- package/types/core/src/sessionHandlers/mongoHandler.d.ts +109 -0
- package/types/core/src/sessionHandlers/respClient.d.ts +22 -0
- 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/sessionHandlers/valkeyHandler.d.ts +65 -0
- package/types/core/src/static.d.ts +2 -0
- package/types/core/src/test.d.ts +94 -0
- package/types/core/src/testClient.d.ts +36 -0
- package/types/core/src/testing.d.ts +58 -0
- package/types/core/src/trustedProxy.d.ts +44 -0
- package/types/core/src/types.d.ts +242 -0
- package/types/core/src/validator.d.ts +52 -0
- package/types/core/src/websocket.d.ts +402 -0
- package/types/core/src/websocketBackplane.d.ts +166 -0
- package/types/core/src/websocketConnection.d.ts +54 -0
- package/types/core/src/wsdl.d.ts +101 -0
- package/types/frond/src/engine.d.ts +263 -0
- package/types/frond/src/index.d.ts +2 -0
- package/types/orm/src/adapters/firebird.d.ts +183 -0
- package/types/orm/src/adapters/mongodb.d.ts +81 -0
- package/types/orm/src/adapters/mssql.d.ts +77 -0
- package/types/orm/src/adapters/mysql.d.ts +67 -0
- package/types/orm/src/adapters/odbc.d.ts +94 -0
- package/types/orm/src/adapters/postgres.d.ts +86 -0
- package/types/orm/src/adapters/sqlDialect.d.ts +71 -0
- package/types/orm/src/adapters/sqlite.d.ts +68 -0
- package/types/orm/src/autoCrud.d.ts +73 -0
- package/types/orm/src/baseModel.d.ts +427 -0
- package/types/orm/src/cachedDatabase.d.ts +190 -0
- package/types/orm/src/connectTimeout.d.ts +100 -0
- package/types/orm/src/database.d.ts +655 -0
- package/types/orm/src/databaseResult.d.ts +109 -0
- package/types/orm/src/databaseUrl.d.ts +125 -0
- package/types/orm/src/docstore.d.ts +241 -0
- package/types/orm/src/fakeData.d.ts +22 -0
- package/types/orm/src/index.d.ts +43 -0
- package/types/orm/src/migration.d.ts +275 -0
- package/types/orm/src/model.d.ts +7 -0
- package/types/orm/src/query.d.ts +14 -0
- package/types/orm/src/queryBuilder.d.ts +193 -0
- package/types/orm/src/realtime/index.d.ts +7 -0
- package/types/orm/src/realtime/models/attachment.d.ts +43 -0
- package/types/orm/src/realtime/models/channel.d.ts +32 -0
- package/types/orm/src/realtime/models/channelMember.d.ts +32 -0
- package/types/orm/src/realtime/models/message.d.ts +36 -0
- package/types/orm/src/realtime/models/workspace.d.ts +26 -0
- package/types/orm/src/realtime/realtime.d.ts +24 -0
- package/types/orm/src/realtime/storage.d.ts +61 -0
- package/types/orm/src/seeder.d.ts +118 -0
- package/types/orm/src/sqlTranslator.d.ts +258 -0
- package/types/orm/src/types.d.ts +148 -0
- package/types/orm/src/validation.d.ts +6 -0
- package/types/swagger/src/generator.d.ts +46 -0
- package/types/swagger/src/index.d.ts +2 -0
- package/types/swagger/src/ui.d.ts +11 -0
- package/packages/core/src/sessionHandlers/redisHandler.ts +0 -206
|
@@ -1,21 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Tina4 synchronous RESP transport — shared by every Redis/Valkey session handler.
|
|
3
3
|
*
|
|
4
|
-
* The session-handler interface is synchronous
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* The session-handler interface is synchronous but node:net is async-only, so
|
|
5
|
+
* this delegates to syncSocket, which owns ONE persistent connection per target
|
|
6
|
+
* behind a worker thread. See that file for why: the previous implementation ran
|
|
7
|
+
* every command in a short-lived `node -e` child, paying a process spawn AND a
|
|
8
|
+
* fresh TCP connection per command (p50 41ms, p99 487ms), and its long tail
|
|
9
|
+
* tripped the child's own deadline — the cause of the sessionHandlers flake.
|
|
8
10
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* GET/SET/DEL), so "end" never fires — the child waited out its timeout, exited
|
|
12
|
-
* non-zero, and execFileSync re-threw it as a "transport failure". EVERY command
|
|
13
|
-
* failed against a reachable server. Here the child parses replies INCREMENTALLY on
|
|
14
|
-
* the "data" event (the same proven RESP parser cache.ts's RespClient uses): it
|
|
15
|
-
* reads exactly the replies it sent commands for (AUTH? + SELECT? + the command),
|
|
16
|
-
* then returns the LAST (command) reply.
|
|
11
|
+
* This module stays as the RESP-shaped seam the Redis/Valkey handlers call, so
|
|
12
|
+
* neither has to know how the synchronous transport is implemented.
|
|
17
13
|
*/
|
|
18
|
-
import {
|
|
14
|
+
import { syncCommand } from "./syncSocket.js";
|
|
19
15
|
|
|
20
16
|
export interface RespTarget {
|
|
21
17
|
host: string;
|
|
@@ -36,136 +32,13 @@ export interface RespTarget {
|
|
|
36
32
|
* `<label> error: ...`. A rejected handshake is a transport failure, not a
|
|
37
33
|
* result, so it is surfaced ahead of the command reply.
|
|
38
34
|
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
35
|
+
* The miss/failure split is the whole contract: collapsing them is how a dead
|
|
36
|
+
* backend silently logs every user out instead of surfacing an outage.
|
|
41
37
|
*/
|
|
42
38
|
export function respCommandSync(target: RespTarget, args: string[], label = "Redis"): string {
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
const script = `
|
|
49
|
-
const net = require("node:net");
|
|
50
|
-
const host = ${JSON.stringify(host)};
|
|
51
|
-
const port = ${port};
|
|
52
|
-
const password = ${JSON.stringify(password)};
|
|
53
|
-
const db = ${db};
|
|
54
|
-
const args = ${JSON.stringify(args)};
|
|
55
|
-
// Replies to consume = AUTH? + SELECT? + the command. The LAST is our result.
|
|
56
|
-
const expected = (password ? 1 : 0) + (db !== 0 ? 1 : 0) + 1;
|
|
57
|
-
|
|
58
|
-
function encode(a) {
|
|
59
|
-
let c = "*" + a.length + "\\r\\n";
|
|
60
|
-
for (const s of a) c += "$" + Buffer.byteLength(s) + "\\r\\n" + s + "\\r\\n";
|
|
61
|
-
return c;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
// Parse one RESP value at offset. Returns { value, next } or null if more bytes
|
|
65
|
-
// are needed (so a bulk string split across TCP chunks is handled correctly).
|
|
66
|
-
function parse(buf, off) {
|
|
67
|
-
if (off >= buf.length) return null;
|
|
68
|
-
const type = buf[off];
|
|
69
|
-
const crlf = buf.indexOf("\\r\\n", off + 1, "utf-8");
|
|
70
|
-
if (crlf === -1) return null;
|
|
71
|
-
const line = buf.toString("utf-8", off + 1, crlf);
|
|
72
|
-
const after = crlf + 2;
|
|
73
|
-
if (type === 0x2b) return { value: line, next: after }; // '+' simple string
|
|
74
|
-
if (type === 0x3a) return { value: line, next: after }; // ':' integer
|
|
75
|
-
if (type === 0x2d) return { value: { __err: line }, next: after }; // '-' error
|
|
76
|
-
if (type === 0x24) { // '$' bulk string
|
|
77
|
-
const len = parseInt(line, 10);
|
|
78
|
-
if (len === -1) return { value: null, next: after };
|
|
79
|
-
if (after + len + 2 > buf.length) return null;
|
|
80
|
-
return { value: buf.toString("utf-8", after, after + len), next: after + len + 2 };
|
|
81
|
-
}
|
|
82
|
-
if (type === 0x2a) { // '*' array
|
|
83
|
-
const count = parseInt(line, 10);
|
|
84
|
-
if (count === -1) return { value: null, next: after };
|
|
85
|
-
const arr = [];
|
|
86
|
-
let pos = after;
|
|
87
|
-
for (let i = 0; i < count; i++) {
|
|
88
|
-
const el = parse(buf, pos);
|
|
89
|
-
if (!el) return null;
|
|
90
|
-
arr.push(el.value);
|
|
91
|
-
pos = el.next;
|
|
92
|
-
}
|
|
93
|
-
return { value: arr, next: pos };
|
|
94
|
-
}
|
|
95
|
-
return { value: line, next: after };
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
const sock = net.createConnection({ host, port });
|
|
99
|
-
sock.setNoDelay(true);
|
|
100
|
-
let buffer = Buffer.alloc(0);
|
|
101
|
-
const replies = [];
|
|
102
|
-
let done = false;
|
|
103
|
-
const timer = setTimeout(() => { if (!done) { done = true; try { sock.destroy(); } catch (e) {} process.stderr.write("timeout"); process.exitCode = 1; } }, 3000);
|
|
104
|
-
|
|
105
|
-
function emit(s) {
|
|
106
|
-
if (done) return;
|
|
107
|
-
done = true;
|
|
108
|
-
clearTimeout(timer);
|
|
109
|
-
// The write callback guarantees stdout is flushed before exit (a bare
|
|
110
|
-
// process.exit can truncate piped stdout).
|
|
111
|
-
process.stdout.write(s, () => { try { sock.destroy(); } catch (e) {} });
|
|
112
|
-
}
|
|
113
|
-
function fail(msg) {
|
|
114
|
-
if (done) return;
|
|
115
|
-
done = true;
|
|
116
|
-
clearTimeout(timer);
|
|
117
|
-
try { sock.destroy(); } catch (e) {}
|
|
118
|
-
process.stderr.write(msg || "");
|
|
119
|
-
process.exitCode = 1;
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
sock.on("connect", () => {
|
|
123
|
-
let cmds = "";
|
|
124
|
-
if (password) cmds += encode(["AUTH", password]);
|
|
125
|
-
if (db !== 0) cmds += encode(["SELECT", String(db)]);
|
|
126
|
-
cmds += encode(args);
|
|
127
|
-
sock.write(cmds);
|
|
128
|
-
});
|
|
129
|
-
sock.on("data", (chunk) => {
|
|
130
|
-
buffer = buffer.length ? Buffer.concat([buffer, chunk]) : chunk;
|
|
131
|
-
while (true) {
|
|
132
|
-
const p = parse(buffer, 0);
|
|
133
|
-
if (!p) break;
|
|
134
|
-
buffer = buffer.subarray(p.next);
|
|
135
|
-
replies.push(p.value);
|
|
136
|
-
if (replies.length < expected) continue;
|
|
137
|
-
// A rejected AUTH/SELECT is a transport failure — surface it first.
|
|
138
|
-
for (let i = 0; i < expected - 1; i++) {
|
|
139
|
-
const r = replies[i];
|
|
140
|
-
if (r && typeof r === "object" && r.__err !== undefined) { emit("__ERR__" + r.__err); return; }
|
|
141
|
-
}
|
|
142
|
-
const result = replies[expected - 1];
|
|
143
|
-
if (result && typeof result === "object" && result.__err !== undefined) emit("__ERR__" + result.__err);
|
|
144
|
-
else if (result === null || result === undefined) emit("__NULL__");
|
|
145
|
-
else emit(String(result));
|
|
146
|
-
return;
|
|
147
|
-
}
|
|
148
|
-
});
|
|
149
|
-
sock.on("error", (err) => fail(err.message));
|
|
150
|
-
sock.on("close", () => { if (!done) fail("connection closed before reply"); });
|
|
151
|
-
`;
|
|
152
|
-
|
|
153
|
-
let result: string;
|
|
154
|
-
try {
|
|
155
|
-
result = execFileSync(process.execPath, ["-e", script], {
|
|
156
|
-
encoding: "utf-8",
|
|
157
|
-
timeout: 5000,
|
|
158
|
-
stdio: ["pipe", "pipe", "pipe"],
|
|
159
|
-
});
|
|
160
|
-
} catch (err) {
|
|
161
|
-
// Non-zero exit = socket error / timeout / closed connection: a transport
|
|
162
|
-
// FAILURE, not a key miss. Surface it so the Session boundary logs + degrades
|
|
163
|
-
// (or re-throws under strict mode).
|
|
164
|
-
throw new Error(`${label} command failed: ${(err as Error).message}`);
|
|
165
|
-
}
|
|
166
|
-
if (result === "__NULL__") return ""; // genuine key miss
|
|
167
|
-
if (result.startsWith("__ERR__")) {
|
|
168
|
-
throw new Error(`${label} error: ${result.slice("__ERR__".length)}`);
|
|
169
|
-
}
|
|
170
|
-
return result;
|
|
39
|
+
return syncCommand(
|
|
40
|
+
{ host: target.host, port: target.port, password: target.password, db: target.db },
|
|
41
|
+
args,
|
|
42
|
+
label,
|
|
43
|
+
);
|
|
171
44
|
}
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tina4 synchronous SQL transport for the database session backend.
|
|
3
|
+
*
|
|
4
|
+
* WHY IT EXISTS. The SessionHandler interface is SYNCHRONOUS and every SQL
|
|
5
|
+
* driver Node offers for a networked engine (pg, mysql2, tedious,
|
|
6
|
+
* node-firebird) is async-only. That mismatch was previously "resolved" by
|
|
7
|
+
* refusing every engine except SQLite: resolveDbPath() THREW on any non-sqlite
|
|
8
|
+
* TINA4_DATABASE_URL. So an app developed on SQLite and deployed on PostgreSQL
|
|
9
|
+
* did not start, in the one subsystem that decides whether anybody is logged in.
|
|
10
|
+
*
|
|
11
|
+
* The mismatch is not a reason to refuse an engine, because the fix already
|
|
12
|
+
* existed: syncBridge.ts. A Worker thread keeps its own event loop, so it can
|
|
13
|
+
* hold a long-lived driver connection and do ordinary async I/O, while the
|
|
14
|
+
* caller blocks in Atomics.wait until the worker writes its reply into a
|
|
15
|
+
* SharedArrayBuffer. RESP (Redis/Valkey), memcached and MongoDB have all ridden
|
|
16
|
+
* that bridge for months. This is a fifth consumer of a proven mechanism, not a
|
|
17
|
+
* new mechanism.
|
|
18
|
+
*
|
|
19
|
+
* SQLITE DOES NOT COME THROUGH HERE. `node:sqlite` is already synchronous, so
|
|
20
|
+
* routing it through a worker would add a thread hop and a JSON round-trip to
|
|
21
|
+
* the one engine that needs neither. DatabaseSessionHandler drives it directly.
|
|
22
|
+
*
|
|
23
|
+
* WHY THE DRIVERS ARE REQUIRED INSIDE THE WORKER rather than reaching for the
|
|
24
|
+
* ORM adapters: exactly the reason mongoClient.ts requires "mongodb" itself.
|
|
25
|
+
* The worker body is an eval'd CJS string, so a bare `require` is the one module
|
|
26
|
+
* resolution that works identically under tsx, under plain node, from source and
|
|
27
|
+
* from a built dist. @tina4/orm already declares pg / mysql2 / tedious as
|
|
28
|
+
* optional dependencies, so nothing new is installed for this - core stays
|
|
29
|
+
* zero-dependency.
|
|
30
|
+
*
|
|
31
|
+
* The SQL itself is NOT built here. The handler builds engine-neutral SQL with
|
|
32
|
+
* `?` placeholders - the same statement text as the Python master - and this
|
|
33
|
+
* transport rewrites the placeholders into the dialect the driver wants. Only
|
|
34
|
+
* the placeholder style and the CREATE TABLE types differ per engine.
|
|
35
|
+
*/
|
|
36
|
+
import { createRequire } from "node:module";
|
|
37
|
+
import { getBridge, STATUS_OK, STATUS_TRANSPORT } from "./syncBridge.js";
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The SQL engines the database session backend speaks.
|
|
41
|
+
*
|
|
42
|
+
* This IS the invariant: it is the engine set of the ORM Database layer minus
|
|
43
|
+
* the two non-SQL entries (mongodb has its own session backend, odbc has no
|
|
44
|
+
* session story in any of the four frameworks). Naming it once means the
|
|
45
|
+
* refusal message and the dispatch can never disagree about what is supported.
|
|
46
|
+
*/
|
|
47
|
+
export const SQL_SESSION_ENGINES = ["sqlite", "postgres", "mysql", "mssql", "firebird"] as const;
|
|
48
|
+
|
|
49
|
+
export type SqlSessionEngine = (typeof SQL_SESSION_ENGINES)[number];
|
|
50
|
+
|
|
51
|
+
/** The engines that need the bridge - everything except already-sync SQLite. */
|
|
52
|
+
export type BridgedEngine = Exclude<SqlSessionEngine, "sqlite">;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* A connection target for the worker.
|
|
56
|
+
*
|
|
57
|
+
* A PLAIN object, deliberately - never a `DatabaseUrl`. That class carries a
|
|
58
|
+
* cleartext password and its own docblock forbids persisting it across a
|
|
59
|
+
* structured-clone boundary (test/databaseUrlRedaction.test.ts enforces it).
|
|
60
|
+
* The worker genuinely needs credentials to authenticate, so it gets the fields
|
|
61
|
+
* it needs and nothing that renders itself.
|
|
62
|
+
*/
|
|
63
|
+
export interface SqlTarget {
|
|
64
|
+
engine: BridgedEngine;
|
|
65
|
+
host: string;
|
|
66
|
+
port: number;
|
|
67
|
+
database: string;
|
|
68
|
+
username: string | null;
|
|
69
|
+
password: string | null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Connect budget, kept BELOW the bridge's 5s reply timeout on purpose. An
|
|
74
|
+
* unreachable server then surfaces as a real driver message ("ECONNREFUSED
|
|
75
|
+
* 127.0.0.1:5432") instead of the caller's generic "timed out after 5000ms",
|
|
76
|
+
* which says nothing about what is actually wrong. Same reasoning, same number
|
|
77
|
+
* as mongoClient's SERVER_SELECTION_MS.
|
|
78
|
+
*/
|
|
79
|
+
const CONNECT_TIMEOUT_MS = 3000;
|
|
80
|
+
|
|
81
|
+
/** The driver each engine needs. All are already optional deps of @tina4/orm. */
|
|
82
|
+
const DRIVER_PACKAGE: Record<BridgedEngine, string> = {
|
|
83
|
+
postgres: "pg",
|
|
84
|
+
mysql: "mysql2",
|
|
85
|
+
mssql: "tedious",
|
|
86
|
+
firebird: "node-firebird",
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const requireFromHere = createRequire(import.meta.url);
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Resolve the driver to an ABSOLUTE path, on the main thread, before the worker
|
|
93
|
+
* starts.
|
|
94
|
+
*
|
|
95
|
+
* A bare `require("pg")` inside the worker would resolve from the process
|
|
96
|
+
* WORKING DIRECTORY (an eval'd worker has no real filename to resolve from), so
|
|
97
|
+
* a server started from anywhere other than the project root would fail to find
|
|
98
|
+
* a driver that is installed. Resolving from THIS module instead walks up from
|
|
99
|
+
* the framework's own location, which is correct in the monorepo and in an
|
|
100
|
+
* installed app alike.
|
|
101
|
+
*
|
|
102
|
+
* @throws Error naming the missing package and how to install it.
|
|
103
|
+
*/
|
|
104
|
+
function driverPath(engine: BridgedEngine): string {
|
|
105
|
+
const packageName = DRIVER_PACKAGE[engine];
|
|
106
|
+
try {
|
|
107
|
+
return requireFromHere.resolve(packageName);
|
|
108
|
+
} catch {
|
|
109
|
+
throw new Error(
|
|
110
|
+
`The "database" session backend on ${engine} requires the "${packageName}" package. `
|
|
111
|
+
+ `Install it with: npm install ${packageName}`,
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const SQL_WORKER = `
|
|
117
|
+
const target = workerData.target;
|
|
118
|
+
const engine = target.engine;
|
|
119
|
+
const driver = require(workerData.driverPath);
|
|
120
|
+
|
|
121
|
+
let client = null;
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Rewrite the handler's neutral \`?\` placeholders into the driver's dialect.
|
|
125
|
+
* mysql2 and node-firebird already take \`?\`, so they are left alone.
|
|
126
|
+
*/
|
|
127
|
+
function convert(sql) {
|
|
128
|
+
if (engine === "postgres") { let n = 0; return sql.replace(/\\?/g, () => "$" + (++n)); }
|
|
129
|
+
if (engine === "mssql") { let n = 0; return sql.replace(/\\?/g, () => "@p" + (n++)); }
|
|
130
|
+
return sql;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function connect() {
|
|
134
|
+
if (engine === "postgres") {
|
|
135
|
+
const Client = driver.Client || (driver.default && driver.default.Client);
|
|
136
|
+
const c = new Client({
|
|
137
|
+
host: target.host,
|
|
138
|
+
port: target.port,
|
|
139
|
+
user: target.username === null ? undefined : target.username,
|
|
140
|
+
password: target.password === null ? undefined : target.password,
|
|
141
|
+
database: target.database,
|
|
142
|
+
connectionTimeoutMillis: ${CONNECT_TIMEOUT_MS},
|
|
143
|
+
});
|
|
144
|
+
await c.connect();
|
|
145
|
+
return c;
|
|
146
|
+
}
|
|
147
|
+
if (engine === "mysql") {
|
|
148
|
+
const c = driver.createConnection({
|
|
149
|
+
host: target.host,
|
|
150
|
+
port: target.port,
|
|
151
|
+
user: target.username === null ? undefined : target.username,
|
|
152
|
+
password: target.password === null ? undefined : target.password,
|
|
153
|
+
database: target.database,
|
|
154
|
+
connectTimeout: ${CONNECT_TIMEOUT_MS},
|
|
155
|
+
});
|
|
156
|
+
await new Promise((resolve, reject) => c.connect((err) => (err ? reject(err) : resolve())));
|
|
157
|
+
return c;
|
|
158
|
+
}
|
|
159
|
+
if (engine === "mssql") {
|
|
160
|
+
const c = new driver.Connection({
|
|
161
|
+
server: target.host,
|
|
162
|
+
authentication: {
|
|
163
|
+
type: "default",
|
|
164
|
+
options: { userName: target.username, password: target.password },
|
|
165
|
+
},
|
|
166
|
+
options: {
|
|
167
|
+
database: target.database,
|
|
168
|
+
port: target.port,
|
|
169
|
+
trustServerCertificate: true,
|
|
170
|
+
encrypt: false,
|
|
171
|
+
connectTimeout: ${CONNECT_TIMEOUT_MS},
|
|
172
|
+
},
|
|
173
|
+
});
|
|
174
|
+
await new Promise((resolve, reject) => {
|
|
175
|
+
c.on("connect", (err) => (err ? reject(err) : resolve()));
|
|
176
|
+
c.connect();
|
|
177
|
+
});
|
|
178
|
+
return c;
|
|
179
|
+
}
|
|
180
|
+
if (engine === "firebird") {
|
|
181
|
+
return await new Promise((resolve, reject) => {
|
|
182
|
+
driver.attach(
|
|
183
|
+
{
|
|
184
|
+
host: target.host,
|
|
185
|
+
port: target.port,
|
|
186
|
+
database: target.database,
|
|
187
|
+
user: target.username,
|
|
188
|
+
password: target.password,
|
|
189
|
+
},
|
|
190
|
+
(err, db) => (err ? reject(err) : resolve(db)),
|
|
191
|
+
);
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
throw new Error("unsupported session SQL engine: " + engine);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function runMssql(sql, params) {
|
|
198
|
+
return new Promise((resolve, reject) => {
|
|
199
|
+
const rows = [];
|
|
200
|
+
const request = new driver.Request(convert(sql), (err) => (err ? reject(err) : resolve(rows)));
|
|
201
|
+
params.forEach((value, i) => {
|
|
202
|
+
// EVERY number binds as Float, never Int. tedious' Int is 32-bit and an
|
|
203
|
+
// expiry stamp is epoch SECONDS - an integral value above 2147483647
|
|
204
|
+
// (2038-01-19) would overflow and be stored as garbage, silently.
|
|
205
|
+
if (typeof value === "number") request.addParameter("p" + i, driver.TYPES.Float, value);
|
|
206
|
+
else if (value === null || value === undefined) request.addParameter("p" + i, driver.TYPES.NVarChar, null);
|
|
207
|
+
// length: Infinity means NVARCHAR(MAX). Without it tedious caps the
|
|
208
|
+
// parameter at 4000 characters and a large session TRUNCATES on write.
|
|
209
|
+
else request.addParameter("p" + i, driver.TYPES.NVarChar, String(value), { length: Infinity });
|
|
210
|
+
});
|
|
211
|
+
request.on("row", (columns) => {
|
|
212
|
+
const row = {};
|
|
213
|
+
columns.forEach((column) => { row[column.metadata.colName] = column.value; });
|
|
214
|
+
rows.push(row);
|
|
215
|
+
});
|
|
216
|
+
client.execSql(request);
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async function run(sql, params) {
|
|
221
|
+
if (!client) client = await connect();
|
|
222
|
+
if (engine === "postgres") {
|
|
223
|
+
const result = await client.query(convert(sql), params);
|
|
224
|
+
return result.rows || [];
|
|
225
|
+
}
|
|
226
|
+
if (engine === "mysql") {
|
|
227
|
+
const results = await new Promise((resolve, reject) => {
|
|
228
|
+
client.query(sql, params, (err, rows) => (err ? reject(err) : resolve(rows)));
|
|
229
|
+
});
|
|
230
|
+
return Array.isArray(results) ? results : [];
|
|
231
|
+
}
|
|
232
|
+
if (engine === "mssql") return await runMssql(sql, params);
|
|
233
|
+
const rows = await new Promise((resolve, reject) => {
|
|
234
|
+
client.query(sql, params, (err, result) => (err ? reject(err) : resolve(result)));
|
|
235
|
+
});
|
|
236
|
+
return Array.isArray(rows) ? rows : [];
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
parentPort.on("message", (message) => {
|
|
240
|
+
void (async () => {
|
|
241
|
+
try {
|
|
242
|
+
const rows = await run(message.sql, message.params || []);
|
|
243
|
+
__reply(${STATUS_OK}, JSON.stringify(rows));
|
|
244
|
+
} catch (err) {
|
|
245
|
+
// Drop the connection so the NEXT command reconnects. A transient blip
|
|
246
|
+
// must not poison the channel for the life of the process.
|
|
247
|
+
try {
|
|
248
|
+
if (client) {
|
|
249
|
+
if (typeof client.end === "function") client.end();
|
|
250
|
+
else if (typeof client.close === "function") client.close();
|
|
251
|
+
else if (typeof client.detach === "function") client.detach();
|
|
252
|
+
}
|
|
253
|
+
} catch (ignored) {}
|
|
254
|
+
client = null;
|
|
255
|
+
__reply(${STATUS_TRANSPORT}, String((err && err.message) || err));
|
|
256
|
+
}
|
|
257
|
+
})();
|
|
258
|
+
});
|
|
259
|
+
`;
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Run one SQL statement synchronously against a networked engine.
|
|
263
|
+
*
|
|
264
|
+
* @returns the result rows - always an array, empty for a write or DDL.
|
|
265
|
+
* @throws Error on ANY driver failure (server unreachable, bad credentials,
|
|
266
|
+
* SQL error). It is never swallowed into an empty result: for a session
|
|
267
|
+
* store, "the database is down" and "no session yet" must stay
|
|
268
|
+
* distinguishable, or a dead backend silently logs every user out.
|
|
269
|
+
*/
|
|
270
|
+
export function sqlCommandSync(
|
|
271
|
+
target: SqlTarget,
|
|
272
|
+
sql: string,
|
|
273
|
+
params: unknown[] = [],
|
|
274
|
+
label = "Database session",
|
|
275
|
+
): Record<string, unknown>[] {
|
|
276
|
+
const key = `sql:${target.engine}:${target.host}:${target.port}:${target.database}:${target.username ?? ""}`;
|
|
277
|
+
const { status, payload } = getBridge(key, SQL_WORKER, {
|
|
278
|
+
target,
|
|
279
|
+
driverPath: driverPath(target.engine),
|
|
280
|
+
}).call({ sql, params }, label);
|
|
281
|
+
|
|
282
|
+
if (status !== STATUS_OK) {
|
|
283
|
+
throw new Error(`${label} command failed: ${payload}`);
|
|
284
|
+
}
|
|
285
|
+
try {
|
|
286
|
+
return JSON.parse(payload) as Record<string, unknown>[];
|
|
287
|
+
} catch {
|
|
288
|
+
return [];
|
|
289
|
+
}
|
|
290
|
+
}
|
|
@@ -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
|
+
}
|