@adiholick/nvstudio-mcp 2.1.0 → 2.1.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.
@@ -18,6 +18,22 @@ let totalTasks = 0;
18
18
  let successCount = 0;
19
19
  let errorCount = 0;
20
20
  const sessionStart = new Date().toISOString();
21
+ // Per-studio pending events queue: events yang belum diambil oleh Studio
22
+ const studioEventQueues = new Map();
23
+ function enqueueForStudio(studioId, event) {
24
+ if (studioId) {
25
+ // Event untuk studio tertentu
26
+ if (!studioEventQueues.has(studioId))
27
+ studioEventQueues.set(studioId, []);
28
+ studioEventQueues.get(studioId).push(event);
29
+ }
30
+ else {
31
+ // Broadcast ke semua studio yang terdaftar
32
+ for (const [sid] of studioEventQueues) {
33
+ studioEventQueues.get(sid).push(event);
34
+ }
35
+ }
36
+ }
21
37
  const ssePeers = new Map();
22
38
  // Apakah MCP agent (Antigravity/Cursor) sedang terhubung ke stdio transport?
23
39
  let mcpConnected = false;
@@ -51,24 +67,30 @@ function sendEvent(peer, kind, payload) {
51
67
  * Push status koneksi ke semua peer (apakah mcpConnected, jumlah peer aktif dll).
52
68
  */
53
69
  function broadcastStatus() {
54
- broadcastEvent('status', {
70
+ const statusEvent = {
55
71
  mcpConnected,
56
72
  studioCount: ssePeers.size,
57
73
  serverVersion: '2.1.0',
58
- });
74
+ };
75
+ broadcastEvent('status', statusEvent);
76
+ // Juga enqueue untuk Studio yang belum polling saat ini
77
+ enqueueForStudio(null, { kind: 'status', ...statusEvent });
59
78
  }
60
79
  /**
61
80
  * Push task ke semua Studio peer (broadcast ke semua Studio yang terbuka).
62
81
  * Hanya satu yang akan memprosesnya (first-come first-served via requestId).
63
82
  */
64
83
  function broadcastTask(task) {
65
- broadcastEvent('request', {
84
+ const taskEvent = {
66
85
  requestId: task.id,
67
86
  command: task.command,
68
87
  target: task.target,
69
88
  data: task.data ?? null,
70
89
  remainingMs: 29000,
71
- });
90
+ };
91
+ broadcastEvent('request', taskEvent);
92
+ // Juga enqueue untuk Studio yang sedang tidak polling
93
+ enqueueForStudio(null, { kind: 'request', ...taskEvent });
72
94
  }
73
95
  // ── Logging ───────────────────────────────────────────────────────────────────
74
96
  function addLog(io, type, message) {
@@ -152,16 +174,74 @@ function startBridgeServer(port = 3055) {
152
174
  app.get('/api/ping', (req, res) => {
153
175
  res.json({ status: 'ok', studioCount: ssePeers.size, mcpConnected, timestamp: Date.now() });
154
176
  });
155
- // ── /api/stream (SSE endpoint utama) ─────────────────────────────────────
177
+ // ── /api/stream (Snapshot endpoint untuk Roblox GetAsync) ───────────────
178
+ // Roblox HttpService:GetAsync() TIDAK bisa streaming — ia block sampai
179
+ // koneksi ditutup. Endpoint ini: daftarkan studio, kirim semua events
180
+ // yang tertunda (status + queued tasks), lalu TUTUP koneksi.
181
+ // Plugin mengulang GET ini setiap ~0.5 detik (pseudo-polling via snapshot).
156
182
  app.get('/api/stream', (req, res) => {
157
183
  const studioId = req.query.studioId || `anon-${Date.now()}`;
158
- // Set SSE headers
184
+ // Daftarkan studio jika belum ada
185
+ if (!studioEventQueues.has(studioId)) {
186
+ studioEventQueues.set(studioId, []);
187
+ io.emit('studio-status', { connected: true, studioCount: studioEventQueues.size, studioId });
188
+ addLog(io, 'system', `Studio terdaftar. (ID: ${studioId.substring(0, 8)}…, Total: ${studioEventQueues.size})`);
189
+ if (!dashboardOpened) {
190
+ dashboardOpened = true;
191
+ const url = `http://localhost:${port}`;
192
+ const startCmd = process.platform === 'win32' ? `start "" "${url}"` : process.platform === 'darwin' ? `open "${url}"` : `xdg-open "${url}"`;
193
+ const isDaemon = process.argv.includes('--daemon') || process.argv.includes('-d');
194
+ if (!isDaemon) {
195
+ (0, child_process_1.exec)(startCmd, (error) => {
196
+ if (error)
197
+ console.error(`[Bridge] Gagal membuka browser:`, error);
198
+ });
199
+ }
200
+ }
201
+ }
202
+ // Kumpulkan semua events yang belum dikirim ke studio ini
203
+ const queue = studioEventQueues.get(studioId) ?? [];
204
+ studioEventQueues.set(studioId, []); // kosongkan antrian
205
+ // Selalu sertakan status terkini
206
+ const events = [
207
+ { kind: 'status', mcpConnected, studioCount: studioEventQueues.size, serverVersion: '2.1.0' },
208
+ ];
209
+ // Tambahkan events tertunda (misalnya task requests)
210
+ for (const ev of queue) {
211
+ // Hindari duplikat status
212
+ if (ev.kind !== 'status')
213
+ events.push(ev);
214
+ }
215
+ // Jika ada task di queue global, sertakan juga
216
+ if (task_queue_1.taskQueue.length > 0) {
217
+ const task = task_queue_1.taskQueue.shift();
218
+ totalTasks++;
219
+ addLog(io, 'task', `Mengirim perintah '${task.command}' ke Studio (ID: ${studioId.substring(0, 8)}…).`);
220
+ events.push({
221
+ kind: 'request',
222
+ requestId: task.id,
223
+ command: task.command,
224
+ target: task.target,
225
+ data: task.data ?? null,
226
+ remainingMs: 29000,
227
+ });
228
+ }
229
+ // Format sebagai teks SSE (setiap event = satu baris "data: ...\n\n")
230
+ res.setHeader('Content-Type', 'text/plain');
231
+ res.setHeader('Cache-Control', 'no-cache, no-store');
232
+ const body = events
233
+ .map(ev => `data: ${JSON.stringify(ev)}`)
234
+ .join('\n');
235
+ res.send(body);
236
+ });
237
+ // ── /api/events (SSE persisten untuk browser/klien modern) ───────────────
238
+ app.get('/api/events', (req, res) => {
239
+ const studioId = req.query.studioId || `browser-${Date.now()}`;
159
240
  res.setHeader('Content-Type', 'text/event-stream');
160
241
  res.setHeader('Cache-Control', 'no-cache');
161
242
  res.setHeader('Connection', 'keep-alive');
162
- res.setHeader('X-Accel-Buffering', 'no'); // Untuk Nginx proxy
243
+ res.setHeader('X-Accel-Buffering', 'no');
163
244
  res.flushHeaders();
164
- // Heartbeat setiap 5 detik agar koneksi tidak timeout
165
245
  const heartbeatTimer = setInterval(() => {
166
246
  try {
167
247
  res.write(`data: ${JSON.stringify({ kind: 'heartbeat', timestamp: Date.now() })}\n\n`);
@@ -170,66 +250,21 @@ function startBridgeServer(port = 3055) {
170
250
  cleanup();
171
251
  }
172
252
  }, 5000);
173
- // Silence timeout: jika kita sendiri tidak bisa kirim apapun dalam 30 detik, bersihkan
174
- let silenceTimer = setTimeout(() => cleanup(), 30000);
175
- const resetSilence = () => {
176
- clearTimeout(silenceTimer);
177
- silenceTimer = setTimeout(() => cleanup(), 30000);
178
- };
253
+ const silenceTimer = setTimeout(() => cleanup(), 60000);
179
254
  const peer = { studioId, res, connectedAt: Date.now(), heartbeatTimer, silenceTimer };
180
255
  ssePeers.set(studioId, peer);
181
- // Kirim status awal sesaat setelah koneksi
182
- sendEvent(peer, 'status', {
183
- mcpConnected,
184
- studioCount: ssePeers.size,
185
- serverVersion: '2.1.0',
186
- });
187
- // Jika ada task yang sudah antri sebelum Studio terhubung, kirim sekarang
188
- if (task_queue_1.taskQueue.length > 0) {
189
- const task = task_queue_1.taskQueue[0]; // preview saja, jangan shift di sini (broadcastTask akan handle)
190
- sendEvent(peer, 'request', {
191
- requestId: task.id,
192
- command: task.command,
193
- target: task.target,
194
- data: task.data ?? null,
195
- remainingMs: 29000,
196
- });
197
- }
198
- io.emit('studio-status', { connected: true, studioCount: ssePeers.size, studioId });
199
- addLog(io, 'system', `Roblox Studio terhubung via SSE. (ID: ${studioId.substring(0, 8)}…, Peer aktif: ${ssePeers.size})`);
200
- if (!dashboardOpened) {
201
- dashboardOpened = true;
202
- const url = `http://localhost:${port}`;
203
- const startCmd = process.platform === 'win32' ? `start "" "${url}"` : process.platform === 'darwin' ? `open "${url}"` : `xdg-open "${url}"`;
204
- const isDaemon = process.argv.includes('--daemon') || process.argv.includes('-d');
205
- if (!isDaemon) {
206
- (0, child_process_1.exec)(startCmd, (error) => {
207
- if (error)
208
- console.error(`[Bridge] Gagal membuka browser:`, error);
209
- });
210
- console.error(`[Bridge] Studio terhubung pertama kali! Membuka Dashboard...`);
211
- }
212
- }
256
+ sendEvent(peer, 'status', { mcpConnected, studioCount: studioEventQueues.size, serverVersion: '2.1.0' });
213
257
  function cleanup() {
214
258
  clearInterval(heartbeatTimer);
215
259
  clearTimeout(silenceTimer);
216
260
  ssePeers.delete(studioId);
217
- io.emit('studio-status', { connected: ssePeers.size > 0, studioCount: ssePeers.size, studioId });
218
- addLog(io, 'system', `Roblox Studio terputus. (ID: ${studioId.substring(0, 8)}…, Peer tersisa: ${ssePeers.size})`);
219
261
  try {
220
262
  res.end();
221
263
  }
222
- catch { /* sudah tutup */ }
264
+ catch { /* ignore */ }
223
265
  }
224
266
  req.on('close', cleanup);
225
267
  req.on('error', cleanup);
226
- // Semua write berhasil → reset silence timer
227
- const origWrite = res.write.bind(res);
228
- res.write = (...args) => {
229
- const result = origWrite(...args);
230
- resetSilence();
231
- return result;
232
- };
233
268
  });
234
269
  // ── /api/response/:taskId (Studio POSTs hasil eksekusi) ───────────────────
235
270
  app.post('/api/response/:taskId', (req, res) => {
@@ -260,18 +295,14 @@ function startBridgeServer(port = 3055) {
260
295
  (0, task_queue_1.resolvePendingTask)(id, status, result, error);
261
296
  res.json({ ok: true });
262
297
  });
263
- // ── Push task ke SSE stream saat ada task baru ────────────────────────────
298
+ // ── Notifikasi task baru ke Dashboard ────────────────────────────────────
299
+ // Pengiriman task ke Studio dilakukan via /api/stream (snapshot polling).
300
+ // Di sini kita hanya log ke dashboard browser.
264
301
  task_queue_1.taskEmitter.on('new_task', () => {
265
302
  if (task_queue_1.taskQueue.length === 0)
266
303
  return;
267
- if (ssePeers.size === 0) {
268
- // Tidak ada Studio terhubung — task tetap antri, akan dikirim saat Studio connect
269
- return;
270
- }
271
- const task = task_queue_1.taskQueue.shift();
272
- totalTasks++;
273
- addLog(io, 'task', `Mengirim perintah '${task.command}' ke Studio${task.target ? ` → ${task.target}` : ''}.`);
274
- broadcastTask(task);
304
+ const task = task_queue_1.taskQueue[0]; // preview saja, tidak shift
305
+ addLog(io, 'task', `Task baru antri: '${task.command}'${task.target ? ` → ${task.target}` : ''}. Menunggu Studio polling...`);
275
306
  });
276
307
  // ── /api/tasks/enqueue (dari proses MCP eksternal / bridge forwarding) ────
277
308
  app.post('/api/tasks/enqueue', async (req, res) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adiholick/nvstudio-mcp",
3
- "version": "2.1.0",
3
+ "version": "2.1.1",
4
4
  "description": "Local Bridge Server MCP for Roblox Studio",
5
5
  "main": "dist/index.js",
6
6
  "bin": {