@adiholick/nvstudio-mcp 2.2.1 → 2.2.3

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.
@@ -3,8 +3,12 @@ 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
+ exports.removePendingStreamTask = removePendingStreamTask;
8
+ exports.checkPendingTasksOnStudioDisconnect = checkPendingTasksOnStudioDisconnect;
7
9
  exports.isStudioConnected = isStudioConnected;
10
+ exports.getConnectedStudios = getConnectedStudios;
11
+ exports.getConnectedStudioCount = getConnectedStudioCount;
8
12
  exports.setMcpConnected = setMcpConnected;
9
13
  exports.startBridgeServer = startBridgeServer;
10
14
  const express_1 = __importDefault(require("express"));
@@ -13,8 +17,13 @@ const socket_io_1 = require("socket.io");
13
17
  const path_1 = __importDefault(require("path"));
14
18
  const child_process_1 = require("child_process");
15
19
  const task_queue_1 = require("./task-queue");
20
+ const ws_gateway_1 = require("./ws-gateway");
16
21
  const { version: SERVER_VERSION } = require('../package.json');
17
22
  exports.isBridgeHosting = false;
23
+ let resolveBridgeReady;
24
+ exports.bridgeReadyPromise = new Promise((resolve) => {
25
+ resolveBridgeReady = resolve;
26
+ });
18
27
  // Global process error handlers agar server bridge tidak mati mendadak
