@adiholick/nvstudio-mcp 2.2.1 → 2.2.2
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/dist/index.js
CHANGED
|
@@ -66,6 +66,7 @@ server.setRequestHandler(types_js_1.ListToolsRequestSchema, async () => {
|
|
|
66
66
|
});
|
|
67
67
|
// 5. Handler untuk menangani pemanggilan call_mcp_tool
|
|
68
68
|
server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
|
|
69
|
+
await roblox_bridge_1.bridgeReadyPromise;
|
|
69
70
|
if (request.params.name !== "call_mcp_tool") {
|
|
70
71
|
throw new Error(`Tool tidak dikenali: ${request.params.name}`);
|
|
71
72
|
}
|
|
@@ -141,7 +142,11 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
|
|
|
141
142
|
if (roblox_bridge_1.isBridgeHosting) {
|
|
142
143
|
// Instance ini sendiri yang meng-host bridge server
|
|
143
144
|
if (!(0, roblox_bridge_1.isStudioConnected)()) {
|
|
144
|
-
|
|
145
|
+
for (let i = 0; i < 6; i++) {
|
|
146
|
+
await new Promise(r => setTimeout(r, 50));
|
|
147
|
+
if ((0, roblox_bridge_1.isStudioConnected)())
|
|
148
|
+
break;
|
|
149
|
+
}
|
|
145
150
|
if (!(0, roblox_bridge_1.isStudioConnected)()) {
|
|
146
151
|
return {
|
|
147
152
|
content: [
|
package/dist/roblox-bridge.js
CHANGED
|
@@ -3,7 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
3
3
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
-
exports.isBridgeHosting = void 0;
|
|
6
|
+
exports.bridgeReadyPromise = exports.isBridgeHosting = void 0;
|
|
7
7
|
exports.isStudioConnected = isStudioConnected;
|
|
8
8
|
exports.setMcpConnected = setMcpConnected;
|
|
9
9
|
exports.startBridgeServer = startBridgeServer;
|
|
@@ -15,6 +15,10 @@ const child_process_1 = require("child_process");
|
|
|
15
15
|
const task_queue_1 = require("./task-queue");
|
|
16
16
|
const { version: SERVER_VERSION } = require('../package.json');
|
|
17
17
|
exports.isBridgeHosting = false;
|
|
18
|
+
let resolveBridgeReady;
|
|
19
|
+
exports.bridgeReadyPromise = new Promise((resolve) => {
|
|
20
|
+
resolveBridgeReady = resolve;
|
|
21
|
+
});
|
|
18
22
|
// Global process error handlers agar server bridge tidak mati mendadak
|
|
19
23
|
process.on('uncaughtException', (err) => {
|
|
20
24
|
console.error('[Bridge] Uncaught Exception:', err);
|
|
@@ -30,22 +34,57 @@ const sessionStart = new Date().toISOString();
|
|
|
30
34
|
// Per-studio pending events queue: events yang belum diambil oleh Studio
|
|
31
35
|
const studioEventQueues = new Map();
|
|
32
36
|
const studioLastSeen = new Map();
|
|
37
|
+
const activeStudioPollers = new Map();
|
|
33
38
|
setInterval(() => {
|
|
34
39
|
const now = Date.now();
|
|
35
40
|
let changed = false;
|
|
36
41
|
for (const [sid, lastSeen] of studioLastSeen.entries()) {
|
|
37
|
-
if (now - lastSeen >
|
|
42
|
+
if (now - lastSeen > 35000) {
|
|
38
43
|
studioEventQueues.delete(sid);
|
|
39
44
|
studioLastSeen.delete(sid);
|
|
45
|
+
const poller = activeStudioPollers.get(sid);
|
|
46
|
+
if (poller) {
|
|
47
|
+
clearTimeout(poller.timer);
|
|
48
|
+
try {
|
|
49
|
+
poller.res.end();
|
|
50
|
+
}
|
|
51
|
+
catch { }
|
|
52
|
+
activeStudioPollers.delete(sid);
|
|
53
|
+
}
|
|
40
54
|
changed = true;
|
|
41
55
|
}
|
|
42
56
|
}
|
|
43
57
|
if (changed) {
|
|
44
58
|
broadcastStatus();
|
|
45
59
|
}
|
|
46
|
-
},
|
|
60
|
+
}, 5000);
|
|
47
61
|
function isStudioConnected() {
|
|
48
|
-
return studioEventQueues.size > 0;
|
|
62
|
+
return studioEventQueues.size > 0 || activeStudioPollers.size > 0;
|
|
63
|
+
}
|
|
64
|
+
function flushEventsToStudio(sid) {
|
|
65
|
+
const poller = activeStudioPollers.get(sid);
|
|
66
|
+
if (!poller)
|
|
67
|
+
return false;
|
|
68
|
+
const queue = studioEventQueues.get(sid) ?? [];
|
|
69
|
+
if (queue.length === 0)
|
|
70
|
+
return false;
|
|
71
|
+
studioEventQueues.set(sid, []); // kosongkan antrian
|
|
72
|
+
clearTimeout(poller.timer);
|
|
73
|
+
activeStudioPollers.delete(sid);
|
|
74
|
+
const events = [
|
|
75
|
+
{ kind: 'status', mcpConnected, studioCount: Math.max(studioEventQueues.size, 1), serverVersion: SERVER_VERSION },
|
|
76
|
+
...queue
|
|
77
|
+
];
|
|
78
|
+
try {
|
|
79
|
+
poller.res.setHeader('Content-Type', 'text/plain');
|
|
80
|
+
poller.res.setHeader('Cache-Control', 'no-cache, no-store');
|
|
81
|
+
const body = events.map(ev => `data: ${JSON.stringify(ev)}`).join('\n');
|
|
82
|
+
poller.res.send(body);
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
49
88
|
}
|
|
50
89
|
function enqueueForStudio(studioId, event) {
|
|
51
90
|
if (studioId) {
|
|
@@ -56,6 +95,7 @@ function enqueueForStudio(studioId, event) {
|
|
|
56
95
|
q.push(event);
|
|
57
96
|
if (q.length > 100)
|
|
58
97
|
q.shift();
|
|
98
|
+
flushEventsToStudio(studioId);
|
|
59
99
|
}
|
|
60
100
|
else {
|
|
61
101
|
// Broadcast ke semua studio yang terdaftar
|
|
@@ -63,6 +103,7 @@ function enqueueForStudio(studioId, event) {
|
|
|
63
103
|
q.push(event);
|
|
64
104
|
if (q.length > 100)
|
|
65
105
|
q.shift();
|
|
106
|
+
flushEventsToStudio(sid);
|
|
66
107
|
}
|
|
67
108
|
}
|
|
68
109
|
}
|
|
@@ -214,18 +255,18 @@ function startBridgeServer(port, isDaemon = false) {
|
|
|
214
255
|
activeStudios: activeStudios
|
|
215
256
|
});
|
|
216
257
|
});
|
|
217
|
-
// ── /api/stream (
|
|
218
|
-
// Roblox HttpService:GetAsync()
|
|
219
|
-
//
|
|
220
|
-
//
|
|
221
|
-
//
|
|
258
|
+
// ── /api/stream (True Long-Polling endpoint untuk Roblox GetAsync) ─────
|
|
259
|
+
// Roblox HttpService:GetAsync() menahan koneksi sampai server merespons.
|
|
260
|
+
// Jika ada task yang menunggu: kirim seketika (<1ms).
|
|
261
|
+
// Jika tidak ada task: tahan koneksi (hanging GET) hingga task baru tiba
|
|
262
|
+
// atau timeout 15 detik (heartbeat) untuk menjaga koneksi tetap segar.
|
|
222
263
|
app.get('/api/stream', (req, res) => {
|
|
223
264
|
const studioId = req.query.studioId || `anon-${Date.now()}`;
|
|
224
265
|
studioLastSeen.set(studioId, Date.now());
|
|
225
266
|
// Daftarkan studio jika belum ada
|
|
226
267
|
if (!studioEventQueues.has(studioId)) {
|
|
227
268
|
studioEventQueues.set(studioId, []);
|
|
228
|
-
io.emit('studio-status', { connected: true, studioCount: studioEventQueues.size, studioId });
|
|
269
|
+
io.emit('studio-status', { connected: true, studioCount: Math.max(studioEventQueues.size, 1), studioId });
|
|
229
270
|
addLog(io, 'system', `Studio terdaftar. (ID: ${studioId.substring(0, 8)}…, Total: ${studioEventQueues.size})`);
|
|
230
271
|
if (!isDaemon && !dashboardOpened) {
|
|
231
272
|
dashboardOpened = true;
|
|
@@ -237,26 +278,55 @@ function startBridgeServer(port, isDaemon = false) {
|
|
|
237
278
|
});
|
|
238
279
|
}
|
|
239
280
|
}
|
|
281
|
+
// Jika poller lama untuk studio ini masih aktif, selesaikan dulu
|
|
282
|
+
const oldPoller = activeStudioPollers.get(studioId);
|
|
283
|
+
if (oldPoller) {
|
|
284
|
+
clearTimeout(oldPoller.timer);
|
|
285
|
+
try {
|
|
286
|
+
oldPoller.res.end();
|
|
287
|
+
}
|
|
288
|
+
catch { }
|
|
289
|
+
activeStudioPollers.delete(studioId);
|
|
290
|
+
}
|
|
240
291
|
// Kumpulkan semua events yang belum dikirim ke studio ini
|
|
241
292
|
const queue = studioEventQueues.get(studioId) ?? [];
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
293
|
+
if (queue.length > 0) {
|
|
294
|
+
studioEventQueues.set(studioId, []); // kosongkan antrian
|
|
295
|
+
const events = [
|
|
296
|
+
{ kind: 'status', mcpConnected, studioCount: studioEventQueues.size, serverVersion: SERVER_VERSION },
|
|
297
|
+
];
|
|
298
|
+
for (const ev of queue) {
|
|
299
|
+
if (ev.kind !== 'status')
|
|
300
|
+
events.push(ev);
|
|
301
|
+
}
|
|
302
|
+
res.setHeader('Content-Type', 'text/plain');
|
|
303
|
+
res.setHeader('Cache-Control', 'no-cache, no-store');
|
|
304
|
+
const body = events.map(ev => `data: ${JSON.stringify(ev)}`).join('\n');
|
|
305
|
+
return res.send(body);
|
|
252
306
|
}
|
|
253
|
-
//
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
307
|
+
// Jika antrean kosong, TAHAN koneksi (Long-Polling) maksimal 15 detik
|
|
308
|
+
const timer = setTimeout(() => {
|
|
309
|
+
activeStudioPollers.delete(studioId);
|
|
310
|
+
try {
|
|
311
|
+
const events = [
|
|
312
|
+
{ kind: 'heartbeat', timestamp: Date.now() },
|
|
313
|
+
{ kind: 'status', mcpConnected, studioCount: studioEventQueues.size, serverVersion: SERVER_VERSION }
|
|
314
|
+
];
|
|
315
|
+
res.setHeader('Content-Type', 'text/plain');
|
|
316
|
+
res.setHeader('Cache-Control', 'no-cache, no-store');
|
|
317
|
+
const body = events.map(ev => `data: ${JSON.stringify(ev)}`).join('\n');
|
|
318
|
+
res.send(body);
|
|
319
|
+
}
|
|
320
|
+
catch { }
|
|
321
|
+
}, 15000);
|
|
322
|
+
activeStudioPollers.set(studioId, { res, timer });
|
|
323
|
+
req.on('close', () => {
|
|
324
|
+
const current = activeStudioPollers.get(studioId);
|
|
325
|
+
if (current && current.res === res) {
|
|
326
|
+
clearTimeout(current.timer);
|
|
327
|
+
activeStudioPollers.delete(studioId);
|
|
328
|
+
}
|
|
329
|
+
});
|
|
260
330
|
});
|
|
261
331
|
// ── /api/events (SSE persisten untuk browser/klien modern) ───────────────
|
|
262
332
|
app.get('/api/events', (req, res) => {
|
|
@@ -339,9 +409,13 @@ function startBridgeServer(port, isDaemon = false) {
|
|
|
339
409
|
app.post('/api/tasks/enqueue', async (req, res) => {
|
|
340
410
|
const { command, target, data } = req.body;
|
|
341
411
|
// Fast-check: pastikan setidaknya 1 Studio aktif polling
|
|
342
|
-
if (
|
|
343
|
-
|
|
344
|
-
|
|
412
|
+
if (!isStudioConnected()) {
|
|
413
|
+
for (let i = 0; i < 6; i++) {
|
|
414
|
+
await new Promise(r => setTimeout(r, 50));
|
|
415
|
+
if (isStudioConnected())
|
|
416
|
+
break;
|
|
417
|
+
}
|
|
418
|
+
if (!isStudioConnected()) {
|
|
345
419
|
return res.json({
|
|
346
420
|
status: 'error',
|
|
347
421
|
error: 'Roblox Studio tidak terdeteksi aktif. Pastikan Roblox Studio dibuka, place telah dimuat, dan plugin nvstudio-mcp dalam status terhubung.'
|
|
@@ -393,6 +467,7 @@ function startBridgeServer(port, isDaemon = false) {
|
|
|
393
467
|
const server = httpServer.listen(port, () => {
|
|
394
468
|
exports.isBridgeHosting = true;
|
|
395
469
|
console.error(`[Bridge] SSE Bridge Server aktif di http://localhost:${port}`);
|
|
470
|
+
resolveBridgeReady(true);
|
|
396
471
|
});
|
|
397
472
|
server.on('error', async (err) => {
|
|
398
473
|
if (err.code === 'EADDRINUSE') {
|
|
@@ -404,6 +479,7 @@ function startBridgeServer(port, isDaemon = false) {
|
|
|
404
479
|
if (pingRes.ok) {
|
|
405
480
|
exports.isBridgeHosting = false;
|
|
406
481
|
console.error(`[Bridge] Port ${port} sudah digunakan oleh NVStudio Bridge aktif. Menggunakan instance yang sudah berjalan.`);
|
|
482
|
+
resolveBridgeReady(false);
|
|
407
483
|
return;
|
|
408
484
|
}
|
|
409
485
|
}
|
|
@@ -411,14 +487,16 @@ function startBridgeServer(port, isDaemon = false) {
|
|
|
411
487
|
if (retriesLeft > 0) {
|
|
412
488
|
console.error(`[Bridge] Port ${port} zombie. Mencoba membunuh...`);
|
|
413
489
|
killZombieOnPort(port);
|
|
414
|
-
setTimeout(() => tryListen(retriesLeft - 1),
|
|
490
|
+
setTimeout(() => tryListen(retriesLeft - 1), 500);
|
|
415
491
|
}
|
|
416
492
|
else {
|
|
417
493
|
console.error(`[Bridge] FATAL: Port ${port} tidak dapat dibuka.`);
|
|
494
|
+
resolveBridgeReady(false);
|
|
418
495
|
}
|
|
419
496
|
}
|
|
420
497
|
else {
|
|
421
498
|
console.error(`[Bridge] FATAL: Gagal mendengarkan port ${port}:`, err.message);
|
|
499
|
+
resolveBridgeReady(false);
|
|
422
500
|
process.exit(1);
|
|
423
501
|
}
|
|
424
502
|
});
|
package/package.json
CHANGED
|
@@ -335,7 +335,7 @@ function startConnectionLoop()
|
|
|
335
335
|
handleSSELine(line)
|
|
336
336
|
end
|
|
337
337
|
end
|
|
338
|
-
task.wait(0.
|
|
338
|
+
task.wait(0.05) -- Instant loop: server menahan request (hanging GET) saat idle
|
|
339
339
|
else
|
|
340
340
|
consecutiveErrors = consecutiveErrors + 1
|
|
341
341
|
-- Toleransi 2x gagal sebelum menandai server unreachable
|
|
@@ -824,7 +824,7 @@ function startConnectionLoop()
|
|
|
824
824
|
handleSSELine(line)
|
|
825
825
|
end
|
|
826
826
|
end
|
|
827
|
-
task.wait(0.
|
|
827
|
+
task.wait(0.05) -- Instant loop: server menahan request (hanging GET) saat idle
|
|
828
828
|
else
|
|
829
829
|
consecutiveErrors = consecutiveErrors + 1
|
|
830
830
|
-- Toleransi 2x gagal sebelum menandai server unreachable
|
|
@@ -1088,7 +1088,8 @@ end
|
|
|
1088
1088
|
if targetInstance and targetInstance ~= game then
|
|
1089
1089
|
local descendants = {}
|
|
1090
1090
|
pcall(function() descendants = targetInstance:GetDescendants() end)
|
|
1091
|
-
for
|
|
1091
|
+
for i, desc in ipairs(descendants) do
|
|
1092
|
+
if i % 300 == 0 then task.wait() end
|
|
1092
1093
|
if matchCount >= MAX_MATCHES then break end
|
|
1093
1094
|
if desc:IsA("LuaSourceContainer") then
|
|
1094
1095
|
if searchInScript(desc) then
|
|
@@ -1100,7 +1101,8 @@ end
|
|
|
1100
1101
|
for _, service in ipairs(searchableServices) do
|
|
1101
1102
|
local descendants = {}
|
|
1102
1103
|
pcall(function() descendants = service:GetDescendants() end)
|
|
1103
|
-
for
|
|
1104
|
+
for i, desc in ipairs(descendants) do
|
|
1105
|
+
if i % 300 == 0 then task.wait() end
|
|
1104
1106
|
if matchCount >= MAX_MATCHES then break end
|
|
1105
1107
|
if desc:IsA("LuaSourceContainer") then
|
|
1106
1108
|
if searchInScript(desc) then
|
|
@@ -48,7 +48,8 @@ return function(targetInstance, data, ctx, targetPath)
|
|
|
48
48
|
if targetInstance and targetInstance ~= game then
|
|
49
49
|
local descendants = {}
|
|
50
50
|
pcall(function() descendants = targetInstance:GetDescendants() end)
|
|
51
|
-
for
|
|
51
|
+
for i, desc in ipairs(descendants) do
|
|
52
|
+
if i % 300 == 0 then task.wait() end
|
|
52
53
|
if matchCount >= MAX_MATCHES then break end
|
|
53
54
|
if desc:IsA("LuaSourceContainer") then
|
|
54
55
|
if searchInScript(desc) then
|
|
@@ -60,7 +61,8 @@ return function(targetInstance, data, ctx, targetPath)
|
|
|
60
61
|
for _, service in ipairs(searchableServices) do
|
|
61
62
|
local descendants = {}
|
|
62
63
|
pcall(function() descendants = service:GetDescendants() end)
|
|
63
|
-
for
|
|
64
|
+
for i, desc in ipairs(descendants) do
|
|
65
|
+
if i % 300 == 0 then task.wait() end
|
|
64
66
|
if matchCount >= MAX_MATCHES then break end
|
|
65
67
|
if desc:IsA("LuaSourceContainer") then
|
|
66
68
|
if searchInScript(desc) then
|