@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.
- package/dist/index.js +775 -141
- package/dist/roblox-bridge.js +442 -86
- package/dist/task-queue.js +57 -6
- package/dist/ws-gateway.js +579 -0
- package/package.json +4 -2
- package/public/index.html +16 -2
- package/studio-plugin/Main.server.lua +484 -53
- package/studio-plugin/get_properties.lua +25 -2
- package/studio-plugin/nvstudio_mcp.rbxmx +513 -57
- package/studio-plugin/script_grep.lua +4 -2
package/dist/roblox-bridge.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
111
|
-
*
|
|
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
|
-
|
|
282
|
+
studioId: task.studioId,
|
|
283
|
+
placeId: task.placeId,
|
|
284
|
+
remainingMs,
|
|
120
285
|
};
|
|
121
|
-
|
|
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.
|
|
136
|
-
|
|
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: {
|
|
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 =
|
|
473
|
+
const activeStudios = getConnectedStudioCount();
|
|
474
|
+
const wsStudios = (0, ws_gateway_1.getWsPeerCount)();
|
|
197
475
|
res.json({
|
|
198
476
|
totalTasks, successCount, errorCount, sessionStart,
|
|
199
|
-
studioConnected:
|
|
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 =
|
|
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 (
|
|
218
|
-
// Roblox HttpService:GetAsync()
|
|
219
|
-
//
|
|
220
|
-
//
|
|
221
|
-
//
|
|
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
|
-
//
|
|
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
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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
|
-
//
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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
|
-
|
|
299
|
-
|
|
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
|
|
646
|
+
const taskId = req.params.taskId || req.body?.taskId || req.body?.id;
|
|
304
647
|
const { status, result, error } = req.body;
|
|
305
|
-
|
|
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
|
|
651
|
+
// ── /api/response (legacy & fallback) (Bug H) ───────────────────────────
|
|
317
652
|
app.post('/api/response', (req, res) => {
|
|
318
|
-
const
|
|
319
|
-
|
|
320
|
-
|
|
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
|
|
342
|
-
if (
|
|
343
|
-
|
|
344
|
-
|
|
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:
|
|
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
|
|
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(
|
|
720
|
+
pollRes?.json(claimed);
|
|
375
721
|
}
|
|
376
722
|
});
|
|
377
723
|
app.get('/api/tasks', (req, res) => {
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
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
|
-
|
|
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),
|
|
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
|
});
|