19
28
  process.on('uncaughtException', (err) => {
20
29
  console.error('[Bridge] Uncaught Exception:', err);
@@ -30,22 +39,161 @@ const sessionStart = new Date().toISOString();
30
39
  // Per-studio pending events queue: events yang belum diambil oleh Studio
31
40
  const studioEventQueues = new Map();
32
41
  const studioLastSeen = new Map();
42
+ const studioPlaces = new Map();
43
+ const activeStudioPollers = new Map();
44
+ // Antrean global untuk task request yang menunggu poller long-polling (/api/stream)
45
+ const pendingStreamTasks = [];
46
+ function removePendingStreamTask(taskId) {
47
+ const idx = pendingStreamTasks.findIndex(t => t.requestId === taskId || t.taskId === taskId);
48
+ if (idx !== -1) {
49
+ pendingStreamTasks.splice(idx, 1);
50
+ }
51
+ }
52
+ let disconnectCheckTimer = null;
53
+ function checkPendingTasksOnStudioDisconnect() {
54
+ if (disconnectCheckTimer)
55
+ clearTimeout(disconnectCheckTimer);
56
+ disconnectCheckTimer = setTimeout(() => {
57
+ disconnectCheckTimer = null;
58
+ if (!isStudioConnected() && (0, task_queue_1.hasPendingTasks)()) {
59
+ pendingStreamTasks.length = 0;
60
+ (0, task_queue_1.rejectAllPendingTasks)('Roblox Studio terputus sebelum merespons tugas.');
61
+ }
62
+ }, 2000);
63
+ }
33
64
  setInterval(() => {
34
65
  const now = Date.now();
35
66
  let changed = false;
36
67
  for (const [sid, lastSeen] of studioLastSeen.entries()) {
37
- if (now - lastSeen > 6000) {
68
+ const isPollerActive = activeStudioPollers.has(sid);
69
+ if (!isPollerActive && now - lastSeen > 10000) {
70
+ studioEventQueues.delete(sid);
71
+ studioLastSeen.delete(sid);
72
+ studioPlaces.delete(sid);
73
+ changed = true;
74
+ }
75
+ else if (now - lastSeen > 25000) {
38
76
  studioEventQueues.delete(sid);
39
77
  studioLastSeen.delete(sid);
78
+ studioPlaces.delete(sid);
79
+ const poller = activeStudioPollers.get(sid);
80
+ if (poller) {
81
+ clearTimeout(poller.timer);
82
+ try {
83
+ poller.res.end();
84
+ }
85
+ catch { }
86
+ activeStudioPollers.delete(sid);
87
+ }
40
88
  changed = true;
41
89
  }
42
90
  }
43
91
  if (changed) {
44
92
  broadcastStatus();
93
+ if (!isStudioConnected() && (0, task_queue_1.hasPendingTasks)()) {
94
+ checkPendingTasksOnStudioDisconnect();
95
+ }
96
+ }
97
+ }, 2000);
98
+ function isStudioConnected(filter) {
99
+ if (filter?.studioId) {
100
+ if ((0, ws_gateway_1.hasActiveWsPeer)({ studioId: filter.studioId }))
101
+ return true;
102
+ if (activeStudioPollers.has(filter.studioId))
103
+ return true;
104
+ const seen = studioLastSeen.get(filter.studioId);
105
+ if (seen && Date.now() - seen <= 5000)
106
+ return true;
107
+ return false;
108
+ }
109
+ if (filter?.placeId !== undefined && filter?.placeId !== null) {
110
+ if ((0, ws_gateway_1.hasActiveWsPeer)({ placeId: filter.placeId }))
111
+ return true;
112
+ for (const [sid, pid] of studioPlaces.entries()) {
113
+ if (pid === filter.placeId) {
114
+ if (activeStudioPollers.has(sid))
115
+ return true;
116
+ const seen = studioLastSeen.get(sid);
117
+ if (seen && Date.now() - seen <= 5000)
118
+ return true;
119
+ }
120
+ }
121
+ return false;
122
+ }
123
+ if ((0, ws_gateway_1.hasActiveWsPeer)())
124
+ return true;
125
+ if (activeStudioPollers.size > 0)
126
+ return true;
127
+ const now = Date.now();
128
+ for (const lastSeen of studioLastSeen.values()) {
129
+ if (now - lastSeen <= 3000)
130
+ return true;
131
+ }
132
+ return false;
133
+ }
134
+ function getConnectedStudios() {
135
+ const list = [];
136
+ const wsDetails = (0, ws_gateway_1.getWsPeersDetails)();
137
+ const wsStudioIds = new Set();
138
+ for (const p of wsDetails) {
139
+ wsStudioIds.add(p.studioId);
140
+ list.push({
141
+ studioId: p.studioId,
142
+ placeId: p.placeId,
143
+ transport: 'websocket',
144
+ lastSeen: p.lastSeen,
145
+ isAlive: p.isAlive && !p.unresponsive,
146
+ isBusy: p.isBusy,
147
+ });
148
+ }
149
+ const now = Date.now();
150
+ for (const [sid, lastSeen] of studioLastSeen.entries()) {
151
+ if (!wsStudioIds.has(sid) && (activeStudioPollers.has(sid) || now - lastSeen <= 5000)) {
152
+ list.push({
153
+ studioId: sid,
154
+ placeId: studioPlaces.get(sid),
155
+ transport: 'long-polling',
156
+ lastSeen,
157
+ isAlive: true,
158
+ isBusy: false,
159
+ });
160
+ }
161
+ }
162
+ return list;
163
+ }
164
+ function getConnectedStudioCount() {
165
+ const ids = new Set();
166
+ for (const sid of studioEventQueues.keys())
167
+ ids.add(sid);
168
+ for (const sid of (0, ws_gateway_1.getWsStudioIds)())
169
+ ids.add(sid);
170
+ return Math.max(ids.size, isStudioConnected() ? 1 : 0);
171
+ }
172
+ function flushEventsToStudio(sid) {
173
+ const poller = activeStudioPollers.get(sid);
174
+ if (!poller)
175
+ return false;
176
+ const queue = studioEventQueues.get(sid) ?? [];
177
+ if (queue.length === 0)
178
+ return false;
179
+ studioEventQueues.set(sid, []); // kosongkan antrian
180
+ clearTimeout(poller.timer);
181
+ activeStudioPollers.delete(sid);
182
+ const nonStatusEvents = queue.filter(ev => ev.kind !== 'status');
183
+ const events = [
184
+ { kind: 'status', mcpConnected, studioCount: Math.max(studioEventQueues.size, 1), serverVersion: SERVER_VERSION },
185
+ ...nonStatusEvents
186
+ ];
187
+ try {
188
+ poller.res.setHeader('Content-Type', 'text/plain');
189
+ poller.res.setHeader('Cache-Control', 'no-cache, no-store');
190
+ const body = events.map(ev => `data: ${JSON.stringify(ev)}`).join('\n');
191
+ poller.res.send(body);
192
+ return true;
193
+ }
194
+ catch {
195
+ return false;
45
196
  }
46
- }, 3000);
47
- function isStudioConnected() {
48
- return studioEventQueues.size > 0;
49
197
  }
50
198
  function enqueueForStudio(studioId, event) {
51
199
  if (studioId) {
@@ -56,13 +204,15 @@ function enqueueForStudio(studioId, event) {
56
204
  q.push(event);
57
205
  if (q.length > 100)
58
206
  q.shift();
207
+ flushEventsToStudio(studioId);
59
208
  }
60
209
  else {
61
- // Broadcast ke semua studio yang terdaftar
210
+ // Broadcast non-task (status, heartbeat) ke semua studio yang terdaftar
62
211
  for (const [sid, q] of studioEventQueues.entries()) {
63
212
  q.push(event);
64
213
  if (q.length > 100)
65
214
  q.shift();
215
+ flushEventsToStudio(sid);
66
216
  }
67
217
  }
68
218
  }
@@ -99,26 +249,91 @@ function sendEvent(peer, kind, payload) {
99
249
  * Push status koneksi ke semua peer (apakah mcpConnected, jumlah peer aktif dll).
100
250
  */
101
251
  function broadcastStatus() {
252
+ const studioCount = getConnectedStudioCount();
102
253
  const statusEvent = {
103
254
  mcpConnected,
104
- studioCount: studioEventQueues.size,
255
+ studioCount,
105
256
  serverVersion: SERVER_VERSION,
106
257
  };
107
258
  broadcastEvent('status', statusEvent);
259
+ (0, ws_gateway_1.broadcastWsEvent)('status', statusEvent);
108
260
  }
109
261
  /**
110
- * Push task ke semua Studio peer (broadcast ke semua Studio yang terbuka).
111
- * Hanya satu yang akan memprosesnya (first-come first-served via requestId).
262
+ * Push task ke Studio peer (dengan routing studioId/placeId).
263
+ * Mengembalikan true jika berhasil dikirim langsung ke poller aktif, false jika diantrekan (Bug F).
112
264
  */
113
265
  function broadcastTask(task) {
266
+ const totalBudget = task.remainingMs ?? 30000;
267
+ const elapsed = task.createdAt ? (Date.now() - task.createdAt) : 0;
268
+ const remainingMs = Math.max(1000, totalBudget - elapsed);
269
+ // Bug A & E: Stamp attempt counter dan channel pengiriman
270
+ const attempt = (task.attempt || 0) + 1;
271
+ task.attempt = attempt;
272
+ task.dispatchedAt = Date.now();
273
+ task.deliveredVia = 'stream';
114
274
  const taskEvent = {
275
+ kind: 'request',
115
276
  requestId: task.id,
277
+ taskId: task.id,
278
+ attempt,
116
279
  command: task.command,
117
280
  target: task.target,
118
281
  data: task.data ?? null,
119
- remainingMs: 29000,
282
+ studioId: task.studioId,
283
+ placeId: task.placeId,
284
+ remainingMs,
120
285
  };
121
- enqueueForStudio(null, { kind: 'request', ...taskEvent });
286
+ // 1. Coba kirim langsung ke poller aktif yang cocok (hanging GET)
287
+ let matchingSid = null;
288
+ if (task.studioId && activeStudioPollers.has(task.studioId)) {
289
+ matchingSid = task.studioId;
290
+ }
291
+ else if (task.placeId !== undefined && task.placeId !== null) {
292
+ for (const sid of activeStudioPollers.keys()) {
293
+ if (studioPlaces.get(sid) === task.placeId) {
294
+ matchingSid = sid;
295
+ break;
296
+ }
297
+ }
298
+ }
299
+ else if (!task.studioId && task.placeId === undefined && activeStudioPollers.size > 0) {
300
+ let bestSid = null;
301
+ let latestTime = -1;
302
+ for (const sid of activeStudioPollers.keys()) {
303
+ const seen = studioLastSeen.get(sid) ?? 0;
304
+ if (seen > latestTime) {
305
+ latestTime = seen;
306
+ bestSid = sid;
307
+ }
308
+ }
309
+ matchingSid = bestSid || activeStudioPollers.keys().next().value;
310
+ }
311
+ if (matchingSid && activeStudioPollers.has(matchingSid)) {
312
+ const poller = activeStudioPollers.get(matchingSid);
313
+ clearTimeout(poller.timer);
314
+ activeStudioPollers.delete(matchingSid);
315
+ studioLastSeen.set(matchingSid, Date.now());
316
+ const events = [
317
+ { kind: 'status', mcpConnected, studioCount: Math.max(studioEventQueues.size, 1), serverVersion: SERVER_VERSION },
318
+ taskEvent
319
+ ];
320
+ try {
321
+ poller.res.setHeader('Content-Type', 'text/plain');
322
+ poller.res.setHeader('Cache-Control', 'no-cache, no-store');
323
+ const body = events.map(ev => `data: ${JSON.stringify(ev)}`).join('\n');
324
+ poller.res.send(body);
325
+ return true;
326
+ }
327
+ catch {
328
+ // Jika pengiriman langsung gagal, masukkan ke antrean pending
329
+ }
330
+ }
331
+ // 2. Jika tidak ada poller yang sedang hanging (jeda antar-request), masukkan ke antrean
332
+ pendingStreamTasks.push(taskEvent);
333
+ if (pendingStreamTasks.length > 50) {
334
+ pendingStreamTasks.shift();
335
+ }
336
+ return false;
122
337
  }
123
338
  // ── Logging ───────────────────────────────────────────────────────────────────
124
339
  let logCounter = 0;
@@ -132,8 +347,10 @@ function addLog(io, type, message) {
132
347
  activityLogs.unshift(log);
133
348
  if (activityLogs.length > 200)
134
349
  activityLogs.pop();
135
- io.emit('log', log);
136
- io.emit('stats', { totalTasks, successCount, errorCount, sessionStart });
350
+ if (io.engine && io.engine.clientsCount > 0) {
351
+ io.emit('log', log);
352
+ io.emit('stats', { totalTasks, successCount, errorCount, sessionStart });
353
+ }
137
354
  }
138
355
  /**
139
356
  * Membunuh proses zombie yang masih menyandera port tertentu.
@@ -181,6 +398,57 @@ function startBridgeServer(port, isDaemon = false) {
181
398
  const app = (0, express_1.default)();
182
399
  const httpServer = (0, http_1.createServer)(app);
183
400
  const io = new socket_io_1.Server(httpServer, { cors: { origin: '*' } });
401
+ function handleTaskResponse(taskId, status, result, error) {
402
+ if (status === 'success') {
403
+ successCount++;
404
+ addLog(io, 'response', `Perintah selesai: ${String(result ?? '').substring(0, 120)}`);
405
+ }
406
+ else {
407
+ errorCount++;
408
+ addLog(io, 'error', `Gagal: ${error}`);
409
+ }
410
+ removePendingStreamTask(taskId);
411
+ (0, ws_gateway_1.cancelTaskDeadline)(taskId);
412
+ (0, task_queue_1.resolvePendingTask)(taskId, status, result, error);
413
+ }
414
+ // Pasang WebSocket Gateway untuk duplex communication instan (/ws)
415
+ (0, ws_gateway_1.attachWsGateway)(httpServer, {
416
+ onTaskResponse: (taskId, status, result, error) => {
417
+ handleTaskResponse(taskId, status, result, error);
418
+ },
419
+ onLog: (type, message) => {
420
+ addLog(io, type, message);
421
+ },
422
+ onStudioStatusChange: () => {
423
+ const connected = isStudioConnected();
424
+ const studioCount = getConnectedStudioCount();
425
+ io.emit('studio-status', { connected, studioCount });
426
+ broadcastStatus();
427
+ if (!connected && (0, task_queue_1.hasPendingTasks)()) {
428
+ checkPendingTasksOnStudioDisconnect();
429
+ }
430
+ },
431
+ getServerVersion: () => SERVER_VERSION,
432
+ isMcpConnected: () => mcpConnected,
433
+ onRedispatchTask: (task, failedStudioId) => {
434
+ // Coba re-dispatch ke peer WS lain jika ada yang cocok
435
+ const nextPeer = (0, ws_gateway_1.selectWsPeer)({
436
+ placeId: task.placeId,
437
+ });
438
+ if (nextPeer && nextPeer.studioId !== failedStudioId) {
439
+ const ok = (0, ws_gateway_1.sendTaskViaWs)(task, nextPeer.studioId);
440
+ if (ok)
441
+ return true;
442
+ }
443
+ // Fallback: hanya ke long-polling jika ada poller yang sedang hanging aktif (Bug F)
444
+ if (activeStudioPollers.size > 0) {
445
+ const delivered = broadcastTask(task);
446
+ if (delivered)
447
+ return true;
448
+ }
449
+ return false;
450
+ },
451
+ });
184
452
  app.use(express_1.default.json({ limit: '50mb' }));
185
453
  app.use(express_1.default.static(path_1.default.join(__dirname, '../public')));
186
454
  let dashboardOpened = false;
@@ -188,45 +456,66 @@ function startBridgeServer(port, isDaemon = false) {
188
456
  io.on('connection', (socket) => {
189
457
  socket.emit('init', {
190
458
  logs: activityLogs,
191
- stats: { totalTasks, successCount, errorCount, sessionStart, studioConnected: studioEventQueues.size > 0, port },
459
+ stats: {
460
+ totalTasks,
461
+ successCount,
462
+ errorCount,
463
+ sessionStart,
464
+ studioConnected: isStudioConnected(),
465
+ studioCount: getConnectedStudioCount(),
466
+ wsStudios: (0, ws_gateway_1.getWsPeerCount)(),
467
+ port
468
+ },
192
469
  });
193
470
  });
194
471
  // ── /api/stats ─────────────────────────────────────────────────────────────
195
472
  app.get('/api/stats', (req, res) => {
196
- const activeStudios = studioEventQueues.size;
473
+ const activeStudios = getConnectedStudioCount();
474
+ const wsStudios = (0, ws_gateway_1.getWsPeerCount)();
197
475
  res.json({
198
476
  totalTasks, successCount, errorCount, sessionStart,
199
- studioConnected: activeStudios > 0,
477
+ studioConnected: isStudioConnected(),
200
478
  studioCount: activeStudios,
479
+ wsConnectedCount: wsStudios,
480
+ transport: wsStudios > 0 ? 'websocket' : (activeStudioPollers.size > 0 ? 'long-polling' : 'none'),
201
481
  mcpConnected,
202
482
  port,
483
+ connectedStudios: getConnectedStudios(),
203
484
  });
204
485
  });
205
486
  // ── /api/ping ─────────────────────────────────────────────────────────────
206
487
  app.get('/api/ping', (req, res) => {
207
488
  const uptime = Math.floor((Date.now() - new Date(sessionStart).getTime()) / 1000);
208
- const activeStudios = studioEventQueues.size;
489
+ const activeStudios = getConnectedStudioCount();
490
+ const wsStudios = (0, ws_gateway_1.getWsPeerCount)();
209
491
  res.json({
210
492
  status: 'ok',
211
493
  version: SERVER_VERSION,
212
494
  uptimeSeconds: uptime,
213
495
  totalTasksProcessed: totalTasks,
214
- activeStudios: activeStudios
496
+ activeStudios: activeStudios,
497
+ wsStudios: wsStudios,
498
+ dualMode: true,
215
499
  });
216
500
  });
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).
501
+ // ── /api/stream (True Long-Polling endpoint untuk Roblox GetAsync) ─────
502
+ // Roblox HttpService:GetAsync() menahan koneksi sampai server merespons.
503
+ // Jika ada task yang menunggu: kirim seketika (<1ms).
504
+ // Jika tidak ada task: tahan koneksi (hanging GET) hingga task baru tiba
505
+ // atau timeout 15 detik (heartbeat) untuk menjaga koneksi tetap segar.
222
506
  app.get('/api/stream', (req, res) => {
223
507
  const studioId = req.query.studioId || `anon-${Date.now()}`;
508
+ const placeIdStr = req.query.placeId;
509
+ const placeId = placeIdStr !== undefined && placeIdStr !== '' ? parseInt(placeIdStr, 10) : undefined;
510
+ if (placeId !== undefined && !isNaN(placeId)) {
511
+ studioPlaces.set(studioId, placeId);
512
+ }
224
513
  studioLastSeen.set(studioId, Date.now());
225
514
  // Daftarkan studio jika belum ada
226
515
  if (!studioEventQueues.has(studioId)) {
227
516
  studioEventQueues.set(studioId, []);
228
- io.emit('studio-status', { connected: true, studioCount: studioEventQueues.size, studioId });
229
- addLog(io, 'system', `Studio terdaftar. (ID: ${studioId.substring(0, 8)}…, Total: ${studioEventQueues.size})`);
517
+ io.emit('studio-status', { connected: true, studioCount: Math.max(studioEventQueues.size, 1), studioId });
518
+ addLog(io, 'system', `Studio terdaftar. (ID: ${studioId.substring(0, 8)}…, Place: ${placeId ?? 'Local'}, Total: ${studioEventQueues.size})`);
230
519
  if (!isDaemon && !dashboardOpened) {
231
520
  dashboardOpened = true;
232
521
  const url = `http://localhost:${port}`;
@@ -237,26 +526,80 @@ function startBridgeServer(port, isDaemon = false) {
237
526
  });
238
527
  }
239
528
  }
240
- // Kumpulkan semua events yang belum dikirim ke studio ini
529
+ // Jika poller lama untuk studio ini masih aktif, selesaikan dulu
530
+ const oldPoller = activeStudioPollers.get(studioId);
531
+ if (oldPoller) {
532
+ clearTimeout(oldPoller.timer);
533
+ try {
534
+ oldPoller.res.end();
535
+ }
536
+ catch { }
537
+ activeStudioPollers.delete(studioId);
538
+ }
539
+ // 1. Prioritas Utama: Cek apakah ada task AI yang cocok sedang menunggu poller Studio ini (Bug #3 Routing)
540
+ const matchIdx = pendingStreamTasks.findIndex((t) => {
541
+ if (t.studioId && t.studioId !== studioId)
542
+ return false;
543
+ if (t.placeId !== undefined && t.placeId !== null && placeId !== undefined && t.placeId !== placeId)
544
+ return false;
545
+ return true;
546
+ });
547
+ if (matchIdx !== -1) {
548
+ const taskEvent = pendingStreamTasks.splice(matchIdx, 1)[0];
549
+ const events = [
550
+ { kind: 'status', mcpConnected, studioCount: Math.max(studioEventQueues.size, 1), serverVersion: SERVER_VERSION },
551
+ taskEvent
552
+ ];
553
+ res.setHeader('Content-Type', 'text/plain');
554
+ res.setHeader('Cache-Control', 'no-cache, no-store');
555
+ const body = events.map(ev => `data: ${JSON.stringify(ev)}`).join('\n');
556
+ return res.send(body);
557
+ }
558
+ // 2. Kumpulkan semua events non-task yang belum dikirim ke studio ini
241
559
  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);
560
+ if (queue.length > 0) {
561
+ studioEventQueues.set(studioId, []); // kosongkan antrian
562
+ const events = [
563
+ { kind: 'status', mcpConnected, studioCount: Math.max(studioEventQueues.size, 1), serverVersion: SERVER_VERSION },
564
+ ...queue.filter(ev => ev.kind !== 'status')
565
+ ];
566
+ res.setHeader('Content-Type', 'text/plain');
567
+ res.setHeader('Cache-Control', 'no-cache, no-store');
568
+ const body = events.map(ev => `data: ${JSON.stringify(ev)}`).join('\n');
569
+ return res.send(body);
252
570
  }
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);
571
+ // 3. Jika antrean kosong, TAHAN koneksi (Long-Polling) maksimal 15 detik
572
+ const timer = setTimeout(() => {
573
+ activeStudioPollers.delete(studioId);
574
+ try {
575
+ const events = [
576
+ { kind: 'heartbeat', timestamp: Date.now() },
577
+ { kind: 'status', mcpConnected, studioCount: Math.max(studioEventQueues.size, 1), serverVersion: SERVER_VERSION }
578
+ ];
579
+ res.setHeader('Content-Type', 'text/plain');
580
+ res.setHeader('Cache-Control', 'no-cache, no-store');
581
+ const body = events.map(ev => `data: ${JSON.stringify(ev)}`).join('\n');
582
+ res.send(body);
583
+ }
584
+ catch { }
585
+ }, 15000);
586
+ activeStudioPollers.set(studioId, { res, timer });
587
+ res.on('close', () => {
588
+ const current = activeStudioPollers.get(studioId);
589
+ if (current && current.res === res) {
590
+ clearTimeout(current.timer);
591
+ activeStudioPollers.delete(studioId);
592
+ }
593
+ if (!res.writableEnded) {
594
+ // Client menutup koneksi HTTP sebelum respons dikirim (Studio ditutup/crash)
595
+ studioLastSeen.delete(studioId);
596
+ studioEventQueues.delete(studioId);
597
+ broadcastStatus();
598
+ if ((0, task_queue_1.hasPendingTasks)()) {
599
+ checkPendingTasksOnStudioDisconnect();
600
+ }
601
+ }
602
+ });
260
603
  });
261
604
  // ── /api/events (SSE persisten untuk browser/klien modern) ───────────────
262
605
  app.get('/api/events', (req, res) => {
@@ -295,94 +638,103 @@ function startBridgeServer(port, isDaemon = false) {
295
638
  }
296
639
  catch { /* ignore */ }
297
640
  }
298
- req.on('close', cleanup);
299
- req.on('error', cleanup);
641
+ res.on('close', cleanup);
642
+ res.on('error', cleanup);
300
643
  });
