dominus-cli 2.1.1 → 2.2.1
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/CHANGELOG.md +138 -95
- package/README.md +33 -12
- package/dist/bridge-daemon.js +581 -0
- package/dist/bridge-daemon.js.map +1 -0
- package/dist/install-plugin.js +499 -27
- package/dist/install-plugin.js.map +1 -1
- package/dist/mcp.js +1298 -690
- package/dist/mcp.js.map +1 -1
- package/docs/DOMINUS_2_PLAN.md +8 -2
- package/package.json +1 -1
- package/plugin/Dominus.rbxmx +8 -4
- package/plugin/default.project.json +4 -1
- package/plugin/src/ValueCodec.lua +1 -1
- package/plugin/src/init.server.lua +5 -1
|
@@ -0,0 +1,581 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import os from 'os';
|
|
5
|
+
import * as crypto from 'crypto';
|
|
6
|
+
import { randomUUID } from 'crypto';
|
|
7
|
+
import { EventEmitter } from 'events';
|
|
8
|
+
import { WebSocket, WebSocketServer } from 'ws';
|
|
9
|
+
import { nanoid } from 'nanoid';
|
|
10
|
+
import { z } from 'zod';
|
|
11
|
+
|
|
12
|
+
var DOMINUS_DIR = path.join(os.homedir(), ".dominus");
|
|
13
|
+
var CONFIG_PATH = path.join(DOMINUS_DIR, "config.json");
|
|
14
|
+
path.join(DOMINUS_DIR, "dominus.db");
|
|
15
|
+
path.join(DOMINUS_DIR, "rules.json");
|
|
16
|
+
var DEFAULTS = {
|
|
17
|
+
provider: "openai",
|
|
18
|
+
apiBaseUrl: "",
|
|
19
|
+
model: "",
|
|
20
|
+
port: 18088,
|
|
21
|
+
maxToolIterations: 25,
|
|
22
|
+
maxVerifyRetries: 3,
|
|
23
|
+
tokenBudget: 128e3,
|
|
24
|
+
autoIndex: true,
|
|
25
|
+
autoLearn: true,
|
|
26
|
+
userName: "Developer"
|
|
27
|
+
};
|
|
28
|
+
function ensureDir() {
|
|
29
|
+
if (!fs.existsSync(DOMINUS_DIR)) {
|
|
30
|
+
fs.mkdirSync(DOMINUS_DIR, { recursive: true });
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
function readJson(filePath, defaults) {
|
|
34
|
+
try {
|
|
35
|
+
if (fs.existsSync(filePath)) {
|
|
36
|
+
const raw = fs.readFileSync(filePath, "utf-8");
|
|
37
|
+
return { ...defaults, ...JSON.parse(raw) };
|
|
38
|
+
}
|
|
39
|
+
} catch {
|
|
40
|
+
}
|
|
41
|
+
return { ...defaults };
|
|
42
|
+
}
|
|
43
|
+
function loadConfig() {
|
|
44
|
+
ensureDir();
|
|
45
|
+
return readJson(CONFIG_PATH, DEFAULTS);
|
|
46
|
+
}
|
|
47
|
+
function getDominusDir() {
|
|
48
|
+
ensureDir();
|
|
49
|
+
return DOMINUS_DIR;
|
|
50
|
+
}
|
|
51
|
+
var DOMINUS_PROTOCOL_VERSION = 2;
|
|
52
|
+
var MAX_MESSAGE_BYTES = 1024 * 1024;
|
|
53
|
+
var StudioMsg = {
|
|
54
|
+
HELLO: "studio:hello",
|
|
55
|
+
CONNECTED: "studio:connected",
|
|
56
|
+
DISCONNECTED: "studio:disconnected",
|
|
57
|
+
RESPONSE: "studio:response",
|
|
58
|
+
AUTHENTICATED: "server:authenticated",
|
|
59
|
+
AUTH_REJECTED: "server:auth_rejected"
|
|
60
|
+
};
|
|
61
|
+
var ControllerMsg = {
|
|
62
|
+
HELLO: "controller:hello",
|
|
63
|
+
WELCOME: "controller:welcome",
|
|
64
|
+
RELAY: "controller:relay",
|
|
65
|
+
TARGET_STATE: "controller:target_state",
|
|
66
|
+
SET_ACTIVE_CONNECTION: "controller:set_active_connection"
|
|
67
|
+
};
|
|
68
|
+
function createMessage(type, payload) {
|
|
69
|
+
return { id: nanoid(), type, payload, ts: Date.now() };
|
|
70
|
+
}
|
|
71
|
+
function parseMessage(raw) {
|
|
72
|
+
if (Buffer.byteLength(raw, "utf8") > MAX_MESSAGE_BYTES) {
|
|
73
|
+
throw new Error(`WebSocket message exceeds ${MAX_MESSAGE_BYTES} bytes`);
|
|
74
|
+
}
|
|
75
|
+
try {
|
|
76
|
+
const parsed = JSON.parse(raw);
|
|
77
|
+
return wsMessageSchema.parse(parsed);
|
|
78
|
+
} catch {
|
|
79
|
+
throw new Error(`Failed to parse WebSocket message: ${raw.slice(0, 100)}`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
function serializeMessage(msg) {
|
|
83
|
+
const serialized = JSON.stringify(msg);
|
|
84
|
+
if (Buffer.byteLength(serialized, "utf8") > MAX_MESSAGE_BYTES) {
|
|
85
|
+
throw new Error(`WebSocket message exceeds ${MAX_MESSAGE_BYTES} bytes`);
|
|
86
|
+
}
|
|
87
|
+
return serialized;
|
|
88
|
+
}
|
|
89
|
+
var wsMessageSchema = z.object({
|
|
90
|
+
id: z.string().min(1).max(128),
|
|
91
|
+
type: z.string().min(1).max(128),
|
|
92
|
+
payload: z.unknown(),
|
|
93
|
+
ts: z.number().finite()
|
|
94
|
+
}).strict();
|
|
95
|
+
var TOKEN_FILE = "bridge-token";
|
|
96
|
+
function loadOrCreateBridgeToken() {
|
|
97
|
+
const tokenPath = path.join(getDominusDir(), TOKEN_FILE);
|
|
98
|
+
try {
|
|
99
|
+
const existing = fs.readFileSync(tokenPath, "utf8").trim();
|
|
100
|
+
if (existing.length >= 32) return existing;
|
|
101
|
+
} catch {
|
|
102
|
+
}
|
|
103
|
+
const token = crypto.randomBytes(32).toString("base64url");
|
|
104
|
+
fs.writeFileSync(tokenPath, `${token}
|
|
105
|
+
`, { encoding: "utf8", mode: 384 });
|
|
106
|
+
try {
|
|
107
|
+
fs.chmodSync(tokenPath, 384);
|
|
108
|
+
} catch {
|
|
109
|
+
}
|
|
110
|
+
return token;
|
|
111
|
+
}
|
|
112
|
+
function tokensEqual(actual, expected) {
|
|
113
|
+
if (typeof actual !== "string") return false;
|
|
114
|
+
const actualBuffer = Buffer.from(actual);
|
|
115
|
+
const expectedBuffer = Buffer.from(expected);
|
|
116
|
+
return actualBuffer.length === expectedBuffer.length && crypto.timingSafeEqual(actualBuffer, expectedBuffer);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// src/transport/ws-server.ts
|
|
120
|
+
var HANDSHAKE_TIMEOUT_MS = 8e3;
|
|
121
|
+
var RATE_WINDOW_MS = 1e4;
|
|
122
|
+
var RATE_LIMIT = 250;
|
|
123
|
+
var DominusServer = class extends EventEmitter {
|
|
124
|
+
wss = null;
|
|
125
|
+
studioClients = /* @__PURE__ */ new Map();
|
|
126
|
+
wsToConnectionId = /* @__PURE__ */ new Map();
|
|
127
|
+
controllers = /* @__PURE__ */ new Set();
|
|
128
|
+
socketRoles = /* @__PURE__ */ new Map();
|
|
129
|
+
pendingRequests = /* @__PURE__ */ new Map();
|
|
130
|
+
relayRequests = /* @__PURE__ */ new Map();
|
|
131
|
+
rateState = /* @__PURE__ */ new Map();
|
|
132
|
+
token;
|
|
133
|
+
host;
|
|
134
|
+
maxPayloadBytes;
|
|
135
|
+
handshakeTimeoutMs;
|
|
136
|
+
activeConnectionId = null;
|
|
137
|
+
port;
|
|
138
|
+
constructor(port = 18088, options = {}) {
|
|
139
|
+
super();
|
|
140
|
+
this.port = port;
|
|
141
|
+
this.token = options.token ?? loadOrCreateBridgeToken();
|
|
142
|
+
this.host = options.host ?? "127.0.0.1";
|
|
143
|
+
this.maxPayloadBytes = options.maxPayloadBytes ?? MAX_MESSAGE_BYTES;
|
|
144
|
+
this.handshakeTimeoutMs = options.handshakeTimeoutMs ?? HANDSHAKE_TIMEOUT_MS;
|
|
145
|
+
}
|
|
146
|
+
start(maxRetries = 0) {
|
|
147
|
+
return new Promise((resolve, reject) => {
|
|
148
|
+
const tryPort = (port, attempt) => {
|
|
149
|
+
const wss = new WebSocketServer({
|
|
150
|
+
port,
|
|
151
|
+
host: this.host,
|
|
152
|
+
maxPayload: this.maxPayloadBytes,
|
|
153
|
+
perMessageDeflate: false
|
|
154
|
+
});
|
|
155
|
+
wss.on("listening", () => {
|
|
156
|
+
this.wss = wss;
|
|
157
|
+
this.port = port;
|
|
158
|
+
wss.on("connection", (ws) => this.handleConnection(ws));
|
|
159
|
+
this.emit("server:started", { port, host: this.host });
|
|
160
|
+
resolve();
|
|
161
|
+
});
|
|
162
|
+
wss.on("error", (err) => {
|
|
163
|
+
if (err.code === "EADDRINUSE" && attempt < maxRetries) {
|
|
164
|
+
wss.close();
|
|
165
|
+
tryPort(port + 1, attempt + 1);
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
this.emit("server:error", err);
|
|
169
|
+
reject(err);
|
|
170
|
+
});
|
|
171
|
+
};
|
|
172
|
+
tryPort(this.port, 0);
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
handleConnection(ws) {
|
|
176
|
+
this.socketRoles.set(ws, "unknown");
|
|
177
|
+
this.rateState.set(ws, { startedAt: Date.now(), count: 0 });
|
|
178
|
+
const handshakeTimer = setTimeout(() => {
|
|
179
|
+
if (this.socketRoles.get(ws) === "unknown") {
|
|
180
|
+
ws.close(1008, "Dominus handshake timed out");
|
|
181
|
+
}
|
|
182
|
+
}, this.handshakeTimeoutMs);
|
|
183
|
+
ws.on("message", (raw) => {
|
|
184
|
+
try {
|
|
185
|
+
if (!this.consumeRateBudget(ws) || rawByteLength(raw) > this.maxPayloadBytes) {
|
|
186
|
+
ws.close(1009, "Dominus message limit exceeded");
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
const msg = parseMessage(raw.toString());
|
|
190
|
+
const role = this.socketRoles.get(ws) ?? "unknown";
|
|
191
|
+
if (role === "unknown") {
|
|
192
|
+
clearTimeout(handshakeTimer);
|
|
193
|
+
this.handleHandshake(ws, msg);
|
|
194
|
+
} else if (role === "controller") {
|
|
195
|
+
this.handleControllerMessage(ws, msg);
|
|
196
|
+
} else if (role === "studio") {
|
|
197
|
+
this.handleStudioMessage(ws, msg);
|
|
198
|
+
}
|
|
199
|
+
} catch (err) {
|
|
200
|
+
this.emit("server:error", err);
|
|
201
|
+
this.sendSafe(
|
|
202
|
+
ws,
|
|
203
|
+
createMessage(StudioMsg.AUTH_REJECTED, {
|
|
204
|
+
error: err instanceof Error ? err.message : String(err)
|
|
205
|
+
})
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
ws.on("close", () => {
|
|
210
|
+
clearTimeout(handshakeTimer);
|
|
211
|
+
this.cleanupSocket(ws);
|
|
212
|
+
});
|
|
213
|
+
ws.on("error", (err) => this.emit("server:error", err));
|
|
214
|
+
}
|
|
215
|
+
consumeRateBudget(ws) {
|
|
216
|
+
const now = Date.now();
|
|
217
|
+
const state = this.rateState.get(ws) ?? { startedAt: now, count: 0 };
|
|
218
|
+
if (now - state.startedAt >= RATE_WINDOW_MS) {
|
|
219
|
+
state.startedAt = now;
|
|
220
|
+
state.count = 0;
|
|
221
|
+
}
|
|
222
|
+
state.count += 1;
|
|
223
|
+
this.rateState.set(ws, state);
|
|
224
|
+
return state.count <= RATE_LIMIT;
|
|
225
|
+
}
|
|
226
|
+
handleHandshake(ws, msg) {
|
|
227
|
+
if (msg.type === StudioMsg.HELLO) {
|
|
228
|
+
const hello = validateStudioHello(msg.payload);
|
|
229
|
+
if (tokensEqual(hello.token, this.token)) {
|
|
230
|
+
this.authorizeStudio(ws, hello, randomUUID());
|
|
231
|
+
} else {
|
|
232
|
+
this.sendSafe(
|
|
233
|
+
ws,
|
|
234
|
+
createMessage(StudioMsg.AUTH_REJECTED, {
|
|
235
|
+
error: "Studio credentials are missing or stale. Run dominus-install-plugin."
|
|
236
|
+
})
|
|
237
|
+
);
|
|
238
|
+
ws.close(1008, "Unauthorized Studio plugin");
|
|
239
|
+
}
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
if (msg.type === ControllerMsg.HELLO) {
|
|
243
|
+
const payload = msg.payload;
|
|
244
|
+
if (payload.protocolVersion !== DOMINUS_PROTOCOL_VERSION || !tokensEqual(payload.token, this.token)) {
|
|
245
|
+
this.sendSafe(
|
|
246
|
+
ws,
|
|
247
|
+
createMessage(StudioMsg.AUTH_REJECTED, {
|
|
248
|
+
error: "Invalid Dominus controller credentials"
|
|
249
|
+
})
|
|
250
|
+
);
|
|
251
|
+
ws.close(1008, "Unauthorized controller");
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
this.socketRoles.set(ws, "controller");
|
|
255
|
+
this.controllers.add(ws);
|
|
256
|
+
this.sendSafe(
|
|
257
|
+
ws,
|
|
258
|
+
createMessage(ControllerMsg.WELCOME, {
|
|
259
|
+
protocolVersion: DOMINUS_PROTOCOL_VERSION,
|
|
260
|
+
port: this.port,
|
|
261
|
+
connections: this.listConnections(),
|
|
262
|
+
activeConnectionId: this.activeConnectionId
|
|
263
|
+
})
|
|
264
|
+
);
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
this.sendSafe(
|
|
268
|
+
ws,
|
|
269
|
+
createMessage(StudioMsg.AUTH_REJECTED, {
|
|
270
|
+
error: `Expected ${StudioMsg.HELLO} or ${ControllerMsg.HELLO}`
|
|
271
|
+
})
|
|
272
|
+
);
|
|
273
|
+
ws.close(1008, "Invalid Dominus handshake");
|
|
274
|
+
}
|
|
275
|
+
authorizeStudio(ws, hello, connectionId) {
|
|
276
|
+
const connection = {
|
|
277
|
+
ws,
|
|
278
|
+
connectionId,
|
|
279
|
+
clientId: hello.clientId,
|
|
280
|
+
connectedAt: Date.now(),
|
|
281
|
+
studioVersion: hello.studioVersion,
|
|
282
|
+
placeId: hello.placeId ?? 0,
|
|
283
|
+
placeName: hello.placeName
|
|
284
|
+
};
|
|
285
|
+
this.studioClients.set(connectionId, connection);
|
|
286
|
+
this.wsToConnectionId.set(ws, connectionId);
|
|
287
|
+
this.socketRoles.set(ws, "studio");
|
|
288
|
+
if (!this.activeConnectionId || !this.studioClients.has(this.activeConnectionId)) {
|
|
289
|
+
this.activeConnectionId = connectionId;
|
|
290
|
+
}
|
|
291
|
+
this.sendSafe(
|
|
292
|
+
ws,
|
|
293
|
+
createMessage(StudioMsg.AUTHENTICATED, {
|
|
294
|
+
protocolVersion: DOMINUS_PROTOCOL_VERSION,
|
|
295
|
+
connectionId,
|
|
296
|
+
token: this.token
|
|
297
|
+
})
|
|
298
|
+
);
|
|
299
|
+
const info = this.toPlaceInfo(connection);
|
|
300
|
+
this.emit("studio:connected", info);
|
|
301
|
+
this.broadcastToControllers(StudioMsg.CONNECTED, info);
|
|
302
|
+
this.broadcastTargetState();
|
|
303
|
+
return connection;
|
|
304
|
+
}
|
|
305
|
+
handleControllerMessage(controllerWs, msg) {
|
|
306
|
+
if (msg.type === ControllerMsg.RELAY) {
|
|
307
|
+
const payload = msg.payload;
|
|
308
|
+
const request = parseMessage(JSON.stringify(payload.request));
|
|
309
|
+
const target = this.resolveConnection(
|
|
310
|
+
typeof payload.connectionId === "string" ? payload.connectionId : null
|
|
311
|
+
);
|
|
312
|
+
if (!target) {
|
|
313
|
+
this.sendResponse(controllerWs, request.id, { error: this.getTargetError() });
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
this.relayRequests.set(request.id, { controllerWs, targetWs: target.ws });
|
|
317
|
+
this.sendSafe(target.ws, request);
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
if (msg.type === ControllerMsg.SET_ACTIVE_CONNECTION) {
|
|
321
|
+
const payload = msg.payload;
|
|
322
|
+
const connectionId = typeof payload.connectionId === "string" ? payload.connectionId : null;
|
|
323
|
+
if (connectionId && !this.studioClients.has(connectionId)) {
|
|
324
|
+
this.sendResponse(controllerWs, msg.id, {
|
|
325
|
+
success: false,
|
|
326
|
+
error: `Studio connection ${connectionId} is not connected`
|
|
327
|
+
});
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
this.sendResponse(controllerWs, msg.id, { success: true, connectionId });
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
this.sendResponse(controllerWs, msg.id, {
|
|
334
|
+
error: `Unsupported controller message: ${msg.type}`
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
handleStudioMessage(ws, msg) {
|
|
338
|
+
if (msg.type === StudioMsg.RESPONSE) {
|
|
339
|
+
const relay = this.relayRequests.get(msg.id);
|
|
340
|
+
if (relay) {
|
|
341
|
+
if (relay.targetWs !== ws) {
|
|
342
|
+
this.emit(
|
|
343
|
+
"server:error",
|
|
344
|
+
new Error(`Ignored relayed response ${msg.id} from the wrong Studio session`)
|
|
345
|
+
);
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
this.sendSafe(relay.controllerWs, msg);
|
|
349
|
+
this.relayRequests.delete(msg.id);
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
const pending = this.pendingRequests.get(msg.id);
|
|
353
|
+
if (pending) {
|
|
354
|
+
if (pending.targetWs !== ws) {
|
|
355
|
+
this.emit(
|
|
356
|
+
"server:error",
|
|
357
|
+
new Error(`Ignored response ${msg.id} from the wrong Studio session`)
|
|
358
|
+
);
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
clearTimeout(pending.timer);
|
|
362
|
+
this.pendingRequests.delete(msg.id);
|
|
363
|
+
pending.resolve(msg);
|
|
364
|
+
}
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
this.emit(msg.type, msg.payload);
|
|
368
|
+
this.emit("message", msg);
|
|
369
|
+
}
|
|
370
|
+
cleanupSocket(ws) {
|
|
371
|
+
const role = this.socketRoles.get(ws);
|
|
372
|
+
this.socketRoles.delete(ws);
|
|
373
|
+
this.rateState.delete(ws);
|
|
374
|
+
if (role === "controller") {
|
|
375
|
+
this.controllers.delete(ws);
|
|
376
|
+
for (const [id, relay] of this.relayRequests) {
|
|
377
|
+
if (relay.controllerWs === ws) this.relayRequests.delete(id);
|
|
378
|
+
}
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
const connectionId = this.wsToConnectionId.get(ws);
|
|
382
|
+
if (!connectionId) return;
|
|
383
|
+
const connection = this.studioClients.get(connectionId);
|
|
384
|
+
this.wsToConnectionId.delete(ws);
|
|
385
|
+
this.studioClients.delete(connectionId);
|
|
386
|
+
for (const [id, pending] of this.pendingRequests) {
|
|
387
|
+
if (pending.targetWs === ws) {
|
|
388
|
+
clearTimeout(pending.timer);
|
|
389
|
+
pending.reject(new Error(`Studio disconnected (${connectionId})`));
|
|
390
|
+
this.pendingRequests.delete(id);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
for (const [id, relay] of this.relayRequests) {
|
|
394
|
+
if (relay.targetWs === ws) {
|
|
395
|
+
this.sendResponse(relay.controllerWs, id, {
|
|
396
|
+
error: `Studio disconnected (${connectionId})`
|
|
397
|
+
});
|
|
398
|
+
this.relayRequests.delete(id);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
if (this.activeConnectionId === connectionId) {
|
|
402
|
+
this.activeConnectionId = this.studioClients.size === 1 ? this.studioClients.keys().next().value ?? null : null;
|
|
403
|
+
}
|
|
404
|
+
const info = connection ? this.toPlaceInfo(connection) : { connectionId };
|
|
405
|
+
this.emit("studio:disconnected", info);
|
|
406
|
+
this.broadcastToControllers(StudioMsg.DISCONNECTED, info);
|
|
407
|
+
this.broadcastTargetState();
|
|
408
|
+
}
|
|
409
|
+
resolveConnection(connectionId = this.activeConnectionId) {
|
|
410
|
+
if (connectionId) {
|
|
411
|
+
const connection = this.studioClients.get(connectionId);
|
|
412
|
+
return connection?.ws.readyState === WebSocket.OPEN ? connection : null;
|
|
413
|
+
}
|
|
414
|
+
if (this.studioClients.size === 1) {
|
|
415
|
+
const connection = this.studioClients.values().next().value;
|
|
416
|
+
return connection?.ws.readyState === WebSocket.OPEN ? connection : null;
|
|
417
|
+
}
|
|
418
|
+
return null;
|
|
419
|
+
}
|
|
420
|
+
getTargetError() {
|
|
421
|
+
if (this.studioClients.size === 0) return "No authenticated Roblox Studio connection";
|
|
422
|
+
if (this.activeConnectionId)
|
|
423
|
+
return `Studio connection ${this.activeConnectionId} is unavailable`;
|
|
424
|
+
return "Multiple Studio sessions are connected. Call dominus_select_studio with a connectionId.";
|
|
425
|
+
}
|
|
426
|
+
toPlaceInfo(connection) {
|
|
427
|
+
return {
|
|
428
|
+
placeId: connection.placeId ?? 0,
|
|
429
|
+
connectionId: connection.connectionId,
|
|
430
|
+
clientId: connection.clientId,
|
|
431
|
+
placeName: connection.placeName,
|
|
432
|
+
studioVersion: connection.studioVersion,
|
|
433
|
+
connectedAt: connection.connectedAt,
|
|
434
|
+
active: connection.connectionId === this.activeConnectionId
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
broadcastToControllers(type, payload) {
|
|
438
|
+
const message = createMessage(type, payload);
|
|
439
|
+
for (const controller of this.controllers) this.sendSafe(controller, message);
|
|
440
|
+
}
|
|
441
|
+
broadcastTargetState() {
|
|
442
|
+
this.broadcastToControllers(ControllerMsg.TARGET_STATE, {
|
|
443
|
+
connections: this.listConnections(),
|
|
444
|
+
defaultActiveConnectionId: this.activeConnectionId
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
sendResponse(ws, id, payload) {
|
|
448
|
+
this.sendSafe(ws, { id, type: StudioMsg.RESPONSE, payload, ts: Date.now() });
|
|
449
|
+
}
|
|
450
|
+
sendSafe(ws, message) {
|
|
451
|
+
if (ws.readyState !== WebSocket.OPEN) return;
|
|
452
|
+
try {
|
|
453
|
+
ws.send(serializeMessage(message));
|
|
454
|
+
} catch (err) {
|
|
455
|
+
this.emit("server:error", err);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
sendRequest(type, payload, timeoutMs = 15e3) {
|
|
459
|
+
return new Promise((resolve, reject) => {
|
|
460
|
+
const target = this.resolveConnection();
|
|
461
|
+
if (!target) {
|
|
462
|
+
reject(new Error(this.getTargetError()));
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
const message = createMessage(type, payload);
|
|
466
|
+
const timer = setTimeout(() => {
|
|
467
|
+
this.pendingRequests.delete(message.id);
|
|
468
|
+
reject(new Error(`Studio request timed out: ${type}`));
|
|
469
|
+
}, timeoutMs);
|
|
470
|
+
this.pendingRequests.set(message.id, {
|
|
471
|
+
resolve,
|
|
472
|
+
reject,
|
|
473
|
+
timer,
|
|
474
|
+
targetWs: target.ws
|
|
475
|
+
});
|
|
476
|
+
this.sendSafe(target.ws, message);
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
sendNotification(type, payload) {
|
|
480
|
+
const target = this.resolveConnection();
|
|
481
|
+
if (target) this.sendSafe(target.ws, createMessage(type, payload));
|
|
482
|
+
}
|
|
483
|
+
isConnected() {
|
|
484
|
+
return this.resolveConnection() !== null;
|
|
485
|
+
}
|
|
486
|
+
getConnectionInfo() {
|
|
487
|
+
return this.resolveConnection();
|
|
488
|
+
}
|
|
489
|
+
listConnections() {
|
|
490
|
+
return [...this.studioClients.values()].filter(({ ws }) => ws.readyState === WebSocket.OPEN).map((connection) => this.toPlaceInfo(connection));
|
|
491
|
+
}
|
|
492
|
+
getActiveConnectionId() {
|
|
493
|
+
return this.activeConnectionId;
|
|
494
|
+
}
|
|
495
|
+
async setActiveConnectionId(connectionId) {
|
|
496
|
+
if (connectionId && !this.studioClients.has(connectionId)) {
|
|
497
|
+
throw new Error(`Studio connection ${connectionId} is not connected`);
|
|
498
|
+
}
|
|
499
|
+
this.activeConnectionId = connectionId;
|
|
500
|
+
this.broadcastTargetState();
|
|
501
|
+
}
|
|
502
|
+
getActivePlaceId() {
|
|
503
|
+
return this.resolveConnection()?.placeId ?? 0;
|
|
504
|
+
}
|
|
505
|
+
setActivePlaceId(placeId) {
|
|
506
|
+
if (placeId === 0) {
|
|
507
|
+
this.activeConnectionId = null;
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
const matches = [...this.studioClients.values()].filter(
|
|
511
|
+
(connection) => connection.placeId === placeId
|
|
512
|
+
);
|
|
513
|
+
if (matches.length !== 1) {
|
|
514
|
+
throw new Error(
|
|
515
|
+
matches.length === 0 ? `Place ${placeId} is not connected` : `Place ${placeId} has multiple Studio sessions; select a connectionId instead`
|
|
516
|
+
);
|
|
517
|
+
}
|
|
518
|
+
this.activeConnectionId = matches[0].connectionId;
|
|
519
|
+
}
|
|
520
|
+
getPort() {
|
|
521
|
+
return this.port;
|
|
522
|
+
}
|
|
523
|
+
stop() {
|
|
524
|
+
return new Promise((resolve) => {
|
|
525
|
+
for (const pending of this.pendingRequests.values()) {
|
|
526
|
+
clearTimeout(pending.timer);
|
|
527
|
+
pending.reject(new Error("Dominus bridge is shutting down"));
|
|
528
|
+
}
|
|
529
|
+
this.pendingRequests.clear();
|
|
530
|
+
this.relayRequests.clear();
|
|
531
|
+
for (const ws of this.socketRoles.keys()) ws.close(1001, "Dominus bridge shutdown");
|
|
532
|
+
this.studioClients.clear();
|
|
533
|
+
this.controllers.clear();
|
|
534
|
+
this.socketRoles.clear();
|
|
535
|
+
this.wsToConnectionId.clear();
|
|
536
|
+
if (this.wss) this.wss.close(() => resolve());
|
|
537
|
+
else resolve();
|
|
538
|
+
});
|
|
539
|
+
}
|
|
540
|
+
};
|
|
541
|
+
function validateStudioHello(payload) {
|
|
542
|
+
if (!payload || typeof payload !== "object") throw new Error("Invalid Studio hello payload");
|
|
543
|
+
const hello = payload;
|
|
544
|
+
if (hello.protocolVersion !== DOMINUS_PROTOCOL_VERSION) {
|
|
545
|
+
throw new Error(
|
|
546
|
+
`Studio plugin protocol ${hello.protocolVersion ?? "unknown"} is incompatible; expected ${DOMINUS_PROTOCOL_VERSION}`
|
|
547
|
+
);
|
|
548
|
+
}
|
|
549
|
+
if (typeof hello.clientId !== "string" || hello.clientId.length < 8 || hello.clientId.length > 128) {
|
|
550
|
+
throw new Error("Studio hello is missing a valid clientId");
|
|
551
|
+
}
|
|
552
|
+
if (typeof hello.studioVersion !== "string" || hello.studioVersion.length > 128) {
|
|
553
|
+
throw new Error("Studio hello is missing studioVersion");
|
|
554
|
+
}
|
|
555
|
+
if (hello.placeName && hello.placeName.length > 256)
|
|
556
|
+
throw new Error("Studio placeName is too long");
|
|
557
|
+
return hello;
|
|
558
|
+
}
|
|
559
|
+
function rawByteLength(raw) {
|
|
560
|
+
if (typeof raw === "string") return Buffer.byteLength(raw);
|
|
561
|
+
if (Array.isArray(raw)) return raw.reduce((total, item) => total + item.byteLength, 0);
|
|
562
|
+
return raw.byteLength;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// src/transport/bridge-daemon.ts
|
|
566
|
+
var configuredPort = Number(process.env.DOMINUS_BRIDGE_PORT ?? loadConfig().port);
|
|
567
|
+
if (!Number.isInteger(configuredPort) || configuredPort < 1 || configuredPort > 65535) {
|
|
568
|
+
throw new Error(`Invalid Dominus bridge port: ${configuredPort}`);
|
|
569
|
+
}
|
|
570
|
+
var bridge = new DominusServer(configuredPort);
|
|
571
|
+
await bridge.start();
|
|
572
|
+
var stopping = false;
|
|
573
|
+
async function stop() {
|
|
574
|
+
if (stopping) return;
|
|
575
|
+
stopping = true;
|
|
576
|
+
await bridge.stop();
|
|
577
|
+
}
|
|
578
|
+
process.once("SIGINT", () => void stop().finally(() => process.exit(0)));
|
|
579
|
+
process.once("SIGTERM", () => void stop().finally(() => process.exit(0)));
|
|
580
|
+
//# sourceMappingURL=bridge-daemon.js.map
|
|
581
|
+
//# sourceMappingURL=bridge-daemon.js.map
|