@adiholick/nvstudio-mcp 2.0.4 → 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.
- package/dist/index.js +53 -9
- package/dist/roblox-bridge.js +272 -83
- package/package.json +1 -1
- package/studio-plugin/Main.server.lua +349 -301
- package/studio-plugin/nvstudio_mcp.rbxmx +357 -300
- package/studio-plugin/ui_manager.lua +12 -3
package/dist/index.js
CHANGED
|
@@ -22,12 +22,13 @@ function backupScript(target, code) {
|
|
|
22
22
|
const fileName = `${safeTargetName}_${timestamp}.luau`;
|
|
23
23
|
fs_1.default.writeFileSync(path_1.default.join(HISTORY_DIR, fileName), code);
|
|
24
24
|
}
|
|
25
|
+
const isDaemon = process.argv.includes('--daemon') || process.argv.includes('-d');
|
|
25
26
|
// 1. Impor dan jalankan server Express lokal di port 3055
|
|
26
27
|
(0, roblox_bridge_1.startBridgeServer)(3055);
|
|
27
28
|
// 2. Inisialisasi server MCP
|
|
28
29
|
const server = new index_js_1.Server({
|
|
29
30
|
name: "nvstudio-mcp",
|
|
30
|
-
version: "2.0
|
|
31
|
+
version: "2.1.0",
|
|
31
32
|
}, {
|
|
32
33
|
capabilities: {
|
|
33
34
|
tools: {},
|
|
@@ -134,12 +135,46 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
|
|
|
134
135
|
};
|
|
135
136
|
}
|
|
136
137
|
}
|
|
137
|
-
// 8. Teruskan ke antrean
|
|
138
|
+
// 8. Teruskan ke antrean tugas
|
|
138
139
|
try {
|
|
139
140
|
if (command === "update_script_source" && data) {
|
|
140
141
|
backupScript(target, String(data));
|
|
141
142
|
}
|
|
142
|
-
|
|
143
|
+
let result;
|
|
144
|
+
if (roblox_bridge_1.isBridgeHosting) {
|
|
145
|
+
// Instance ini sendiri yang meng-host bridge server
|
|
146
|
+
result = await (0, task_queue_1.addTaskToQueue)(command, target, data);
|
|
147
|
+
}
|
|
148
|
+
else {
|
|
149
|
+
// Bridge server aktif di proses background lain (port 3055)
|
|
150
|
+
let forwardedToBridge = false;
|
|
151
|
+
try {
|
|
152
|
+
const controller = new AbortController();
|
|
153
|
+
const timeoutId = setTimeout(() => controller.abort(), 35000);
|
|
154
|
+
const bridgeRes = await fetch("http://localhost:3055/api/tasks/enqueue", {
|
|
155
|
+
method: "POST",
|
|
156
|
+
headers: { "Content-Type": "application/json" },
|
|
157
|
+
body: JSON.stringify({ command, target, data }),
|
|
158
|
+
signal: controller.signal
|
|
159
|
+
});
|
|
160
|
+
clearTimeout(timeoutId);
|
|
161
|
+
if (bridgeRes.ok) {
|
|
162
|
+
forwardedToBridge = true;
|
|
163
|
+
const bridgeJson = await bridgeRes.json();
|
|
164
|
+
if (bridgeJson.status === "error") {
|
|
165
|
+
throw new Error(bridgeJson.error);
|
|
166
|
+
}
|
|
167
|
+
result = bridgeJson.result;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
catch (err) {
|
|
171
|
+
if (forwardedToBridge) {
|
|
172
|
+
throw err;
|
|
173
|
+
}
|
|
174
|
+
// Fallback in-process jika HTTP gagal dihubungi
|
|
175
|
+
result = await (0, task_queue_1.addTaskToQueue)(command, target, data);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
143
178
|
return {
|
|
144
179
|
content: [
|
|
145
180
|
{
|
|
@@ -164,16 +199,19 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
|
|
|
164
199
|
};
|
|
165
200
|
}
|
|
166
201
|
});
|
|
167
|
-
// 8. Jalankan koneksi MCP menggunakan StdioServerTransport
|
|
202
|
+
// 8. Jalankan koneksi MCP menggunakan StdioServerTransport (hanya jika bukan mode Daemon)
|
|
168
203
|
async function main() {
|
|
169
204
|
const transport = new stdio_js_1.StdioServerTransport();
|
|
170
205
|
await server.connect(transport);
|
|
171
206
|
console.error("[nvstudio-mcp] MCP Server aktif melalui StdioServerTransport.");
|
|
207
|
+
// Beritahu SSE plugin bahwa MCP agent sekarang aktif
|
|
208
|
+
(0, roblox_bridge_1.setMcpConnected)(true);
|
|
172
209
|
let isShuttingDown = false;
|
|
173
210
|
const shutdown = () => {
|
|
174
211
|
if (isShuttingDown)
|
|
175
212
|
return;
|
|
176
213
|
isShuttingDown = true;
|
|
214
|
+
(0, roblox_bridge_1.setMcpConnected)(false); // Beritahu SSE plugin bahwa MCP agent sudah menutup koneksi
|
|
177
215
|
console.error("[nvstudio-mcp] Koneksi ditutup oleh IDE. Mematikan server...");
|
|
178
216
|
process.exit(0);
|
|
179
217
|
};
|
|
@@ -182,7 +220,6 @@ async function main() {
|
|
|
182
220
|
process.stdin.on('end', shutdown);
|
|
183
221
|
process.stdin.on('error', shutdown);
|
|
184
222
|
// Fallback khusus Windows: cek apakah stdin masih readable secara berkala
|
|
185
|
-
// process.kill(ppid, 0) tidak reliable di Windows, jadi kita pakai cara ini
|
|
186
223
|
setInterval(() => {
|
|
187
224
|
if (!process.stdin.readable) {
|
|
188
225
|
console.error("[nvstudio-mcp] stdin tidak readable lagi. IDE kemungkinan sudah ditutup.");
|
|
@@ -190,7 +227,14 @@ async function main() {
|
|
|
190
227
|
}
|
|
191
228
|
}, 3000);
|
|
192
229
|
}
|
|
193
|
-
|
|
194
|
-
console.error("[nvstudio-mcp]
|
|
195
|
-
process.exit(
|
|
196
|
-
|
|
230
|
+
if (isDaemon) {
|
|
231
|
+
console.error("[nvstudio-mcp] Berjalan dalam mode DAEMON (Background Bridge Server di port 3055).");
|
|
232
|
+
process.on('SIGINT', () => process.exit(0));
|
|
233
|
+
process.on('SIGTERM', () => process.exit(0));
|
|
234
|
+
}
|
|
235
|
+
else {
|
|
236
|
+
main().catch((err) => {
|
|
237
|
+
console.error("[nvstudio-mcp] Gagal menjalankan MCP server:", err);
|
|
238
|
+
process.exit(1);
|
|
239
|
+
});
|
|
240
|
+
}
|
package/dist/roblox-bridge.js
CHANGED
|
@@ -3,6 +3,8 @@ 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;
|
|
7
|
+
exports.setMcpConnected = setMcpConnected;
|
|
6
8
|
exports.startBridgeServer = startBridgeServer;
|
|
7
9
|
const express_1 = __importDefault(require("express"));
|
|
8
10
|
const http_1 = require("http");
|
|
@@ -10,11 +12,87 @@ const socket_io_1 = require("socket.io");
|
|
|
10
12
|
const path_1 = __importDefault(require("path"));
|
|
11
13
|
const child_process_1 = require("child_process");
|
|
12
14
|
const task_queue_1 = require("./task-queue");
|
|
15
|
+
exports.isBridgeHosting = false;
|
|
13
16
|
const activityLogs = [];
|
|
14
17
|
let totalTasks = 0;
|
|
15
18
|
let successCount = 0;
|
|
16
19
|
let errorCount = 0;
|
|
17
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
|
+
}
|
|
37
|
+
const ssePeers = new Map();
|
|
38
|
+
// Apakah MCP agent (Antigravity/Cursor) sedang terhubung ke stdio transport?
|
|
39
|
+
let mcpConnected = false;
|
|
40
|
+
function setMcpConnected(connected) {
|
|
41
|
+
mcpConnected = connected;
|
|
42
|
+
broadcastStatus();
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Kirim event SSE ke semua Studio peer yang terhubung.
|
|
46
|
+
*/
|
|
47
|
+
function broadcastEvent(kind, payload) {
|
|
48
|
+
const data = JSON.stringify({ kind, ...payload });
|
|
49
|
+
for (const peer of ssePeers.values()) {
|
|
50
|
+
try {
|
|
51
|
+
peer.res.write(`data: ${data}\n\n`);
|
|
52
|
+
}
|
|
53
|
+
catch { /* peer disconnected */ }
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Kirim event SSE ke satu peer tertentu.
|
|
58
|
+
*/
|
|
59
|
+
function sendEvent(peer, kind, payload) {
|
|
60
|
+
const data = JSON.stringify({ kind, ...payload });
|
|
61
|
+
try {
|
|
62
|
+
peer.res.write(`data: ${data}\n\n`);
|
|
63
|
+
}
|
|
64
|
+
catch { /* peer disconnected */ }
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Push status koneksi ke semua peer (apakah mcpConnected, jumlah peer aktif dll).
|
|
68
|
+
*/
|
|
69
|
+
function broadcastStatus() {
|
|
70
|
+
const statusEvent = {
|
|
71
|
+
mcpConnected,
|
|
72
|
+
studioCount: ssePeers.size,
|
|
73
|
+
serverVersion: '2.1.0',
|
|
74
|
+
};
|
|
75
|
+
broadcastEvent('status', statusEvent);
|
|
76
|
+
// Juga enqueue untuk Studio yang belum polling saat ini
|
|
77
|
+
enqueueForStudio(null, { kind: 'status', ...statusEvent });
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Push task ke semua Studio peer (broadcast ke semua Studio yang terbuka).
|
|
81
|
+
* Hanya satu yang akan memprosesnya (first-come first-served via requestId).
|
|
82
|
+
*/
|
|
83
|
+
function broadcastTask(task) {
|
|
84
|
+
const taskEvent = {
|
|
85
|
+
requestId: task.id,
|
|
86
|
+
command: task.command,
|
|
87
|
+
target: task.target,
|
|
88
|
+
data: task.data ?? null,
|
|
89
|
+
remainingMs: 29000,
|
|
90
|
+
};
|
|
91
|
+
broadcastEvent('request', taskEvent);
|
|
92
|
+
// Juga enqueue untuk Studio yang sedang tidak polling
|
|
93
|
+
enqueueForStudio(null, { kind: 'request', ...taskEvent });
|
|
94
|
+
}
|
|
95
|
+
// ── Logging ───────────────────────────────────────────────────────────────────
|
|
18
96
|
function addLog(io, type, message) {
|
|
19
97
|
const log = {
|
|
20
98
|
id: Date.now().toString(),
|
|
@@ -30,7 +108,6 @@ function addLog(io, type, message) {
|
|
|
30
108
|
}
|
|
31
109
|
/**
|
|
32
110
|
* Membunuh proses zombie yang masih menyandera port tertentu.
|
|
33
|
-
* Ini menyelesaikan masalah EADDRINUSE yang terjadi setelah restart IDE.
|
|
34
111
|
*/
|
|
35
112
|
function killZombieOnPort(port) {
|
|
36
113
|
try {
|
|
@@ -51,7 +128,6 @@ function killZombieOnPort(port) {
|
|
|
51
128
|
return true;
|
|
52
129
|
}
|
|
53
130
|
else {
|
|
54
|
-
// macOS / Linux
|
|
55
131
|
const output = (0, child_process_1.execSync)(`lsof -ti :${port}`, { encoding: 'utf8', timeout: 5000 });
|
|
56
132
|
const pids = output.trim().split('\n');
|
|
57
133
|
for (const pid of pids) {
|
|
@@ -66,128 +142,241 @@ function killZombieOnPort(port) {
|
|
|
66
142
|
}
|
|
67
143
|
}
|
|
68
144
|
catch {
|
|
69
|
-
// Tidak ada proses yang menyandera port — ini normal
|
|
70
145
|
return false;
|
|
71
146
|
}
|
|
72
147
|
}
|
|
73
148
|
function startBridgeServer(port = 3055) {
|
|
74
149
|
const app = (0, express_1.default)();
|
|
75
150
|
const httpServer = (0, http_1.createServer)(app);
|
|
76
|
-
const io = new socket_io_1.Server(httpServer, { cors: { origin:
|
|
151
|
+
const io = new socket_io_1.Server(httpServer, { cors: { origin: '*' } });
|
|
77
152
|
app.use(express_1.default.json({ limit: '50mb' }));
|
|
78
153
|
app.use(express_1.default.static(path_1.default.join(__dirname, '../public')));
|
|
79
154
|
let dashboardOpened = false;
|
|
80
|
-
|
|
81
|
-
// Provide initial state to newly connected dashboard clients
|
|
155
|
+
// Sediakan initial state ke dashboard browser baru
|
|
82
156
|
io.on('connection', (socket) => {
|
|
83
157
|
socket.emit('init', {
|
|
84
158
|
logs: activityLogs,
|
|
85
|
-
stats: { totalTasks, successCount, errorCount, sessionStart, studioConnected, port },
|
|
159
|
+
stats: { totalTasks, successCount, errorCount, sessionStart, studioConnected: ssePeers.size > 0, port },
|
|
86
160
|
});
|
|
87
161
|
});
|
|
88
|
-
//
|
|
162
|
+
// ── /api/stats ─────────────────────────────────────────────────────────────
|
|
89
163
|
app.get('/api/stats', (req, res) => {
|
|
90
|
-
res.json({
|
|
164
|
+
res.json({
|
|
165
|
+
totalTasks, successCount, errorCount, sessionStart,
|
|
166
|
+
studioConnected: ssePeers.size > 0,
|
|
167
|
+
studioCount: ssePeers.size,
|
|
168
|
+
mcpConnected,
|
|
169
|
+
port,
|
|
170
|
+
});
|
|
91
171
|
});
|
|
92
|
-
//
|
|
172
|
+
// ── /api/ping ─────────────────────────────────────────────────────────────
|
|
173
|
+
// Dipertahankan untuk backward-compat (installer check, health check).
|
|
93
174
|
app.get('/api/ping', (req, res) => {
|
|
94
|
-
|
|
95
|
-
studioConnected = true;
|
|
96
|
-
io.emit('studio-status', { connected: true });
|
|
97
|
-
addLog(io, 'system', 'Roblox Studio terhubung ke Bridge Server via Ping.');
|
|
98
|
-
}
|
|
99
|
-
if (!dashboardOpened) {
|
|
100
|
-
dashboardOpened = true;
|
|
101
|
-
const url = `http://localhost:${port}`;
|
|
102
|
-
const startCmd = process.platform === 'win32' ? `start "" "${url}"` : process.platform === 'darwin' ? `open "${url}"` : `xdg-open "${url}"`;
|
|
103
|
-
(0, child_process_1.exec)(startCmd, (error) => {
|
|
104
|
-
if (error)
|
|
105
|
-
console.error(`[Bridge] Gagal membuka browser:`, error);
|
|
106
|
-
});
|
|
107
|
-
console.error(`[Bridge] Pertama kali terhubung dengan Studio! Membuka Dashboard...`);
|
|
108
|
-
}
|
|
109
|
-
res.json({ status: 'ok', studioConnected: true, timestamp: Date.now() });
|
|
175
|
+
res.json({ status: 'ok', studioCount: ssePeers.size, mcpConnected, timestamp: Date.now() });
|
|
110
176
|
});
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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).
|
|
182
|
+
app.get('/api/stream', (req, res) => {
|
|
183
|
+
const studioId = req.query.studioId || `anon-${Date.now()}`;
|
|
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
|
+
}
|
|
119
201
|
}
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
(0, child_process_1.exec)(startCmd, (error) => {
|
|
133
|
-
if (error)
|
|
134
|
-
console.error(`[Bridge] Gagal membuka browser:`, error);
|
|
135
|
-
});
|
|
136
|
-
console.error(`[Bridge] Pertama kali terhubung dengan Studio! Membuka Dashboard...`);
|
|
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);
|
|
137
214
|
}
|
|
215
|
+
// Jika ada task di queue global, sertakan juga
|
|
138
216
|
if (task_queue_1.taskQueue.length > 0) {
|
|
139
217
|
const task = task_queue_1.taskQueue.shift();
|
|
140
218
|
totalTasks++;
|
|
141
|
-
addLog(io, 'task', `Mengirim perintah '${task
|
|
142
|
-
|
|
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()}`;
|
|
240
|
+
res.setHeader('Content-Type', 'text/event-stream');
|
|
241
|
+
res.setHeader('Cache-Control', 'no-cache');
|
|
242
|
+
res.setHeader('Connection', 'keep-alive');
|
|
243
|
+
res.setHeader('X-Accel-Buffering', 'no');
|
|
244
|
+
res.flushHeaders();
|
|
245
|
+
const heartbeatTimer = setInterval(() => {
|
|
246
|
+
try {
|
|
247
|
+
res.write(`data: ${JSON.stringify({ kind: 'heartbeat', timestamp: Date.now() })}\n\n`);
|
|
248
|
+
}
|
|
249
|
+
catch {
|
|
250
|
+
cleanup();
|
|
251
|
+
}
|
|
252
|
+
}, 5000);
|
|
253
|
+
const silenceTimer = setTimeout(() => cleanup(), 60000);
|
|
254
|
+
const peer = { studioId, res, connectedAt: Date.now(), heartbeatTimer, silenceTimer };
|
|
255
|
+
ssePeers.set(studioId, peer);
|
|
256
|
+
sendEvent(peer, 'status', { mcpConnected, studioCount: studioEventQueues.size, serverVersion: '2.1.0' });
|
|
257
|
+
function cleanup() {
|
|
258
|
+
clearInterval(heartbeatTimer);
|
|
259
|
+
clearTimeout(silenceTimer);
|
|
260
|
+
ssePeers.delete(studioId);
|
|
261
|
+
try {
|
|
262
|
+
res.end();
|
|
263
|
+
}
|
|
264
|
+
catch { /* ignore */ }
|
|
265
|
+
}
|
|
266
|
+
req.on('close', cleanup);
|
|
267
|
+
req.on('error', cleanup);
|
|
268
|
+
});
|
|
269
|
+
// ── /api/response/:taskId (Studio POSTs hasil eksekusi) ───────────────────
|
|
270
|
+
app.post('/api/response/:taskId', (req, res) => {
|
|
271
|
+
const { taskId } = req.params;
|
|
272
|
+
const { status, result, error } = req.body;
|
|
273
|
+
if (status === 'success') {
|
|
274
|
+
successCount++;
|
|
275
|
+
addLog(io, 'response', `Perintah selesai: ${String(result ?? '').substring(0, 120)}`);
|
|
143
276
|
}
|
|
144
277
|
else {
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
const index = waitingPollers.indexOf(res);
|
|
148
|
-
if (index !== -1) {
|
|
149
|
-
waitingPollers.splice(index, 1);
|
|
150
|
-
res.json({ id: null });
|
|
151
|
-
}
|
|
152
|
-
}, 20000);
|
|
153
|
-
waitingPollers.push(res);
|
|
154
|
-
// Bersihkan jika terputus prematur
|
|
155
|
-
req.on('close', () => {
|
|
156
|
-
clearTimeout(timeoutId);
|
|
157
|
-
const index = waitingPollers.indexOf(res);
|
|
158
|
-
if (index !== -1) {
|
|
159
|
-
waitingPollers.splice(index, 1);
|
|
160
|
-
}
|
|
161
|
-
});
|
|
278
|
+
errorCount++;
|
|
279
|
+
addLog(io, 'error', `Gagal: ${error}`);
|
|
162
280
|
}
|
|
281
|
+
(0, task_queue_1.resolvePendingTask)(taskId, status, result, error);
|
|
282
|
+
res.json({ ok: true });
|
|
163
283
|
});
|
|
164
|
-
//
|
|
284
|
+
// ── /api/response (legacy — lama, tetap didukung) ────────────────────────
|
|
165
285
|
app.post('/api/response', (req, res) => {
|
|
166
286
|
const { id, status, result, error } = req.body;
|
|
167
287
|
if (status === 'success') {
|
|
168
288
|
successCount++;
|
|
169
|
-
addLog(io, 'response', `Perintah selesai: ${String(result).substring(0, 120)}`);
|
|
289
|
+
addLog(io, 'response', `Perintah selesai: ${String(result ?? '').substring(0, 120)}`);
|
|
170
290
|
}
|
|
171
291
|
else {
|
|
172
292
|
errorCount++;
|
|
173
293
|
addLog(io, 'error', `Gagal: ${error}`);
|
|
174
294
|
}
|
|
175
295
|
(0, task_queue_1.resolvePendingTask)(id, status, result, error);
|
|
176
|
-
res.json({
|
|
296
|
+
res.json({ ok: true });
|
|
297
|
+
});
|
|
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.
|
|
301
|
+
task_queue_1.taskEmitter.on('new_task', () => {
|
|
302
|
+
if (task_queue_1.taskQueue.length === 0)
|
|
303
|
+
return;
|
|
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...`);
|
|
306
|
+
});
|
|
307
|
+
// ── /api/tasks/enqueue (dari proses MCP eksternal / bridge forwarding) ────
|
|
308
|
+
app.post('/api/tasks/enqueue', async (req, res) => {
|
|
309
|
+
const { command, target, data } = req.body;
|
|
310
|
+
try {
|
|
311
|
+
const result = await (0, task_queue_1.addTaskToQueue)(command, target, data);
|
|
312
|
+
res.json({ status: 'success', result });
|
|
313
|
+
}
|
|
314
|
+
catch (err) {
|
|
315
|
+
res.json({ status: 'error', error: err.message });
|
|
316
|
+
}
|
|
177
317
|
});
|
|
178
|
-
// ──
|
|
318
|
+
// ── /api/tasks (legacy long-poll — dipertahankan untuk backward-compat) ───
|
|
319
|
+
// Studio lama yang belum update plugin masih bisa bekerja via polling.
|
|
320
|
+
const waitingPollers = [];
|
|
321
|
+
task_queue_1.taskEmitter.on('new_task_legacy', () => {
|
|
322
|
+
if (task_queue_1.taskQueue.length > 0 && waitingPollers.length > 0) {
|
|
323
|
+
const pollRes = waitingPollers.shift();
|
|
324
|
+
const task = task_queue_1.taskQueue.shift();
|
|
325
|
+
totalTasks++;
|
|
326
|
+
addLog(io, 'task', `[Legacy] Mengirim perintah '${task?.command}' ke Studio.`);
|
|
327
|
+
pollRes?.json(task);
|
|
328
|
+
}
|
|
329
|
+
});
|
|
330
|
+
app.get('/api/tasks', (req, res) => {
|
|
331
|
+
if (task_queue_1.taskQueue.length > 0) {
|
|
332
|
+
const task = task_queue_1.taskQueue.shift();
|
|
333
|
+
totalTasks++;
|
|
334
|
+
addLog(io, 'task', `[Legacy] Mengirim perintah '${task?.command}' ke Studio.`);
|
|
335
|
+
return res.json(task);
|
|
336
|
+
}
|
|
337
|
+
const timeoutId = setTimeout(() => {
|
|
338
|
+
const index = waitingPollers.indexOf(res);
|
|
339
|
+
if (index !== -1) {
|
|
340
|
+
waitingPollers.splice(index, 1);
|
|
341
|
+
res.json({ id: null });
|
|
342
|
+
}
|
|
343
|
+
}, 20000);
|
|
344
|
+
waitingPollers.push(res);
|
|
345
|
+
req.on('close', () => {
|
|
346
|
+
clearTimeout(timeoutId);
|
|
347
|
+
const index = waitingPollers.indexOf(res);
|
|
348
|
+
if (index !== -1)
|
|
349
|
+
waitingPollers.splice(index, 1);
|
|
350
|
+
});
|
|
351
|
+
});
|
|
352
|
+
// ── Port-sharing & anti-zombie ────────────────────────────────────────────
|
|
179
353
|
function tryListen(retriesLeft = 2) {
|
|
180
354
|
const server = httpServer.listen(port, () => {
|
|
181
|
-
|
|
355
|
+
exports.isBridgeHosting = true;
|
|
356
|
+
console.error(`[Bridge] SSE Bridge Server aktif di http://localhost:${port}`);
|
|
182
357
|
});
|
|
183
|
-
server.on('error', (err) => {
|
|
184
|
-
if (err.code === 'EADDRINUSE'
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
358
|
+
server.on('error', async (err) => {
|
|
359
|
+
if (err.code === 'EADDRINUSE') {
|
|
360
|
+
try {
|
|
361
|
+
const controller = new AbortController();
|
|
362
|
+
const timeoutId = setTimeout(() => controller.abort(), 1000);
|
|
363
|
+
const pingRes = await fetch(`http://localhost:${port}/api/ping`, { signal: controller.signal });
|
|
364
|
+
clearTimeout(timeoutId);
|
|
365
|
+
if (pingRes.ok) {
|
|
366
|
+
exports.isBridgeHosting = false;
|
|
367
|
+
console.error(`[Bridge] Port ${port} sudah digunakan oleh NVStudio Bridge aktif. Menggunakan instance yang sudah berjalan.`);
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
catch { /* bukan NVStudio bridge */ }
|
|
372
|
+
if (retriesLeft > 0) {
|
|
373
|
+
console.error(`[Bridge] Port ${port} zombie. Mencoba membunuh...`);
|
|
374
|
+
killZombieOnPort(port);
|
|
375
|
+
setTimeout(() => tryListen(retriesLeft - 1), 1500);
|
|
376
|
+
}
|
|
377
|
+
else {
|
|
378
|
+
console.error(`[Bridge] FATAL: Port ${port} tidak dapat dibuka.`);
|
|
379
|
+
}
|
|
191
380
|
}
|
|
192
381
|
else {
|
|
193
382
|
console.error(`[Bridge] FATAL: Gagal mendengarkan port ${port}:`, err.message);
|