@adiholick/nvstudio-mcp 2.0.3 → 2.1.0
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 +241 -64
- package/package.json +1 -1
- package/studio-plugin/Main.server.lua +287 -205
- package/studio-plugin/nvstudio_mcp.rbxmx +288 -206
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,65 @@ 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
|
+
const ssePeers = new Map();
|
|
22
|
+
// Apakah MCP agent (Antigravity/Cursor) sedang terhubung ke stdio transport?
|
|
23
|
+
let mcpConnected = false;
|
|
24
|
+
function setMcpConnected(connected) {
|
|
25
|
+
mcpConnected = connected;
|
|
26
|
+
broadcastStatus();
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Kirim event SSE ke semua Studio peer yang terhubung.
|
|
30
|
+
*/
|
|
31
|
+
function broadcastEvent(kind, payload) {
|
|
32
|
+
const data = JSON.stringify({ kind, ...payload });
|
|
33
|
+
for (const peer of ssePeers.values()) {
|
|
34
|
+
try {
|
|
35
|
+
peer.res.write(`data: ${data}\n\n`);
|
|
36
|
+
}
|
|
37
|
+
catch { /* peer disconnected */ }
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Kirim event SSE ke satu peer tertentu.
|
|
42
|
+
*/
|
|
43
|
+
function sendEvent(peer, kind, payload) {
|
|
44
|
+
const data = JSON.stringify({ kind, ...payload });
|
|
45
|
+
try {
|
|
46
|
+
peer.res.write(`data: ${data}\n\n`);
|
|
47
|
+
}
|
|
48
|
+
catch { /* peer disconnected */ }
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Push status koneksi ke semua peer (apakah mcpConnected, jumlah peer aktif dll).
|
|
52
|
+
*/
|
|
53
|
+
function broadcastStatus() {
|
|
54
|
+
broadcastEvent('status', {
|
|
55
|
+
mcpConnected,
|
|
56
|
+
studioCount: ssePeers.size,
|
|
57
|
+
serverVersion: '2.1.0',
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Push task ke semua Studio peer (broadcast ke semua Studio yang terbuka).
|
|
62
|
+
* Hanya satu yang akan memprosesnya (first-come first-served via requestId).
|
|
63
|
+
*/
|
|
64
|
+
function broadcastTask(task) {
|
|
65
|
+
broadcastEvent('request', {
|
|
66
|
+
requestId: task.id,
|
|
67
|
+
command: task.command,
|
|
68
|
+
target: task.target,
|
|
69
|
+
data: task.data ?? null,
|
|
70
|
+
remainingMs: 29000,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
// ── Logging ───────────────────────────────────────────────────────────────────
|
|
18
74
|
function addLog(io, type, message) {
|
|
19
75
|
const log = {
|
|
20
76
|
id: Date.now().toString(),
|
|
@@ -30,7 +86,6 @@ function addLog(io, type, message) {
|
|
|
30
86
|
}
|
|
31
87
|
/**
|
|
32
88
|
* Membunuh proses zombie yang masih menyandera port tertentu.
|
|
33
|
-
* Ini menyelesaikan masalah EADDRINUSE yang terjadi setelah restart IDE.
|
|
34
89
|
*/
|
|
35
90
|
function killZombieOnPort(port) {
|
|
36
91
|
try {
|
|
@@ -51,7 +106,6 @@ function killZombieOnPort(port) {
|
|
|
51
106
|
return true;
|
|
52
107
|
}
|
|
53
108
|
else {
|
|
54
|
-
// macOS / Linux
|
|
55
109
|
const output = (0, child_process_1.execSync)(`lsof -ti :${port}`, { encoding: 'utf8', timeout: 5000 });
|
|
56
110
|
const pids = output.trim().split('\n');
|
|
57
111
|
for (const pid of pids) {
|
|
@@ -66,109 +120,232 @@ function killZombieOnPort(port) {
|
|
|
66
120
|
}
|
|
67
121
|
}
|
|
68
122
|
catch {
|
|
69
|
-
// Tidak ada proses yang menyandera port — ini normal
|
|
70
123
|
return false;
|
|
71
124
|
}
|
|
72
125
|
}
|
|
73
126
|
function startBridgeServer(port = 3055) {
|
|
74
127
|
const app = (0, express_1.default)();
|
|
75
128
|
const httpServer = (0, http_1.createServer)(app);
|
|
76
|
-
const io = new socket_io_1.Server(httpServer, { cors: { origin:
|
|
129
|
+
const io = new socket_io_1.Server(httpServer, { cors: { origin: '*' } });
|
|
77
130
|
app.use(express_1.default.json({ limit: '50mb' }));
|
|
78
131
|
app.use(express_1.default.static(path_1.default.join(__dirname, '../public')));
|
|
79
132
|
let dashboardOpened = false;
|
|
80
|
-
|
|
81
|
-
// Provide initial state to newly connected dashboard clients
|
|
133
|
+
// Sediakan initial state ke dashboard browser baru
|
|
82
134
|
io.on('connection', (socket) => {
|
|
83
135
|
socket.emit('init', {
|
|
84
136
|
logs: activityLogs,
|
|
85
|
-
stats: { totalTasks, successCount, errorCount, sessionStart, studioConnected, port },
|
|
137
|
+
stats: { totalTasks, successCount, errorCount, sessionStart, studioConnected: ssePeers.size > 0, port },
|
|
86
138
|
});
|
|
87
139
|
});
|
|
88
|
-
//
|
|
140
|
+
// ── /api/stats ─────────────────────────────────────────────────────────────
|
|
89
141
|
app.get('/api/stats', (req, res) => {
|
|
90
|
-
res.json({
|
|
142
|
+
res.json({
|
|
143
|
+
totalTasks, successCount, errorCount, sessionStart,
|
|
144
|
+
studioConnected: ssePeers.size > 0,
|
|
145
|
+
studioCount: ssePeers.size,
|
|
146
|
+
mcpConnected,
|
|
147
|
+
port,
|
|
148
|
+
});
|
|
91
149
|
});
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
const task = task_queue_1.taskQueue.shift();
|
|
97
|
-
totalTasks++;
|
|
98
|
-
addLog(io, 'task', `Mengirim perintah '${task?.command}' ke Studio${task?.target ? ` → ${task.target}` : ''}.`);
|
|
99
|
-
res?.json(task);
|
|
100
|
-
}
|
|
150
|
+
// ── /api/ping ─────────────────────────────────────────────────────────────
|
|
151
|
+
// Dipertahankan untuk backward-compat (installer check, health check).
|
|
152
|
+
app.get('/api/ping', (req, res) => {
|
|
153
|
+
res.json({ status: 'ok', studioCount: ssePeers.size, mcpConnected, timestamp: Date.now() });
|
|
101
154
|
});
|
|
102
|
-
//
|
|
103
|
-
app.get('/api/
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
155
|
+
// ── /api/stream (SSE endpoint utama) ─────────────────────────────────────
|
|
156
|
+
app.get('/api/stream', (req, res) => {
|
|
157
|
+
const studioId = req.query.studioId || `anon-${Date.now()}`;
|
|
158
|
+
// Set SSE headers
|
|
159
|
+
res.setHeader('Content-Type', 'text/event-stream');
|
|
160
|
+
res.setHeader('Cache-Control', 'no-cache');
|
|
161
|
+
res.setHeader('Connection', 'keep-alive');
|
|
162
|
+
res.setHeader('X-Accel-Buffering', 'no'); // Untuk Nginx proxy
|
|
163
|
+
res.flushHeaders();
|
|
164
|
+
// Heartbeat setiap 5 detik agar koneksi tidak timeout
|
|
165
|
+
const heartbeatTimer = setInterval(() => {
|
|
166
|
+
try {
|
|
167
|
+
res.write(`data: ${JSON.stringify({ kind: 'heartbeat', timestamp: Date.now() })}\n\n`);
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
cleanup();
|
|
171
|
+
}
|
|
172
|
+
}, 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
|
+
};
|
|
179
|
+
const peer = { studioId, res, connectedAt: Date.now(), heartbeatTimer, silenceTimer };
|
|
180
|
+
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
|
+
});
|
|
108
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})`);
|
|
109
200
|
if (!dashboardOpened) {
|
|
110
201
|
dashboardOpened = true;
|
|
111
202
|
const url = `http://localhost:${port}`;
|
|
112
203
|
const startCmd = process.platform === 'win32' ? `start "" "${url}"` : process.platform === 'darwin' ? `open "${url}"` : `xdg-open "${url}"`;
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
+
}
|
|
118
212
|
}
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
213
|
+
function cleanup() {
|
|
214
|
+
clearInterval(heartbeatTimer);
|
|
215
|
+
clearTimeout(silenceTimer);
|
|
216
|
+
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
|
+
try {
|
|
220
|
+
res.end();
|
|
221
|
+
}
|
|
222
|
+
catch { /* sudah tutup */ }
|
|
223
|
+
}
|
|
224
|
+
req.on('close', cleanup);
|
|
225
|
+
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
|
+
});
|
|
234
|
+
// ── /api/response/:taskId (Studio POSTs hasil eksekusi) ───────────────────
|
|
235
|
+
app.post('/api/response/:taskId', (req, res) => {
|
|
236
|
+
const { taskId } = req.params;
|
|
237
|
+
const { status, result, error } = req.body;
|
|
238
|
+
if (status === 'success') {
|
|
239
|
+
successCount++;
|
|
240
|
+
addLog(io, 'response', `Perintah selesai: ${String(result ?? '').substring(0, 120)}`);
|
|
124
241
|
}
|
|
125
242
|
else {
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
const index = waitingPollers.indexOf(res);
|
|
129
|
-
if (index !== -1) {
|
|
130
|
-
waitingPollers.splice(index, 1);
|
|
131
|
-
res.json({ id: null });
|
|
132
|
-
}
|
|
133
|
-
}, 20000);
|
|
134
|
-
waitingPollers.push(res);
|
|
135
|
-
// Bersihkan jika terputus prematur
|
|
136
|
-
req.on('close', () => {
|
|
137
|
-
clearTimeout(timeoutId);
|
|
138
|
-
const index = waitingPollers.indexOf(res);
|
|
139
|
-
if (index !== -1) {
|
|
140
|
-
waitingPollers.splice(index, 1);
|
|
141
|
-
}
|
|
142
|
-
});
|
|
243
|
+
errorCount++;
|
|
244
|
+
addLog(io, 'error', `Gagal: ${error}`);
|
|
143
245
|
}
|
|
246
|
+
(0, task_queue_1.resolvePendingTask)(taskId, status, result, error);
|
|
247
|
+
res.json({ ok: true });
|
|
144
248
|
});
|
|
145
|
-
//
|
|
249
|
+
// ── /api/response (legacy — lama, tetap didukung) ────────────────────────
|
|
146
250
|
app.post('/api/response', (req, res) => {
|
|
147
251
|
const { id, status, result, error } = req.body;
|
|
148
252
|
if (status === 'success') {
|
|
149
253
|
successCount++;
|
|
150
|
-
addLog(io, 'response', `Perintah selesai: ${String(result).substring(0, 120)}`);
|
|
254
|
+
addLog(io, 'response', `Perintah selesai: ${String(result ?? '').substring(0, 120)}`);
|
|
151
255
|
}
|
|
152
256
|
else {
|
|
153
257
|
errorCount++;
|
|
154
258
|
addLog(io, 'error', `Gagal: ${error}`);
|
|
155
259
|
}
|
|
156
260
|
(0, task_queue_1.resolvePendingTask)(id, status, result, error);
|
|
157
|
-
res.json({
|
|
261
|
+
res.json({ ok: true });
|
|
262
|
+
});
|
|
263
|
+
// ── Push task ke SSE stream saat ada task baru ────────────────────────────
|
|
264
|
+
task_queue_1.taskEmitter.on('new_task', () => {
|
|
265
|
+
if (task_queue_1.taskQueue.length === 0)
|
|
266
|
+
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);
|
|
158
275
|
});
|
|
159
|
-
// ──
|
|
276
|
+
// ── /api/tasks/enqueue (dari proses MCP eksternal / bridge forwarding) ────
|
|
277
|
+
app.post('/api/tasks/enqueue', async (req, res) => {
|
|
278
|
+
const { command, target, data } = req.body;
|
|
279
|
+
try {
|
|
280
|
+
const result = await (0, task_queue_1.addTaskToQueue)(command, target, data);
|
|
281
|
+
res.json({ status: 'success', result });
|
|
282
|
+
}
|
|
283
|
+
catch (err) {
|
|
284
|
+
res.json({ status: 'error', error: err.message });
|
|
285
|
+
}
|
|
286
|
+
});
|
|
287
|
+
// ── /api/tasks (legacy long-poll — dipertahankan untuk backward-compat) ───
|
|
288
|
+
// Studio lama yang belum update plugin masih bisa bekerja via polling.
|
|
289
|
+
const waitingPollers = [];
|
|
290
|
+
task_queue_1.taskEmitter.on('new_task_legacy', () => {
|
|
291
|
+
if (task_queue_1.taskQueue.length > 0 && waitingPollers.length > 0) {
|
|
292
|
+
const pollRes = waitingPollers.shift();
|
|
293
|
+
const task = task_queue_1.taskQueue.shift();
|
|
294
|
+
totalTasks++;
|
|
295
|
+
addLog(io, 'task', `[Legacy] Mengirim perintah '${task?.command}' ke Studio.`);
|
|
296
|
+
pollRes?.json(task);
|
|
297
|
+
}
|
|
298
|
+
});
|
|
299
|
+
app.get('/api/tasks', (req, res) => {
|
|
300
|
+
if (task_queue_1.taskQueue.length > 0) {
|
|
301
|
+
const task = task_queue_1.taskQueue.shift();
|
|
302
|
+
totalTasks++;
|
|
303
|
+
addLog(io, 'task', `[Legacy] Mengirim perintah '${task?.command}' ke Studio.`);
|
|
304
|
+
return res.json(task);
|
|
305
|
+
}
|
|
306
|
+
const timeoutId = setTimeout(() => {
|
|
307
|
+
const index = waitingPollers.indexOf(res);
|
|
308
|
+
if (index !== -1) {
|
|
309
|
+
waitingPollers.splice(index, 1);
|
|
310
|
+
res.json({ id: null });
|
|
311
|
+
}
|
|
312
|
+
}, 20000);
|
|
313
|
+
waitingPollers.push(res);
|
|
314
|
+
req.on('close', () => {
|
|
315
|
+
clearTimeout(timeoutId);
|
|
316
|
+
const index = waitingPollers.indexOf(res);
|
|
317
|
+
if (index !== -1)
|
|
318
|
+
waitingPollers.splice(index, 1);
|
|
319
|
+
});
|
|
320
|
+
});
|
|
321
|
+
// ── Port-sharing & anti-zombie ────────────────────────────────────────────
|
|
160
322
|
function tryListen(retriesLeft = 2) {
|
|
161
323
|
const server = httpServer.listen(port, () => {
|
|
162
|
-
|
|
324
|
+
exports.isBridgeHosting = true;
|
|
325
|
+
console.error(`[Bridge] SSE Bridge Server aktif di http://localhost:${port}`);
|
|
163
326
|
});
|
|
164
|
-
server.on('error', (err) => {
|
|
165
|
-
if (err.code === 'EADDRINUSE'
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
327
|
+
server.on('error', async (err) => {
|
|
328
|
+
if (err.code === 'EADDRINUSE') {
|
|
329
|
+
try {
|
|
330
|
+
const controller = new AbortController();
|
|
331
|
+
const timeoutId = setTimeout(() => controller.abort(), 1000);
|
|
332
|
+
const pingRes = await fetch(`http://localhost:${port}/api/ping`, { signal: controller.signal });
|
|
333
|
+
clearTimeout(timeoutId);
|
|
334
|
+
if (pingRes.ok) {
|
|
335
|
+
exports.isBridgeHosting = false;
|
|
336
|
+
console.error(`[Bridge] Port ${port} sudah digunakan oleh NVStudio Bridge aktif. Menggunakan instance yang sudah berjalan.`);
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
catch { /* bukan NVStudio bridge */ }
|
|
341
|
+
if (retriesLeft > 0) {
|
|
342
|
+
console.error(`[Bridge] Port ${port} zombie. Mencoba membunuh...`);
|
|
343
|
+
killZombieOnPort(port);
|
|
344
|
+
setTimeout(() => tryListen(retriesLeft - 1), 1500);
|
|
345
|
+
}
|
|
346
|
+
else {
|
|
347
|
+
console.error(`[Bridge] FATAL: Port ${port} tidak dapat dibuka.`);
|
|
348
|
+
}
|
|
172
349
|
}
|
|
173
350
|
else {
|
|
174
351
|
console.error(`[Bridge] FATAL: Gagal mendengarkan port ${port}:`, err.message);
|