dominus-cli 2.2.0 → 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 +18 -0
- package/README.md +19 -11
- package/dist/bridge-daemon.js +581 -0
- package/dist/bridge-daemon.js.map +1 -0
- package/dist/install-plugin.js +497 -27
- package/dist/install-plugin.js.map +1 -1
- package/dist/mcp.js +119 -464
- package/dist/mcp.js.map +1 -1
- package/package.json +1 -1
- package/plugin/Dominus.rbxmx +7 -3
- package/plugin/default.project.json +4 -1
- package/plugin/src/init.server.lua +5 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 2.2.1 (2026-07-13)
|
|
4
|
+
|
|
5
|
+
### Stable Studio bridge architecture
|
|
6
|
+
|
|
7
|
+
- Moved WebSocket ownership out of individual MCP stdio processes and into one
|
|
8
|
+
detached local bridge daemon. MCP hosts now act as controllers, so closing or
|
|
9
|
+
restarting one agent no longer disconnects Studio or other agents.
|
|
10
|
+
- Added race-safe bridge startup and reuse. Concurrent MCP launches converge on
|
|
11
|
+
the same loopback bridge instead of creating separate connection state.
|
|
12
|
+
- The plugin installer now starts the bridge automatically and provisions the
|
|
13
|
+
user-specific 256-bit credential into the local plugin. Manual connection IDs
|
|
14
|
+
and six-digit pairing codes are no longer part of setup or recovery.
|
|
15
|
+
- Built the plugin root with `RunContext.Plugin` and added an entry-point guard.
|
|
16
|
+
Starting a Play test can no longer launch duplicate Client and Server Dominus
|
|
17
|
+
sessions from one Studio window.
|
|
18
|
+
- Added regressions for plugin run context, seamless credential provisioning,
|
|
19
|
+
unauthorized plugin rejection, and bridge survival across MCP controller exits.
|
|
20
|
+
|
|
3
21
|
## 2.2.0 (2026-07-13)
|
|
4
22
|
|
|
5
23
|
### MCP-native multi-agent coordinator
|
package/README.md
CHANGED
|
@@ -15,7 +15,10 @@ Luau execution.
|
|
|
15
15
|
sibling names for the life of the Studio session.
|
|
16
16
|
- Script writes require a revision from `studio_read_script`.
|
|
17
17
|
- Mutation batches and UI replacement are undoable and roll back on failure.
|
|
18
|
-
- The installer provisions a
|
|
18
|
+
- The installer provisions a local bridge credential automatically; users never
|
|
19
|
+
copy connection IDs or pairing codes.
|
|
20
|
+
- A single background bridge keeps Studio connected while MCP hosts and agents
|
|
21
|
+
start, stop, or run in parallel.
|
|
19
22
|
- Multiple Studio windows are selected by unique connection ID, not place ID.
|
|
20
23
|
- Roblox Creator Docs API lookup is built in.
|
|
21
24
|
|
|
@@ -65,7 +68,7 @@ For a version-pinned configuration that does not require a global install:
|
|
|
65
68
|
"mcpServers": {
|
|
66
69
|
"dominus": {
|
|
67
70
|
"command": "npx",
|
|
68
|
-
"args": ["-y", "-p", "dominus-cli@2.2.
|
|
71
|
+
"args": ["-y", "-p", "dominus-cli@2.2.1", "dominus-mcp"]
|
|
69
72
|
}
|
|
70
73
|
}
|
|
71
74
|
}
|
|
@@ -75,9 +78,13 @@ Restart the MCP client after changing its configuration.
|
|
|
75
78
|
|
|
76
79
|
## First connection
|
|
77
80
|
|
|
78
|
-
1. Run `dominus-install-plugin`;
|
|
79
|
-
|
|
80
|
-
|
|
81
|
+
1. Run `dominus-install-plugin`; it installs the personalized plugin and starts
|
|
82
|
+
the local Studio bridge in the background.
|
|
83
|
+
2. Open Roblox Studio and start the MCP client. Dominus authenticates
|
|
84
|
+
automatically with no connection ID or pairing code.
|
|
85
|
+
|
|
86
|
+
If the local credential is replaced or lost, run `dominus-install-plugin` again.
|
|
87
|
+
Studio normally reloads it without a restart.
|
|
81
88
|
|
|
82
89
|
If several Studio windows are open, call `dominus_select_studio` with the exact
|
|
83
90
|
connection ID before editing. Two windows on the same place ID remain distinct.
|
|
@@ -139,12 +146,13 @@ Roblox documentation:
|
|
|
139
146
|
|
|
140
147
|
## Security model
|
|
141
148
|
|
|
142
|
-
The MCP transport is stdio.
|
|
143
|
-
`127.0.0.1`, limits messages to one MiB, rate-limits sockets, times out
|
|
144
|
-
handshakes, and authenticates both Studio plugins and
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
`loadstring`
|
|
149
|
+
The MCP transport is stdio. A single background Studio WebSocket bridge binds
|
|
150
|
+
only to `127.0.0.1`, limits messages to one MiB, rate-limits sockets, times out
|
|
151
|
+
incomplete handshakes, and authenticates both Studio plugins and MCP controllers.
|
|
152
|
+
The distributed plugin contains only a credential placeholder;
|
|
153
|
+
`dominus-install-plugin` writes the user-specific token into the local copy. The
|
|
154
|
+
plugin only accepts an explicit command allowlist and contains no `loadstring`
|
|
155
|
+
executor.
|
|
148
156
|
|
|
149
157
|
The bridge token protects against accidental or opportunistic access by unrelated
|
|
150
158
|
local processes. Like other user-level credentials, it cannot protect against a
|
|
@@ -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
|