conduit-mcp 2.1.7 → 2.1.9
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.
|
@@ -40,11 +40,12 @@ var log = {
|
|
|
40
40
|
// src/bridge.ts
|
|
41
41
|
var REQUEST_TIMEOUT_MS = 6e4;
|
|
42
42
|
var HEARTBEAT_CHECK_INTERVAL_MS = 5e3;
|
|
43
|
-
var HEARTBEAT_TIMEOUT_MS =
|
|
43
|
+
var HEARTBEAT_TIMEOUT_MS = 2e4;
|
|
44
44
|
var PING_INTERVAL_MS = 1e4;
|
|
45
45
|
var LONG_POLL_TIMEOUT_MS = 25e3;
|
|
46
46
|
var MAX_PORT_RETRIES = 10;
|
|
47
47
|
var REGISTRATION_TIMEOUT_MS = 1e4;
|
|
48
|
+
var FIRST_CONNECT_WAIT_MS = 3e3;
|
|
48
49
|
var Bridge = class extends EventEmitter {
|
|
49
50
|
constructor(port = 3200) {
|
|
50
51
|
super();
|
|
@@ -212,18 +213,26 @@ var Bridge = class extends EventEmitter {
|
|
|
212
213
|
const id = generateId();
|
|
213
214
|
const request = { id, method, params };
|
|
214
215
|
const json = JSON.stringify(request);
|
|
216
|
+
if (this.studios.size === 0 && this.httpStudios.size === 0) {
|
|
217
|
+
await this.waitForFirstStudio(FIRST_CONNECT_WAIT_MS);
|
|
218
|
+
if (this.studios.size === 0 && this.httpStudios.size === 0) {
|
|
219
|
+
throw this.buildPluginNotConnectedError(method);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
215
222
|
const studio = this.resolveTargetStudio();
|
|
223
|
+
const targetStudioId = this.activeStudioId;
|
|
216
224
|
const effectiveTimeout = timeoutMs ?? REQUEST_TIMEOUT_MS;
|
|
217
225
|
return new Promise((resolve, reject) => {
|
|
218
226
|
const timer = setTimeout(() => {
|
|
219
227
|
this.pendingRequests.delete(id);
|
|
220
|
-
reject(
|
|
221
|
-
new Error(
|
|
222
|
-
`Request ${method} timed out after ${effectiveTimeout}ms \u2014 is the Conduit plugin running in Roblox Studio?`
|
|
223
|
-
)
|
|
224
|
-
);
|
|
228
|
+
reject(this.buildTimeoutError(method, effectiveTimeout, targetStudioId));
|
|
225
229
|
}, effectiveTimeout);
|
|
226
|
-
this.pendingRequests.set(id, {
|
|
230
|
+
this.pendingRequests.set(id, {
|
|
231
|
+
resolve,
|
|
232
|
+
reject,
|
|
233
|
+
timer,
|
|
234
|
+
studioId: targetStudioId
|
|
235
|
+
});
|
|
227
236
|
if (studio) {
|
|
228
237
|
studio.ws.send(json, (err) => {
|
|
229
238
|
if (err) {
|
|
@@ -249,6 +258,73 @@ var Bridge = class extends EventEmitter {
|
|
|
249
258
|
}
|
|
250
259
|
});
|
|
251
260
|
}
|
|
261
|
+
// Wait for any studio to register, up to timeoutMs. Resolves immediately if
|
|
262
|
+
// one is already connected. Used to absorb the startup race between MCP
|
|
263
|
+
// initialize (instant) and the plugin's WebSocket handshake (~hundreds of ms).
|
|
264
|
+
waitForFirstStudio(timeoutMs) {
|
|
265
|
+
if (this.studios.size > 0 || this.httpStudios.size > 0) {
|
|
266
|
+
return Promise.resolve();
|
|
267
|
+
}
|
|
268
|
+
return new Promise((resolve) => {
|
|
269
|
+
const timer = setTimeout(() => {
|
|
270
|
+
this.off("studio-connected", onConnect);
|
|
271
|
+
resolve();
|
|
272
|
+
}, timeoutMs);
|
|
273
|
+
const onConnect = () => {
|
|
274
|
+
clearTimeout(timer);
|
|
275
|
+
resolve();
|
|
276
|
+
};
|
|
277
|
+
this.once("studio-connected", onConnect);
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
buildPluginNotConnectedError(method) {
|
|
281
|
+
const err = new Error(
|
|
282
|
+
`Cannot ${method}: the Roblox Studio plugin is not connected. Open Roblox Studio with the Conduit plugin enabled \u2014 it auto-connects when HttpService is allowed (Game Settings \u2192 Security \u2192 Allow HTTP Requests). Check the Conduit dashboard in Studio for connection status.`
|
|
283
|
+
);
|
|
284
|
+
err.code = "PLUGIN_NOT_CONNECTED";
|
|
285
|
+
return err;
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* Classify why a request timed out. Without this, three very different failure modes
|
|
289
|
+
* ("plugin isn't running", "socket is dead", "plugin got the request but its handler
|
|
290
|
+
* is stuck") all surface as the same generic "timed out" error, so callers can't
|
|
291
|
+
* decide whether to retry, reconnect, or surface the hang to the user.
|
|
292
|
+
*
|
|
293
|
+
* We inspect studio state *at timeout fire time* (not request send time) because
|
|
294
|
+
* the socket may have died mid-flight.
|
|
295
|
+
*/
|
|
296
|
+
buildTimeoutError(method, timeoutMs, targetStudioId) {
|
|
297
|
+
const base = `Request ${method} timed out after ${timeoutMs}ms`;
|
|
298
|
+
if (!targetStudioId) {
|
|
299
|
+
const err2 = new Error(
|
|
300
|
+
`${base}: no Roblox Studio plugin was connected when the request was dispatched.`
|
|
301
|
+
);
|
|
302
|
+
err2.code = "PLUGIN_NOT_CONNECTED";
|
|
303
|
+
return err2;
|
|
304
|
+
}
|
|
305
|
+
const studio = this.studios.get(targetStudioId);
|
|
306
|
+
if (!studio || studio.ws.readyState !== WebSocket.OPEN) {
|
|
307
|
+
const err2 = new Error(
|
|
308
|
+
`${base}: the plugin WebSocket for studio "${targetStudioId}" closed before a response arrived.`
|
|
309
|
+
);
|
|
310
|
+
err2.code = "PLUGIN_DISCONNECTED";
|
|
311
|
+
return err2;
|
|
312
|
+
}
|
|
313
|
+
const lastBeat = this.lastHeartbeats.get(targetStudioId);
|
|
314
|
+
const age = lastBeat !== void 0 ? Date.now() - lastBeat : Infinity;
|
|
315
|
+
if (age > HEARTBEAT_TIMEOUT_MS) {
|
|
316
|
+
const err2 = new Error(
|
|
317
|
+
`${base}: the plugin stopped sending heartbeats (${Math.round(age / 1e3)}s ago) \u2014 Studio may be frozen.`
|
|
318
|
+
);
|
|
319
|
+
err2.code = "PLUGIN_UNRESPONSIVE";
|
|
320
|
+
return err2;
|
|
321
|
+
}
|
|
322
|
+
const err = new Error(
|
|
323
|
+
`${base}: the plugin is still responsive but the "${method}" handler did not return in time. This usually means user Lua is running long or the mutation queue is stalled on a prior command.`
|
|
324
|
+
);
|
|
325
|
+
err.code = "HANDLER_TIMEOUT";
|
|
326
|
+
return err;
|
|
327
|
+
}
|
|
252
328
|
// ── Internal helpers ───────────────────────────────────────────
|
|
253
329
|
getActiveStudio() {
|
|
254
330
|
if (this.activeStudioId) {
|
|
@@ -274,6 +350,11 @@ var Bridge = class extends EventEmitter {
|
|
|
274
350
|
studio.ws.terminate();
|
|
275
351
|
this.studios.delete(studioId);
|
|
276
352
|
this.lastHeartbeats.delete(studioId);
|
|
353
|
+
this.failPendingForStudio(
|
|
354
|
+
studioId,
|
|
355
|
+
"PLUGIN_UNRESPONSIVE",
|
|
356
|
+
`the plugin connection went stale and was evicted before responding`
|
|
357
|
+
);
|
|
277
358
|
this.emit("studio-disconnected", studio.info);
|
|
278
359
|
log.info(`Evicted stale studio: ${studioId}`);
|
|
279
360
|
}
|
|
@@ -415,6 +496,11 @@ var Bridge = class extends EventEmitter {
|
|
|
415
496
|
if (current && current.ws === ws) {
|
|
416
497
|
this.studios.delete(studioId);
|
|
417
498
|
this.lastHeartbeats.delete(studioId);
|
|
499
|
+
this.failPendingForStudio(
|
|
500
|
+
studioId,
|
|
501
|
+
"PLUGIN_DISCONNECTED",
|
|
502
|
+
`the plugin WebSocket closed before a response arrived`
|
|
503
|
+
);
|
|
418
504
|
this.emit("studio-disconnected", info);
|
|
419
505
|
log.info(`Studio disconnected: ${studioId}`);
|
|
420
506
|
if (this.activeStudioId === studioId) {
|
|
@@ -434,6 +520,19 @@ var Bridge = class extends EventEmitter {
|
|
|
434
520
|
}
|
|
435
521
|
});
|
|
436
522
|
}
|
|
523
|
+
failPendingForStudio(studioId, code, reason) {
|
|
524
|
+
for (const [id, pending] of this.pendingRequests) {
|
|
525
|
+
if (pending.studioId === studioId) {
|
|
526
|
+
clearTimeout(pending.timer);
|
|
527
|
+
this.pendingRequests.delete(id);
|
|
528
|
+
const err = new Error(
|
|
529
|
+
`Request aborted: ${reason} (studio: ${studioId}). The plugin should auto-reconnect \u2014 please retry.`
|
|
530
|
+
);
|
|
531
|
+
err.code = code;
|
|
532
|
+
pending.reject(err);
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
}
|
|
437
536
|
handlePluginMessage(msg) {
|
|
438
537
|
if (isBridgeError(msg)) {
|
|
439
538
|
const pending = this.pendingRequests.get(msg.id);
|
|
@@ -475,6 +574,12 @@ var Bridge = class extends EventEmitter {
|
|
|
475
574
|
const info = this.httpStudios.get(studioId);
|
|
476
575
|
this.httpStudios.delete(studioId);
|
|
477
576
|
this.lastHeartbeats.delete(studioId);
|
|
577
|
+
this.httpPendingCommands.delete(studioId);
|
|
578
|
+
this.failPendingForStudio(
|
|
579
|
+
studioId,
|
|
580
|
+
"PLUGIN_UNRESPONSIVE",
|
|
581
|
+
`the HTTP-fallback plugin stopped polling for ${Math.round(HEARTBEAT_TIMEOUT_MS / 1e3)}s`
|
|
582
|
+
);
|
|
478
583
|
this.emit("studio-disconnected", info);
|
|
479
584
|
if (this.activeStudioId === studioId) {
|
|
480
585
|
const remaining = this.getStudios();
|
|
@@ -2601,4 +2706,4 @@ async function startServer(port = 3200, options = {}) {
|
|
|
2601
2706
|
export {
|
|
2602
2707
|
startServer
|
|
2603
2708
|
};
|
|
2604
|
-
//# sourceMappingURL=chunk-
|
|
2709
|
+
//# sourceMappingURL=chunk-2OAUC6UN.js.map
|