301
- // ── /api/response/:taskId (Studio POSTs hasil eksekusi) ───────────────────
644
+ // ── /api/response/:taskId (Studio POSTs hasil eksekusi) (Bug H) ─────────
302
645
  app.post('/api/response/:taskId', (req, res) => {
303
- const { taskId } = req.params;
646
+ const taskId = req.params.taskId || req.body?.taskId || req.body?.id;
304
647
  const { status, result, error } = req.body;
305
- if (status === 'success') {
306
- successCount++;
307
- addLog(io, 'response', `Perintah selesai: ${String(result ?? '').substring(0, 120)}`);
308
- }
309
- else {
310
- errorCount++;
311
- addLog(io, 'error', `Gagal: ${error}`);
312
- }
313
- (0, task_queue_1.resolvePendingTask)(taskId, status, result, error);
648
+ handleTaskResponse(taskId, status, result, error);
314
649
  res.json({ ok: true });
315
650
  });
316
- // ── /api/response (legacy — lama, tetap didukung) ────────────────────────
651
+ // ── /api/response (legacy & fallback) (Bug H) ───────────────────────────
317
652
  app.post('/api/response', (req, res) => {
318
- const { id, status, result, error } = req.body;
319
- if (status === 'success') {
320
- successCount++;
321
- addLog(io, 'response', `Perintah selesai: ${String(result ?? '').substring(0, 120)}`);
322
- }
323
- else {
324
- errorCount++;
325
- addLog(io, 'error', `Gagal: ${error}`);
326
- }
327
- (0, task_queue_1.resolvePendingTask)(id, status, result, error);
653
+ const taskId = req.body?.taskId || req.body?.id || req.query?.taskId;
654
+ const { status, result, error } = req.body;
655
+ handleTaskResponse(taskId, status, result, error);
328
656
  res.json({ ok: true });
329
657
  });
