@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
- await new Promise(r => setTimeout(r, 2000));
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: [
@@ -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 > 6000) {
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
- }, 3000);
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 (Snapshot endpoint untuk Roblox GetAsync) ───────────────
218
- // Roblox HttpService:GetAsync() TIDAK bisa streaming — ia block sampai
219
- // koneksi ditutup. Endpoint ini: daftarkan studio, kirim semua events
220
- // yang tertunda (status + queued tasks), lalu TUTUP koneksi.
221
- // Plugin mengulang GET ini setiap ~0.5 detik (pseudo-polling via snapshot).
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
- studioEventQueues.set(studioId, []); // kosongkan antrian
243
- // Selalu sertakan status terkini
244
- const events = [
245
- { kind: 'status', mcpConnected, studioCount: studioEventQueues.size, serverVersion: SERVER_VERSION },
246
- ];
247
- // Tambahkan events tertunda (misalnya task requests)
248
- for (const ev of queue) {
249
- // Hindari duplikat status
250
- if (ev.kind !== 'status')
251
- events.push(ev);
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
- // Format sebagai teks SSE (setiap event = satu baris "data: ...\n\n")
254
- res.setHeader('Content-Type', 'text/plain');
255
- res.setHeader('Cache-Control', 'no-cache, no-store');
256
- const body = events
257
- .map(ev => `data: ${JSON.stringify(ev)}`)
258
- .join('\n');
259
- res.send(body);
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 (studioEventQueues.size === 0) {
343
- await new Promise(r => setTimeout(r, 2000));
344
- if (studioEventQueues.size === 0) {
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), 1500);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adiholick/nvstudio-mcp",
3
- "version": "2.2.1",
3
+ "version": "2.2.2",
4
4
  "description": "Local Bridge Server MCP for Roblox Studio",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -335,7 +335,7 @@ function startConnectionLoop()
335
335
  handleSSELine(line)
336
336
  end
337
337
  end
338
- task.wait(0.8)
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.8)
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 _, desc in ipairs(descendants) do
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 _, desc in ipairs(descendants) do
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 _, desc in ipairs(descendants) do
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 _, desc in ipairs(descendants) do
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