9remote 0.1.63 → 2.0.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/README.md +179 -62
- package/cli/index.js +704 -294
- package/cli/utils/assets/trayIcon.ico +0 -0
- package/cli/utils/cloudflared.js +72 -36
- package/cli/utils/permissions.js +5 -5
- package/cli/utils/pids.js +114 -0
- package/cli/utils/token.js +3 -1
- package/cli/utils/tray.js +251 -0
- package/cli/utils/tui.js +209 -34
- package/cli/utils/updateChecker.js +107 -13
- package/dist/assets/trayIcon.ico +0 -0
- package/dist/cli.cjs +1 -82
- package/dist/ptyDaemon.cjs +1 -10
- package/dist/server.cjs +2 -184
- package/dist/ui/assets/{index-BfTPkO8b.css → index-COWVKicT.css} +1 -1
- package/dist/ui/assets/index-njTKNAa6.js +8 -0
- package/dist/ui/index.html +2 -2
- package/index.js +174 -618
- package/lib/constants.js +64 -0
- package/lib/deviceApproval.js +116 -0
- package/lib/router.js +134 -0
- package/lib/socketio.js +168 -25
- package/package.json +6 -1
- package/dist/ui/assets/index-B37vtDoz.js +0 -8
package/index.js
CHANGED
|
@@ -1,717 +1,273 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Server entry point - Socket.IO
|
|
2
|
+
* Server entry point - config-driven HTTP router + Socket.IO
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import { createServer, request as httpRequest } from "http";
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import { readFileSync, existsSync, writeFileSync, mkdirSync } from "fs";
|
|
6
|
+
import { exec, spawn } from "child_process";
|
|
7
|
+
import { readFileSync, existsSync } from "fs";
|
|
9
8
|
import { join, extname } from "path";
|
|
10
9
|
import { fileURLToPath } from "url";
|
|
10
|
+
import chalk from "chalk";
|
|
11
|
+
|
|
12
|
+
import { createRouter, jsonOk, jsonErr } from "./lib/router.js";
|
|
13
|
+
import { STEP, browserFetch, PERMISSION_POLL_MS } from "./lib/constants.js";
|
|
11
14
|
import { setupSocketIO, getIO } from "./lib/socketio.js";
|
|
12
|
-
import { handleLocalSites } from "./api/localSites.js";
|
|
13
15
|
import { setCorsHeaders, handlePreflight } from "./middleware/cors.js";
|
|
14
16
|
import { createProxyServer, handleProxyRequest, startProxySession, endProxySession } from "./proxy/index.js";
|
|
15
17
|
import { initializeTerminal } from "./features/terminal/terminalSocket.js";
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
18
|
-
import
|
|
19
|
-
|
|
20
|
-
import {
|
|
21
|
-
|
|
22
|
-
|
|
18
|
+
import { handleLocalSites } from "./api/localSites.js";
|
|
19
|
+
import { verifyApiKeyCrc } from "./cli/utils/apiKey.js";
|
|
20
|
+
import { isNewerVersion } from "./cli/utils/updateChecker.js";
|
|
21
|
+
|
|
22
|
+
import {
|
|
23
|
+
loadUiState, loadDesktopState, refreshPermissionsAsync, pushUiEvent, setRemoteAvailable,
|
|
24
|
+
handleSseEvents, handleStateGet, handleStatePost,
|
|
25
|
+
handleStop, handleStart, handleShutdown,
|
|
26
|
+
handleConnections, handleDesktopToggle,
|
|
27
|
+
handlePermissionsGet, handlePermissionsRequest,
|
|
28
|
+
} from "./api/ui.js";
|
|
29
|
+
import { handleOneTimeKey, handleRegenerate } from "./api/key.js";
|
|
30
|
+
import { handleApprove, handleReject, handlePending, handleApproved, handleRemove, handleDisconnect, handleRejected, handleApproveRejected, handleClearRejected } from "./api/device.js";
|
|
31
|
+
import { handleNotifyPost, handleNotifyGet } from "./api/notify.js";
|
|
23
32
|
|
|
24
33
|
const __dirname = fileURLToPath(new URL(".", import.meta.url));
|
|
25
34
|
const IS_DEV = process.env.NODE_ENV === "development";
|
|
26
35
|
const VITE_PORT = 5173;
|
|
36
|
+
const NPM_REGISTRY_URL = "https://registry.npmjs.org/9remote/latest";
|
|
27
37
|
|
|
28
|
-
// UI dist: bundled → cli/dist/ui/, dev → server/ui/dist/
|
|
29
38
|
const UI_DIST = existsSync(join(__dirname, "ui", "dist"))
|
|
30
39
|
? join(__dirname, "ui", "dist")
|
|
31
40
|
: join(__dirname, "ui");
|
|
32
41
|
|
|
33
42
|
const MIME_TYPES = {
|
|
34
|
-
".html": "text/html",
|
|
35
|
-
".
|
|
36
|
-
".
|
|
37
|
-
".svg": "image/svg+xml",
|
|
38
|
-
".png": "image/png",
|
|
39
|
-
".ico": "image/x-icon",
|
|
40
|
-
".woff2": "font/woff2",
|
|
41
|
-
".woff": "font/woff",
|
|
43
|
+
".html": "text/html", ".js": "application/javascript", ".css": "text/css",
|
|
44
|
+
".svg": "image/svg+xml", ".png": "image/png", ".ico": "image/x-icon",
|
|
45
|
+
".woff2": "font/woff2", ".woff": "font/woff",
|
|
42
46
|
};
|
|
43
47
|
|
|
44
|
-
|
|
45
|
-
const NPM_REGISTRY_URL = `https://registry.npmjs.org/${NPM_PACKAGE_NAME}/latest`;
|
|
48
|
+
// ── Static / Dev helpers ─────────────────────────────────────────
|
|
46
49
|
|
|
47
|
-
|
|
48
|
-
function proxyToVite(req, res) {
|
|
50
|
+
function proxyToVite(req, res, fallbackFn) {
|
|
49
51
|
const proxy = httpRequest(
|
|
50
52
|
{ hostname: "localhost", port: VITE_PORT, path: req.url, method: req.method, headers: req.headers },
|
|
51
|
-
(proxyRes) => {
|
|
52
|
-
res.writeHead(proxyRes.statusCode, proxyRes.headers);
|
|
53
|
-
proxyRes.pipe(res);
|
|
54
|
-
}
|
|
53
|
+
(proxyRes) => { res.writeHead(proxyRes.statusCode, proxyRes.headers); proxyRes.pipe(res); }
|
|
55
54
|
);
|
|
56
|
-
proxy.on("error", () => {
|
|
57
|
-
res.writeHead(502);
|
|
58
|
-
res.end("Vite dev server not ready yet, please wait...");
|
|
59
|
-
});
|
|
55
|
+
proxy.on("error", () => fallbackFn ? fallbackFn() : (() => { res.writeHead(502); res.end("Vite dev server not ready"); })());
|
|
60
56
|
req.pipe(proxy);
|
|
61
57
|
}
|
|
62
58
|
|
|
63
|
-
/** Serve a static file from ui/dist */
|
|
64
59
|
function serveStatic(res, filePath) {
|
|
65
60
|
if (!existsSync(filePath)) return false;
|
|
66
|
-
const
|
|
67
|
-
const mime = MIME_TYPES[ext] || "application/octet-stream";
|
|
61
|
+
const mime = MIME_TYPES[extname(filePath)] || "application/octet-stream";
|
|
68
62
|
res.setHeader("Content-Type", mime);
|
|
69
63
|
res.writeHead(200);
|
|
70
64
|
res.end(readFileSync(filePath));
|
|
71
65
|
return true;
|
|
72
66
|
}
|
|
73
67
|
|
|
74
|
-
/** Check npm registry for newer version, push via SSE if found */
|
|
75
68
|
async function checkForUpdate(currentVersion) {
|
|
76
69
|
try {
|
|
77
|
-
const res = await
|
|
70
|
+
const res = await browserFetch(NPM_REGISTRY_URL);
|
|
78
71
|
if (!res.ok) return;
|
|
79
72
|
const { version } = await res.json();
|
|
80
|
-
if (version && version
|
|
81
|
-
pushUiEvent("updateAvailable", { version });
|
|
82
|
-
}
|
|
73
|
+
if (version && isNewerVersion(currentVersion, version)) pushUiEvent("updateAvailable", { version });
|
|
83
74
|
} catch { /* non-critical */ }
|
|
84
75
|
}
|
|
85
76
|
|
|
86
|
-
// ──
|
|
87
|
-
|
|
88
|
-
const UI_STATE_FILE = join(
|
|
89
|
-
process.env.HOME || process.env.USERPROFILE || ".",
|
|
90
|
-
".9remote", "ui-state.json"
|
|
91
|
-
);
|
|
92
|
-
|
|
93
|
-
/** Persist uiState to disk */
|
|
94
|
-
function saveUiState() {
|
|
95
|
-
try {
|
|
96
|
-
mkdirSync(join(process.env.HOME || process.env.USERPROFILE || ".", ".9remote"), { recursive: true });
|
|
97
|
-
writeFileSync(UI_STATE_FILE, JSON.stringify(uiState));
|
|
98
|
-
} catch {}
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
/** Load uiState from disk */
|
|
102
|
-
function loadUiState() {
|
|
103
|
-
try {
|
|
104
|
-
if (existsSync(UI_STATE_FILE)) {
|
|
105
|
-
const saved = JSON.parse(readFileSync(UI_STATE_FILE, "utf8"));
|
|
106
|
-
// Only restore if tunnel was ready — otherwise start fresh
|
|
107
|
-
if (saved.step === 4 && saved.permanentKey) {
|
|
108
|
-
uiState = { ...uiState, ...saved };
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
} catch {}
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
// In-memory UI state + SSE clients
|
|
115
|
-
let uiState = { step: 0, tunnelUrl: "", oneTimeKey: "", oneTimeKeyExpiresAt: null, permanentKey: "", qrUrl: "", latency: null, uptime: null };
|
|
116
|
-
const sseClients = new Set();
|
|
117
|
-
|
|
118
|
-
// Active socket connections for UI display
|
|
119
|
-
const activeConnections = new Map();
|
|
120
|
-
|
|
121
|
-
// Remote desktop toggle state — loaded from state file on start
|
|
122
|
-
let desktopEnabled = false;
|
|
123
|
-
|
|
124
|
-
const DESKTOP_STATE_FILE = join(
|
|
125
|
-
process.env.HOME || process.env.USERPROFILE || ".",
|
|
126
|
-
".9remote", "desktop.json"
|
|
127
|
-
);
|
|
128
|
-
|
|
129
|
-
/** Load desktopEnabled from state file */
|
|
130
|
-
function loadDesktopState() {
|
|
131
|
-
try {
|
|
132
|
-
if (existsSync(DESKTOP_STATE_FILE)) {
|
|
133
|
-
const data = JSON.parse(readFileSync(DESKTOP_STATE_FILE, "utf8"));
|
|
134
|
-
desktopEnabled = !!data.enabled;
|
|
135
|
-
}
|
|
136
|
-
} catch {}
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
/** Save desktopEnabled to state file */
|
|
140
|
-
function saveDesktopState() {
|
|
141
|
-
try {
|
|
142
|
-
mkdirSync(join(process.env.HOME || process.env.USERPROFILE || ".", ".9remote"), { recursive: true });
|
|
143
|
-
writeFileSync(DESKTOP_STATE_FILE, JSON.stringify({ enabled: desktopEnabled }));
|
|
144
|
-
} catch {}
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
// Cached permissions — refreshed async, never blocks request
|
|
148
|
-
let cachedPermissions = { screenRecording: false, accessibility: false };
|
|
149
|
-
|
|
150
|
-
function refreshPermissionsAsync() {
|
|
151
|
-
checkPermissions().then((p) => { cachedPermissions = p; });
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
function getSystemPermissions() {
|
|
155
|
-
return cachedPermissions;
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
/** Open System Preferences pane for a permission, then poll until granted */
|
|
159
|
-
function requestSystemPermission(type) {
|
|
160
|
-
return new Promise((resolve) => {
|
|
161
|
-
if (process.platform !== "darwin") { resolve(); return; }
|
|
162
|
-
openPermissionPane(type);
|
|
163
|
-
resolve(); // resolve immediately — UI stays open
|
|
164
|
-
|
|
165
|
-
// Poll every 2s for up to 60s to detect when user grants permission
|
|
166
|
-
let attempts = 0;
|
|
167
|
-
const poll = setInterval(() => {
|
|
168
|
-
attempts++;
|
|
169
|
-
checkPermissions().then((p) => {
|
|
170
|
-
cachedPermissions = p;
|
|
171
|
-
if (p[type] || attempts >= 30) {
|
|
172
|
-
clearInterval(poll);
|
|
173
|
-
pushUiEvent("permissions", { ...cachedPermissions, desktopEnabled });
|
|
174
|
-
}
|
|
175
|
-
});
|
|
176
|
-
}, 2000);
|
|
177
|
-
});
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
/** Clear one-time key from UI state after client uses it */
|
|
181
|
-
export function clearOneTimeKey() {
|
|
182
|
-
uiState = { ...uiState, oneTimeKey: "", oneTimeKeyExpiresAt: null, qrUrl: "" };
|
|
183
|
-
pushUiEvent("state", uiState);
|
|
184
|
-
saveUiState();
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
/** Track a new socket connection */
|
|
188
|
-
export function trackConnection(socketId, ip, type = "ws") {
|
|
189
|
-
activeConnections.set(socketId, { ip, type, connectedAt: Date.now() });
|
|
190
|
-
pushUiEvent("connections", { connections: [...activeConnections.values()] });
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
/** Remove a socket connection */
|
|
194
|
-
export function untrackConnection(socketId) {
|
|
195
|
-
activeConnections.delete(socketId);
|
|
196
|
-
pushUiEvent("connections", { connections: [...activeConnections.values()] });
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
/** Push a log line to UI */
|
|
200
|
-
export function pushUiLog(message) {
|
|
201
|
-
pushUiEvent("log", { message: `[${new Date().toLocaleTimeString()}] ${message}` });
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
/** Push event to all connected UI SSE clients */
|
|
205
|
-
function pushUiEvent(type, data) {
|
|
206
|
-
const payload = `data: ${JSON.stringify({ type, ...data })}\n\n`;
|
|
207
|
-
for (const res of sseClients) {
|
|
208
|
-
try { res.write(payload); } catch { sseClients.delete(res); }
|
|
209
|
-
}
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
/** Handle SSE connection from UI */
|
|
213
|
-
function handleUiEvents(req, res) {
|
|
214
|
-
res.setHeader("Content-Type", "text/event-stream");
|
|
215
|
-
res.setHeader("Cache-Control", "no-cache");
|
|
216
|
-
res.setHeader("Connection", "keep-alive");
|
|
217
|
-
res.writeHead(200);
|
|
77
|
+
// ── Codespace handler ────────────────────────────────────────
|
|
218
78
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
/** Handle UI state update from CLI */
|
|
229
|
-
function handleUiState(body) {
|
|
230
|
-
try {
|
|
231
|
-
const data = JSON.parse(body || "{}");
|
|
232
|
-
uiState = { ...uiState, ...data };
|
|
233
|
-
pushUiEvent("state", uiState);
|
|
234
|
-
saveUiState();
|
|
235
|
-
} catch { /* ignore malformed */ }
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
const ORANGE = chalk.rgb(230, 138, 110);
|
|
239
|
-
|
|
240
|
-
function isCodespaces() {
|
|
241
|
-
return process.env.CODESPACES === "true";
|
|
79
|
+
function handleCodespaceStop(req, res) {
|
|
80
|
+
if (process.env.CODESPACES !== "true") { jsonErr(res, 400, "Not running on Codespaces"); return; }
|
|
81
|
+
const name = process.env.CODESPACE_NAME;
|
|
82
|
+
if (!name) { jsonErr(res, 400, "Codespace name not found"); return; }
|
|
83
|
+
const io = getIO();
|
|
84
|
+
if (io) io.emit("codespace:stopping");
|
|
85
|
+
jsonOk(res, { success: true, message: "Stopping codespace..." });
|
|
86
|
+
setTimeout(() => exec(`gh codespace stop -c ${name}`, { windowsHide: true }), 500);
|
|
242
87
|
}
|
|
243
88
|
|
|
244
|
-
|
|
245
|
-
if (req.method !== "POST") {
|
|
246
|
-
res.writeHead(405);
|
|
247
|
-
res.end(JSON.stringify({ error: "Method not allowed" }));
|
|
248
|
-
return;
|
|
249
|
-
}
|
|
89
|
+
// ── Proxy handlers ────────────────────────────────────────────
|
|
250
90
|
|
|
251
|
-
|
|
252
|
-
res.writeHead(400);
|
|
253
|
-
res.end(JSON.stringify({ error: "Not running on Codespaces" }));
|
|
254
|
-
return;
|
|
255
|
-
}
|
|
91
|
+
let proxyServer;
|
|
256
92
|
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
93
|
+
async function handleProxyStartEnd(req, res, { pathname }) {
|
|
94
|
+
// Verify API key (required for tunnel access)
|
|
95
|
+
const authHeader = req.headers.authorization;
|
|
96
|
+
if (!authHeader || !authHeader.startsWith("Bearer ") || !verifyApiKeyCrc(authHeader.slice(7))) {
|
|
97
|
+
jsonErr(res, 401, "Unauthorized");
|
|
261
98
|
return;
|
|
262
99
|
}
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
if (
|
|
267
|
-
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
res.setHeader("Content-Type", "application/json");
|
|
271
|
-
res.writeHead(200);
|
|
272
|
-
res.end(JSON.stringify({ success: true, message: "Stopping codespace..." }));
|
|
273
|
-
|
|
274
|
-
setTimeout(() => {
|
|
275
|
-
exec(`gh codespace stop -c ${codespaceName}`, (error) => {
|
|
276
|
-
if (error) {
|
|
277
|
-
console.error("Failed to stop codespace:", error);
|
|
278
|
-
}
|
|
279
|
-
});
|
|
280
|
-
}, 500);
|
|
100
|
+
const { parseJsonBody } = await import("./lib/router.js");
|
|
101
|
+
const data = await parseJsonBody(req, res);
|
|
102
|
+
if (!data) return;
|
|
103
|
+
if (!data.port) { jsonErr(res, 400, "Port required"); return; }
|
|
104
|
+
pathname.endsWith("start") ? startProxySession(data.port) : endProxySession(data.port);
|
|
105
|
+
jsonOk(res, { success: true });
|
|
281
106
|
}
|
|
282
107
|
|
|
283
|
-
|
|
284
|
-
const
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
const pushLastTime = {};
|
|
288
|
-
const PUSH_RATE_LIMIT_MS = 10000;
|
|
289
|
-
|
|
290
|
-
function handleNotify(req, res, body, query) {
|
|
291
|
-
const now = Date.now();
|
|
292
|
-
|
|
293
|
-
let type, sessionId, tool;
|
|
294
|
-
|
|
295
|
-
if (query) {
|
|
296
|
-
type = query.type || "stop";
|
|
297
|
-
sessionId = query.sessionId || "";
|
|
298
|
-
tool = query.tool || "claude";
|
|
108
|
+
function handleProxy(req, res, { pathname, search }) {
|
|
109
|
+
const match = pathname.match(/^\/proxy\/(\d+)(\/.*)?$/);
|
|
110
|
+
if (match) {
|
|
111
|
+
handleProxyRequest(proxyServer, req, res, match[1], match[2] || "/", search);
|
|
299
112
|
} else {
|
|
300
|
-
|
|
301
|
-
const data = JSON.parse(body || "{}");
|
|
302
|
-
type = data.type || "stop";
|
|
303
|
-
sessionId = data.sessionId || "";
|
|
304
|
-
tool = data.tool || "claude";
|
|
305
|
-
} catch {
|
|
306
|
-
res.writeHead(400);
|
|
307
|
-
res.end(JSON.stringify({ error: "Invalid JSON" }));
|
|
308
|
-
return;
|
|
309
|
-
}
|
|
113
|
+
jsonErr(res, 404, "Not found");
|
|
310
114
|
}
|
|
115
|
+
}
|
|
311
116
|
|
|
117
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
118
|
+
// ROUTE TABLE — single source of truth for all HTTP endpoints
|
|
119
|
+
// public: true = accessible via tunnel, false/omit = localhost-only
|
|
120
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
121
|
+
|
|
122
|
+
const ROUTES = [
|
|
123
|
+
// Public routes (accessible via tunnel)
|
|
124
|
+
{ path: "/api/health", method: "GET", public: true, handler: (req, res) => jsonOk(res, { status: "ok", timestamp: Date.now() }) },
|
|
125
|
+
{ path: "/api/version", method: "GET", public: true, handler: (req, res) => {
|
|
126
|
+
const version = typeof __CLI_VERSION__ !== "undefined"
|
|
127
|
+
? __CLI_VERSION__
|
|
128
|
+
: JSON.parse(readFileSync(join(__dirname, "..", "package.json"), "utf8")).version;
|
|
129
|
+
jsonOk(res, { version });
|
|
130
|
+
}},
|
|
131
|
+
{ path: "/api/notify", method: "POST", public: true, handler: handleNotifyPost },
|
|
132
|
+
{ path: "/api/notify", method: "GET", public: true, handler: handleNotifyGet },
|
|
133
|
+
{ path: "/proxy/*", method: "*", public: true, handler: handleProxy },
|
|
134
|
+
|
|
135
|
+
// UI state & SSE (localhost-only)
|
|
136
|
+
{ path: "/api/ui/events", method: "GET", handler: handleSseEvents },
|
|
137
|
+
{ path: "/api/ui/state", method: "GET", handler: handleStateGet },
|
|
138
|
+
{ path: "/api/ui/state", method: "POST", handler: handleStatePost },
|
|
139
|
+
{ path: "/api/ui/stop", method: "POST", handler: handleStop },
|
|
140
|
+
{ path: "/api/ui/start", method: "POST", handler: handleStart },
|
|
141
|
+
{ path: "/api/ui/shutdown", method: "POST", handler: handleShutdown },
|
|
142
|
+
|
|
143
|
+
// Key management (localhost-only)
|
|
144
|
+
{ path: "/api/key/one-time", method: "POST", handler: handleOneTimeKey },
|
|
145
|
+
{ path: "/api/key/regenerate", method: "POST", handler: handleRegenerate },
|
|
146
|
+
|
|
147
|
+
// Device approval (localhost-only)
|
|
148
|
+
{ path: "/api/device/approve", method: "POST", handler: handleApprove },
|
|
149
|
+
{ path: "/api/device/reject", method: "POST", handler: handleReject },
|
|
150
|
+
{ path: "/api/device/pending", method: "GET", handler: handlePending },
|
|
151
|
+
{ path: "/api/device/approved", method: "GET", handler: handleApproved },
|
|
152
|
+
{ path: "/api/device/rejected", method: "GET", handler: handleRejected },
|
|
153
|
+
{ path: "/api/device/approve-rejected", method: "POST", handler: handleApproveRejected },
|
|
154
|
+
{ path: "/api/device/clear-rejected", method: "POST", handler: handleClearRejected },
|
|
155
|
+
{ path: "/api/device/remove", method: "POST", handler: handleRemove },
|
|
156
|
+
{ path: "/api/device/disconnect", method: "POST", handler: handleDisconnect },
|
|
157
|
+
|
|
158
|
+
// System (localhost-only)
|
|
159
|
+
{ path: "/api/connections", method: "GET", handler: handleConnections },
|
|
160
|
+
{ path: "/api/permissions", method: "GET", handler: handlePermissionsGet },
|
|
161
|
+
{ path: "/api/permissions/request", method: "POST", handler: handlePermissionsRequest },
|
|
162
|
+
{ path: "/api/desktop/toggle", method: "POST", handler: handleDesktopToggle },
|
|
163
|
+
{ path: "/api/local-sites", method: "*", public: true, handler: handleLocalSites },
|
|
164
|
+
{ path: "/api/codespace/stop", method: "POST", handler: handleCodespaceStop },
|
|
165
|
+
|
|
166
|
+
// Proxy session management (tunnel-accessible, requires API key)
|
|
167
|
+
{ path: "/api/proxy/start", method: "POST", public: true, handler: handleProxyStartEnd },
|
|
168
|
+
{ path: "/api/proxy/end", method: "POST", public: true, handler: handleProxyStartEnd },
|
|
169
|
+
];
|
|
170
|
+
|
|
171
|
+
// ── Server bootstrap ───────────────────────────────────────────────────────
|
|
312
172
|
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
const notification = { type, sessionId, tool, timestamp: now };
|
|
316
|
-
|
|
317
|
-
if (sessionId)
|
|
318
|
-
|
|
319
|
-
// Always persist badge and notify all clients (no throttle)
|
|
320
|
-
if (sessionId) {
|
|
321
|
-
addNotification(sessionId, notification);
|
|
322
|
-
io.emit("chatNotification", notification);
|
|
323
|
-
|
|
324
|
-
// PWA push: throttle per tool+type to avoid spam
|
|
325
|
-
const pushKey = `${tool}:${type}`;
|
|
326
|
-
const pushThrottled = now - (pushLastTime[pushKey] || 0) < PUSH_RATE_LIMIT_MS;
|
|
327
|
-
if (!pushThrottled) {
|
|
328
|
-
pushLastTime[pushKey] = now;
|
|
329
|
-
io.timeout(5000).emit("chatNotificationAck", notification, (err, responses) => {
|
|
330
|
-
const connectedCount = io.sockets.sockets.size;
|
|
331
|
-
const hasFocused = !err && responses && responses.length > 0;
|
|
332
|
-
if (!hasFocused && connectedCount === 0) sendPushNotification(notification);
|
|
333
|
-
});
|
|
334
|
-
}
|
|
335
|
-
}
|
|
336
|
-
}
|
|
173
|
+
const hostname = "localhost";
|
|
174
|
+
const port = parseInt(process.env.PORT || "2208", 10);
|
|
337
175
|
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
176
|
+
// Auto-start Vite dev server in dev mode
|
|
177
|
+
let viteProcess = null;
|
|
178
|
+
function startViteDev() {
|
|
179
|
+
if (!IS_DEV) return;
|
|
180
|
+
const viteConfigPath = join(__dirname, "vite.config.js");
|
|
181
|
+
if (!existsSync(viteConfigPath)) return;
|
|
182
|
+
const viteBin = existsSync(join(__dirname, "node_modules", ".bin", "vite"))
|
|
183
|
+
? join(__dirname, "node_modules", ".bin", "vite")
|
|
184
|
+
: join(__dirname, "..", "node_modules", ".bin", "vite");
|
|
185
|
+
viteProcess = spawn("node", [viteBin, "--config", viteConfigPath], {
|
|
186
|
+
cwd: __dirname,
|
|
187
|
+
stdio: "ignore",
|
|
188
|
+
detached: false,
|
|
189
|
+
});
|
|
190
|
+
viteProcess.on("error", () => {});
|
|
191
|
+
viteProcess.unref();
|
|
341
192
|
}
|
|
342
193
|
|
|
343
194
|
export async function startServer() {
|
|
344
|
-
// Initialize terminal sessions
|
|
345
195
|
await initializeTerminal();
|
|
196
|
+
proxyServer = createProxyServer();
|
|
197
|
+
startViteDev();
|
|
346
198
|
|
|
347
|
-
|
|
199
|
+
// Static file / Vite dev fallback (localhost-only)
|
|
200
|
+
const staticFallback = (req, res, { pathname }) => {
|
|
201
|
+
if (pathname.startsWith("/api/") || pathname.startsWith("/socket.io")) {
|
|
202
|
+
jsonErr(res, 404, "Not found");
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
const isTunnel = !!req.headers["cf-connecting-ip"];
|
|
206
|
+
const isLocal = req.socket.remoteAddress === "127.0.0.1" || req.socket.remoteAddress === "::1";
|
|
207
|
+
if (isTunnel || !isLocal) { jsonErr(res, 403, "Forbidden"); return; }
|
|
208
|
+
const serveStaticFiles = () => {
|
|
209
|
+
const filePath = (pathname === "/" || pathname === "") ? join(UI_DIST, "index.html") : join(UI_DIST, pathname);
|
|
210
|
+
if (serveStatic(res, filePath)) return;
|
|
211
|
+
serveStatic(res, join(UI_DIST, "index.html"));
|
|
212
|
+
};
|
|
213
|
+
if (IS_DEV) { proxyToVite(req, res, serveStaticFiles); return; }
|
|
214
|
+
serveStaticFiles();
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
const router = createRouter(ROUTES, { fallback: staticFallback });
|
|
348
218
|
|
|
349
219
|
const server = createServer(async (req, res) => {
|
|
350
220
|
setCorsHeaders(res);
|
|
351
|
-
|
|
352
221
|
if (handlePreflight(req, res)) return;
|
|
353
|
-
|
|
354
|
-
try {
|
|
355
|
-
const parsedUrl = parse(req.url, true);
|
|
356
|
-
const { pathname, search } = parsedUrl;
|
|
357
|
-
|
|
358
|
-
// UI routes: only accessible from localhost (not via tunnel)
|
|
359
|
-
const isLocalhost = req.socket.remoteAddress === "127.0.0.1" || req.socket.remoteAddress === "::1";
|
|
360
|
-
const isUiRoute = pathname === "/api/ui/events" || pathname === "/api/ui/state"
|
|
361
|
-
|| (!pathname.startsWith("/api/") && !pathname.startsWith("/proxy/") && !pathname.startsWith("/socket.io"));
|
|
362
|
-
if (isUiRoute && !isLocalhost) {
|
|
363
|
-
res.writeHead(403);
|
|
364
|
-
res.end(JSON.stringify({ error: "forbidden" }));
|
|
365
|
-
return;
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
// UI SSE stream
|
|
369
|
-
if (pathname === "/api/ui/events") {
|
|
370
|
-
handleUiEvents(req, res);
|
|
371
|
-
return;
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
// UI state update from CLI
|
|
375
|
-
if (pathname === "/api/ui/state" && req.method === "POST") {
|
|
376
|
-
let body = "";
|
|
377
|
-
req.on("data", chunk => body += chunk);
|
|
378
|
-
req.on("end", () => {
|
|
379
|
-
handleUiState(body);
|
|
380
|
-
res.setHeader("Content-Type", "application/json");
|
|
381
|
-
res.writeHead(200);
|
|
382
|
-
res.end(JSON.stringify({ ok: true }));
|
|
383
|
-
});
|
|
384
|
-
return;
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
// UI state snapshot
|
|
388
|
-
if (pathname === "/api/ui/state" && req.method === "GET") {
|
|
389
|
-
res.setHeader("Content-Type", "application/json");
|
|
390
|
-
res.writeHead(200);
|
|
391
|
-
// Include permissions + desktopEnabled so UI only needs 1 fetch
|
|
392
|
-
res.end(JSON.stringify({
|
|
393
|
-
...uiState,
|
|
394
|
-
...getSystemPermissions(),
|
|
395
|
-
desktopEnabled,
|
|
396
|
-
}));
|
|
397
|
-
return;
|
|
398
|
-
}
|
|
399
|
-
|
|
400
|
-
// Stop tunnel (disconnect), keep server alive
|
|
401
|
-
if (pathname === "/api/ui/stop" && req.method === "POST") {
|
|
402
|
-
res.setHeader("Content-Type", "application/json");
|
|
403
|
-
res.writeHead(200);
|
|
404
|
-
res.end(JSON.stringify({ ok: true }));
|
|
405
|
-
// Immediately reset state so UI transitions to welcome screen
|
|
406
|
-
uiState = { ...uiState, step: 0, tunnelUrl: "", oneTimeKey: "", oneTimeKeyExpiresAt: null };
|
|
407
|
-
pushUiEvent("state", uiState);
|
|
408
|
-
saveUiState();
|
|
409
|
-
writeCmd("stop-tunnel");
|
|
410
|
-
return;
|
|
411
|
-
}
|
|
412
|
-
|
|
413
|
-
// Start tunnel (reconnect)
|
|
414
|
-
if (pathname === "/api/ui/start" && req.method === "POST") {
|
|
415
|
-
res.setHeader("Content-Type", "application/json");
|
|
416
|
-
res.writeHead(200);
|
|
417
|
-
res.end(JSON.stringify({ ok: true }));
|
|
418
|
-
// Immediately push step:1 so UI transitions to progress screen
|
|
419
|
-
uiState = { ...uiState, step: 1 };
|
|
420
|
-
pushUiEvent("state", uiState);
|
|
421
|
-
saveUiState();
|
|
422
|
-
writeCmd("start-tunnel");
|
|
423
|
-
return;
|
|
424
|
-
}
|
|
425
|
-
|
|
426
|
-
// Generate new one-time key
|
|
427
|
-
if (pathname === "/api/key/one-time" && req.method === "POST") {
|
|
428
|
-
const workerUrl = uiState.workerUrl || "https://9remote.cc";
|
|
429
|
-
const permanentKey = uiState.permanentKey;
|
|
430
|
-
if (!permanentKey) {
|
|
431
|
-
res.writeHead(400);
|
|
432
|
-
res.end(JSON.stringify({ error: "No permanent key set" }));
|
|
433
|
-
return;
|
|
434
|
-
}
|
|
435
|
-
try {
|
|
436
|
-
const r = await fetch(`${workerUrl}/api/temp-key/create`, {
|
|
437
|
-
method: "POST",
|
|
438
|
-
headers: { "Content-Type": "application/json" },
|
|
439
|
-
body: JSON.stringify({ apiKey: permanentKey, expiryMinutes: 30 }),
|
|
440
|
-
});
|
|
441
|
-
const data = await r.json();
|
|
442
|
-
const qrUrl = `${workerUrl}/login?k=${data.tempKey}`;
|
|
443
|
-
uiState = { ...uiState, oneTimeKey: data.tempKey, oneTimeKeyExpiresAt: data.expiresAt, qrUrl };
|
|
444
|
-
pushUiEvent("state", uiState);
|
|
445
|
-
res.setHeader("Content-Type", "application/json");
|
|
446
|
-
res.writeHead(200);
|
|
447
|
-
res.end(JSON.stringify({ oneTimeKey: data.tempKey, expiresAt: data.expiresAt, qrUrl }));
|
|
448
|
-
} catch (err) {
|
|
449
|
-
res.writeHead(500);
|
|
450
|
-
res.end(JSON.stringify({ error: err.message }));
|
|
451
|
-
}
|
|
452
|
-
return;
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
// Regenerate permanent key
|
|
456
|
-
if (pathname === "/api/key/regenerate" && req.method === "POST") {
|
|
457
|
-
res.setHeader("Content-Type", "application/json");
|
|
458
|
-
try {
|
|
459
|
-
const machineId = await getConsistentMachineId();
|
|
460
|
-
const { key } = generateApiKeyWithMachine(machineId);
|
|
461
|
-
const existing = loadKey();
|
|
462
|
-
saveKey(machineId, key, existing?.name || "Default");
|
|
463
|
-
pushUiEvent("state", { ...uiState, permanentKey: key });
|
|
464
|
-
uiState = { ...uiState, permanentKey: key };
|
|
465
|
-
res.writeHead(200);
|
|
466
|
-
res.end(JSON.stringify({ ok: true, permanentKey: key }));
|
|
467
|
-
} catch (err) {
|
|
468
|
-
res.writeHead(500);
|
|
469
|
-
res.end(JSON.stringify({ error: err.message }));
|
|
470
|
-
}
|
|
471
|
-
return;
|
|
472
|
-
}
|
|
473
|
-
|
|
474
|
-
// Get system permissions status
|
|
475
|
-
if (pathname === "/api/permissions" && req.method === "GET") {
|
|
476
|
-
res.setHeader("Content-Type", "application/json");
|
|
477
|
-
res.writeHead(200);
|
|
478
|
-
res.end(JSON.stringify(getSystemPermissions()));
|
|
479
|
-
return;
|
|
480
|
-
}
|
|
481
|
-
|
|
482
|
-
// Request a system permission
|
|
483
|
-
if (pathname === "/api/permissions/request" && req.method === "POST") {
|
|
484
|
-
let body = "";
|
|
485
|
-
req.on("data", (chunk) => body += chunk);
|
|
486
|
-
req.on("end", async () => {
|
|
487
|
-
try {
|
|
488
|
-
const { type } = JSON.parse(body || "{}");
|
|
489
|
-
await requestSystemPermission(type);
|
|
490
|
-
res.setHeader("Content-Type", "application/json");
|
|
491
|
-
res.writeHead(200);
|
|
492
|
-
res.end(JSON.stringify({ ok: true }));
|
|
493
|
-
} catch (err) {
|
|
494
|
-
res.writeHead(500);
|
|
495
|
-
res.end(JSON.stringify({ error: err.message }));
|
|
496
|
-
}
|
|
497
|
-
});
|
|
498
|
-
return;
|
|
499
|
-
}
|
|
500
|
-
|
|
501
|
-
// Toggle remote desktop
|
|
502
|
-
if (pathname === "/api/desktop/toggle" && req.method === "POST") {
|
|
503
|
-
let body = "";
|
|
504
|
-
req.on("data", (chunk) => body += chunk);
|
|
505
|
-
req.on("end", () => {
|
|
506
|
-
try {
|
|
507
|
-
const { enabled } = JSON.parse(body || "{}");
|
|
508
|
-
desktopEnabled = !!enabled;
|
|
509
|
-
saveDesktopState();
|
|
510
|
-
// Push updated state including desktopEnabled + fresh permissions
|
|
511
|
-
pushUiEvent("permissions", { ...getSystemPermissions(), desktopEnabled });
|
|
512
|
-
res.setHeader("Content-Type", "application/json");
|
|
513
|
-
res.writeHead(200);
|
|
514
|
-
res.end(JSON.stringify({ ok: true, enabled: desktopEnabled }));
|
|
515
|
-
} catch (err) {
|
|
516
|
-
res.writeHead(500);
|
|
517
|
-
res.end(JSON.stringify({ error: err.message }));
|
|
518
|
-
}
|
|
519
|
-
});
|
|
520
|
-
return;
|
|
521
|
-
}
|
|
522
|
-
|
|
523
|
-
// Get active connections
|
|
524
|
-
if (pathname === "/api/connections" && req.method === "GET") {
|
|
525
|
-
res.setHeader("Content-Type", "application/json");
|
|
526
|
-
res.writeHead(200);
|
|
527
|
-
res.end(JSON.stringify({ connections: [...activeConnections.values()] }));
|
|
528
|
-
return;
|
|
529
|
-
}
|
|
530
|
-
|
|
531
|
-
// Health check endpoint
|
|
532
|
-
if (pathname === "/api/health") {
|
|
533
|
-
res.setHeader("Content-Type", "application/json");
|
|
534
|
-
res.writeHead(200);
|
|
535
|
-
res.end(JSON.stringify({ status: "ok", timestamp: Date.now() }));
|
|
536
|
-
return;
|
|
537
|
-
}
|
|
538
|
-
|
|
539
|
-
// Version endpoint
|
|
540
|
-
if (pathname === "/api/version") {
|
|
541
|
-
const pkg = JSON.parse(readFileSync(join(__dirname, "..", "package.json"), "utf8"));
|
|
542
|
-
res.setHeader("Content-Type", "application/json");
|
|
543
|
-
res.writeHead(200);
|
|
544
|
-
res.end(JSON.stringify({ version: pkg.version }));
|
|
545
|
-
return;
|
|
546
|
-
}
|
|
547
|
-
|
|
548
|
-
// Serve UI: proxy to Vite (dev) or serve static (production)
|
|
549
|
-
if (!pathname.startsWith("/api/") && !pathname.startsWith("/proxy/") && !pathname.startsWith("/socket.io")) {
|
|
550
|
-
if (IS_DEV) {
|
|
551
|
-
proxyToVite(req, res);
|
|
552
|
-
return;
|
|
553
|
-
}
|
|
554
|
-
const filePath = pathname === "/" || pathname === ""
|
|
555
|
-
? join(UI_DIST, "index.html")
|
|
556
|
-
: join(UI_DIST, pathname);
|
|
557
|
-
if (serveStatic(res, filePath)) return;
|
|
558
|
-
// SPA fallback
|
|
559
|
-
if (serveStatic(res, join(UI_DIST, "index.html"))) return;
|
|
560
|
-
}
|
|
561
|
-
|
|
562
|
-
// API routes
|
|
563
|
-
if (pathname === "/api/local-sites") {
|
|
564
|
-
await handleLocalSites(req, res);
|
|
565
|
-
return;
|
|
566
|
-
}
|
|
567
|
-
|
|
568
|
-
// Codespace stop endpoint
|
|
569
|
-
if (pathname === "/api/codespace/stop") {
|
|
570
|
-
await handleCodespaceStop(req, res);
|
|
571
|
-
return;
|
|
572
|
-
}
|
|
573
|
-
|
|
574
|
-
// Notification endpoint (called by AI tool hooks)
|
|
575
|
-
if (pathname === "/api/notify" && req.method === "POST") {
|
|
576
|
-
let body = "";
|
|
577
|
-
req.on("data", chunk => body += chunk);
|
|
578
|
-
req.on("end", () => {
|
|
579
|
-
handleNotify(req, res, body);
|
|
580
|
-
});
|
|
581
|
-
return;
|
|
582
|
-
}
|
|
583
|
-
|
|
584
|
-
if (pathname === "/api/notify" && req.method === "GET") {
|
|
585
|
-
handleNotify(req, res, null, parsedUrl.query);
|
|
586
|
-
return;
|
|
587
|
-
}
|
|
588
|
-
|
|
589
|
-
// Proxy session management
|
|
590
|
-
if (pathname === "/api/proxy/start" && req.method === "POST") {
|
|
591
|
-
let body = "";
|
|
592
|
-
req.on("data", chunk => body += chunk);
|
|
593
|
-
req.on("end", () => {
|
|
594
|
-
const { port } = JSON.parse(body || "{}");
|
|
595
|
-
if (port) {
|
|
596
|
-
startProxySession(port);
|
|
597
|
-
res.setHeader("Content-Type", "application/json");
|
|
598
|
-
res.writeHead(200);
|
|
599
|
-
res.end(JSON.stringify({ success: true }));
|
|
600
|
-
} else {
|
|
601
|
-
res.writeHead(400);
|
|
602
|
-
res.end(JSON.stringify({ error: "Port required" }));
|
|
603
|
-
}
|
|
604
|
-
});
|
|
605
|
-
return;
|
|
606
|
-
}
|
|
607
|
-
|
|
608
|
-
if (pathname === "/api/proxy/end" && req.method === "POST") {
|
|
609
|
-
let body = "";
|
|
610
|
-
req.on("data", chunk => body += chunk);
|
|
611
|
-
req.on("end", () => {
|
|
612
|
-
const { port } = JSON.parse(body || "{}");
|
|
613
|
-
if (port) {
|
|
614
|
-
endProxySession(port);
|
|
615
|
-
res.setHeader("Content-Type", "application/json");
|
|
616
|
-
res.writeHead(200);
|
|
617
|
-
res.end(JSON.stringify({ success: true }));
|
|
618
|
-
} else {
|
|
619
|
-
res.writeHead(400);
|
|
620
|
-
res.end(JSON.stringify({ error: "Port required" }));
|
|
621
|
-
}
|
|
622
|
-
});
|
|
623
|
-
return;
|
|
624
|
-
}
|
|
625
|
-
|
|
626
|
-
// Proxy routes
|
|
627
|
-
if (pathname.startsWith("/proxy/")) {
|
|
628
|
-
const match = pathname.match(/^\/proxy\/(\d+)(\/.*)?$/);
|
|
629
|
-
if (match) {
|
|
630
|
-
handleProxyRequest(proxy, req, res, match[1], match[2] || "/", search);
|
|
631
|
-
return;
|
|
632
|
-
}
|
|
633
|
-
}
|
|
634
|
-
|
|
635
|
-
// 404 for unknown routes
|
|
636
|
-
res.writeHead(404);
|
|
637
|
-
res.end(JSON.stringify({ error: "Not found" }));
|
|
638
|
-
} catch (err) {
|
|
639
|
-
console.error("Error:", req.url, err);
|
|
640
|
-
res.statusCode = 500;
|
|
641
|
-
res.end("Internal server error");
|
|
642
|
-
}
|
|
222
|
+
await router(req, res);
|
|
643
223
|
});
|
|
644
224
|
|
|
645
|
-
// Load persisted states + warm up permission cache
|
|
646
225
|
loadUiState();
|
|
647
226
|
loadDesktopState();
|
|
648
227
|
refreshPermissionsAsync();
|
|
228
|
+
// macOS TCC has no change event — poll to detect permission revoke/grant
|
|
229
|
+
if (process.platform === "darwin") {
|
|
230
|
+
setInterval(refreshPermissionsAsync, PERMISSION_POLL_MS);
|
|
231
|
+
}
|
|
649
232
|
|
|
650
233
|
await setupSocketIO(server);
|
|
651
234
|
|
|
652
235
|
server.listen(port, (err) => {
|
|
653
236
|
if (err) throw err;
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
checkForUpdate(pkg.version);
|
|
237
|
+
const version = typeof __CLI_VERSION__ !== "undefined"
|
|
238
|
+
? __CLI_VERSION__
|
|
239
|
+
: JSON.parse(readFileSync(join(__dirname, "..", "package.json"), "utf8")).version;
|
|
240
|
+
checkForUpdate(version);
|
|
659
241
|
});
|
|
660
242
|
|
|
661
|
-
// Graceful shutdown
|
|
243
|
+
// Graceful shutdown
|
|
662
244
|
let isShuttingDown = false;
|
|
663
|
-
|
|
664
245
|
const gracefulShutdown = async (signal) => {
|
|
665
246
|
if (isShuttingDown) return;
|
|
666
247
|
isShuttingDown = true;
|
|
667
|
-
|
|
668
248
|
console.log(chalk.yellow(`\n🛑 Received ${signal}, shutting down gracefully...`));
|
|
669
|
-
|
|
670
|
-
// Set timeout to force exit if cleanup takes too long
|
|
671
|
-
const forceExitTimeout = setTimeout(() => {
|
|
672
|
-
console.log(chalk.red("⚠️ Forced exit after 5s timeout"));
|
|
673
|
-
process.exit(1);
|
|
674
|
-
}, 5000);
|
|
675
|
-
|
|
249
|
+
const forceExit = setTimeout(() => { console.log(chalk.red("⚠️ Forced exit")); process.exit(1); }, 5000);
|
|
676
250
|
try {
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
console.log(chalk.gray("✓ HTTP server closed"));
|
|
680
|
-
});
|
|
681
|
-
|
|
682
|
-
// 2. Close all Socket.IO connections
|
|
251
|
+
server.close(() => console.log(chalk.gray("✓ HTTP server closed")));
|
|
252
|
+
if (viteProcess) { try { viteProcess.kill(); } catch {} }
|
|
683
253
|
const io = getIO();
|
|
684
|
-
if (io) {
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
console.log(chalk.gray("✓ Socket.IO closed"));
|
|
688
|
-
});
|
|
689
|
-
}
|
|
690
|
-
|
|
691
|
-
// 3. Wait a bit for cleanup
|
|
692
|
-
await new Promise(resolve => setTimeout(resolve, 500));
|
|
693
|
-
|
|
694
|
-
clearTimeout(forceExitTimeout);
|
|
254
|
+
if (io) { io.emit("server:shutdown"); io.close(() => console.log(chalk.gray("✓ Socket.IO closed"))); }
|
|
255
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
256
|
+
clearTimeout(forceExit);
|
|
695
257
|
console.log(chalk.green("✅ Server stopped cleanly"));
|
|
696
258
|
process.exit(0);
|
|
697
259
|
} catch (error) {
|
|
698
260
|
console.error(chalk.red("❌ Error during shutdown:"), error);
|
|
699
|
-
clearTimeout(
|
|
261
|
+
clearTimeout(forceExit);
|
|
700
262
|
process.exit(1);
|
|
701
263
|
}
|
|
702
264
|
};
|
|
703
|
-
|
|
704
|
-
// Register signal handlers
|
|
265
|
+
|
|
705
266
|
process.on("SIGINT", () => gracefulShutdown("SIGINT"));
|
|
706
267
|
process.on("SIGTERM", () => gracefulShutdown("SIGTERM"));
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
if (process.platform === "win32") {
|
|
710
|
-
process.on("SIGBREAK", () => gracefulShutdown("SIGBREAK"));
|
|
711
|
-
}
|
|
712
|
-
|
|
268
|
+
if (process.platform === "win32") process.on("SIGBREAK", () => gracefulShutdown("SIGBREAK"));
|
|
269
|
+
|
|
713
270
|
return server;
|
|
714
271
|
}
|
|
715
272
|
|
|
716
|
-
// Auto start if run directly
|
|
717
273
|
startServer();
|