330
- // ── Notifikasi task baru ke Dashboard ────────────────────────────────────
331
- // Pengiriman task ke Studio dilakukan via /api/stream (snapshot polling).
332
- // Di sini kita hanya log ke dashboard browser.
658
+ // ── Notifikasi task baru ke Dashboard & Dispatch ke Studio ────────────────
333
659
  task_queue_1.taskEmitter.on('new_task', (task) => {
334
660
  totalTasks++;
335
661
  addLog(io, 'task', `Task dikirim ke Studio: '${task.command}'${task.target ? ` → ${task.target}` : ''}`);
662
+ // Prioritas 1: Push via WebSocket instan duplex jika Studio aktif terhubung via WS
663
+ const sentWs = (0, ws_gateway_1.sendTaskViaWs)(task);
664
+ if (sentWs) {
665
+ task.deliveredVia = 'ws';
666
+ return; // Berhasil terkirim instan via WebSocket! Tidak perlu enqueue ke long-polling.
667
+ }
668
+ // Prioritas 2 (Fallback): Enqueue ke Long-Polling /api/stream
336
669
  broadcastTask(task);
337
670
  });
338
671
  // ── /api/tasks/enqueue (dari proses MCP eksternal / bridge forwarding) ────
339
672
  app.post('/api/tasks/enqueue', async (req, res) => {
340
- const { command, target, data } = req.body;
341
- // 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) {
673
+ const { command, target, data, placeId, studioId, remainingMs } = req.body;
674
+ // Fast-check: pastikan Studio yang cocok aktif polling / websocket (Bug #3)
675
+ if (!isStudioConnected({ placeId, studioId })) {
676
+ for (let i = 0; i < 10; i++) {
677
+ await new Promise(r => setTimeout(r, 50));
678
+ if (isStudioConnected({ placeId, studioId }))
679
+ break;
680
+ }
681
+ if (!isStudioConnected({ placeId, studioId })) {
345
682
  return res.json({
346
683
  status: 'error',
347
- error: 'Roblox Studio tidak terdeteksi aktif. Pastikan Roblox Studio dibuka, place telah dimuat, dan plugin nvstudio-mcp dalam status terhubung.'
684
+ error: placeId !== undefined
685
+ ? `Roblox Studio dengan Place ID ${placeId} tidak terdeteksi aktif.`
686
+ : 'Roblox Studio tidak terdeteksi aktif. Pastikan Roblox Studio dibuka, place telah dimuat, dan plugin nvstudio-mcp dalam status terhubung.'
348
687
  });
349
688
  }
350
689
  }
351
690
  try {
352
- const result = await (0, task_queue_1.addTaskToQueue)(command, target, data);
691
+ const result = await (0, task_queue_1.addTaskToQueue)(command, target, data, { placeId, studioId, remainingMs });
353
692
  res.json({ status: 'success', result });
354
693
  }
355
694
  catch (err) {
356
- res.json({ status: 'error', error: err.message });
695
+ res.json({ status: 'error', error: err?.message || String(err) });
357
696
  }
358
697
  });
359
698
  // ── /api/tasks (legacy long-poll — dipertahankan untuk backward-compat) ───
360
699
  // Studio lama yang belum update plugin masih bisa bekerja via polling.
700
+ // Menggunakan claimTask secara atomik untuk mencegah perebutan task (Bug D & E).
361
701
  const waitingPollers = [];
362
702
  task_queue_1.taskEmitter.on('new_task', (task) => {
703
+ if (task.deliveredVia)
704
+ return; // Abaikan jika task sudah terkirim via WS atau Stream
363
705
  if (waitingPollers.length > 0) {
706
+ const claimed = (0, task_queue_1.claimTask)(task.id, 'legacy');
707
+ if (!claimed)
708
+ return;
364
709
  const pollRes = waitingPollers.shift();
365
710
  const index = task_queue_1.taskQueue.findIndex(t => t.id === task.id);
366
711
  if (index !== -1)
367
712
  task_queue_1.taskQueue.splice(index, 1);
713
+ removePendingStreamTask(task.id);
368
714
  for (const queue of studioEventQueues.values()) {
369
715
  const qIndex = queue.findIndex((e) => e.requestId === task.id);
370
716
  if (qIndex !== -1)
371
717
  queue.splice(qIndex, 1);
372
718
  }
373
719
  addLog(io, 'task', `[Legacy] Mengirim perintah '${task.command}' ke Studio.`);
374
- pollRes?.json(task);
720
+ pollRes?.json(claimed);
375
721
  }
376
722
  });
377
723
  app.get('/api/tasks', (req, res) => {
378
- if (task_queue_1.taskQueue.length > 0) {
379
- const task = task_queue_1.taskQueue.shift();
380
- addLog(io, 'task', `[Legacy] Mengirim perintah tertunda '${task?.command}' ke Studio.`);
381
- res.json(task);
382
- return;
724
+ // Cari task pertama yang belum diklaim oleh transport mana pun
725
+ const unclaimedIdx = task_queue_1.taskQueue.findIndex(t => !t.deliveredVia);
726
+ if (unclaimedIdx !== -1) {
727
+ const candidate = task_queue_1.taskQueue[unclaimedIdx];
728
+ const claimed = (0, task_queue_1.claimTask)(candidate.id, 'legacy');
729
+ if (claimed) {
730
+ task_queue_1.taskQueue.splice(unclaimedIdx, 1);
731
+ removePendingStreamTask(claimed.id);
732
+ addLog(io, 'task', `[Legacy] Mengirim perintah tertunda '${claimed.command}' ke Studio.`);
733
+ return res.json(claimed);
734
+ }
383
735
  }
384
736
  waitingPollers.push(res);
385
- req.on('close', () => {
737
+ res.on('close', () => {
386
738
  const index = waitingPollers.indexOf(res);
387
739
  if (index !== -1)
388
740
  waitingPollers.splice(index, 1);
@@ -393,6 +745,7 @@ function startBridgeServer(port, isDaemon = false) {
393
745
  const server = httpServer.listen(port, () => {
394
746
  exports.isBridgeHosting = true;
395
747
  console.error(`[Bridge] SSE Bridge Server aktif di http://localhost:${port}`);
748
+ resolveBridgeReady(true);
396
749
  });
397
750
  server.on('error', async (err) => {
398
751
  if (err.code === 'EADDRINUSE') {
@@ -404,6 +757,7 @@ function startBridgeServer(port, isDaemon = false) {
404
757
  if (pingRes.ok) {
405
758
  exports.isBridgeHosting = false;
406
759
  console.error(`[Bridge] Port ${port} sudah digunakan oleh NVStudio Bridge aktif. Menggunakan instance yang sudah berjalan.`);
760
+ resolveBridgeReady(false);
407
761
  return;
408
762
  }
409
763
  }
@@ -411,14 +765,16 @@ function startBridgeServer(port, isDaemon = false) {
411
765
  if (retriesLeft > 0) {
412
766
  console.error(`[Bridge] Port ${port} zombie. Mencoba membunuh...`);
413
767
  killZombieOnPort(port);
414
- setTimeout(() => tryListen(retriesLeft - 1), 1500);
768
+ setTimeout(() => tryListen(retriesLeft - 1), 500);
415
769
  }
416
770
  else {
417
771
  console.error(`[Bridge] FATAL: Port ${port} tidak dapat dibuka.`);
772
+ resolveBridgeReady(false);
418
773
  }
419
774
  }
420
775
  else {
421
776
  console.error(`[Bridge] FATAL: Gagal mendengarkan port ${port}:`, err.message);
777
+ resolveBridgeReady(false);
422
778
  process.exit(1);
423
779
  }
424
780
  });