9remote 2.0.2 → 2.0.7
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/cli.cjs +1 -1
- package/dist/install.cjs +2 -0
- package/dist/ptyDaemon.cjs +1 -1
- package/dist/server.cjs +1 -1
- package/dist/ui/assets/{index-COWVKicT.css → index-BMHG73CL.css} +1 -1
- package/dist/ui/assets/index-Bg86Demx.js +8 -0
- package/dist/ui/index.html +2 -2
- package/package.json +4 -6
- package/cli/index.js +0 -1330
- package/cli/scripts/install.js +0 -19
- package/cli/utils/apiKey.js +0 -77
- package/cli/utils/assets/trayIcon.ico +0 -0
- package/cli/utils/cloudflared.js +0 -493
- package/cli/utils/machineId.js +0 -22
- package/cli/utils/permissions.js +0 -45
- package/cli/utils/pids.js +0 -114
- package/cli/utils/state.js +0 -115
- package/cli/utils/token.js +0 -32
- package/cli/utils/tray.js +0 -251
- package/cli/utils/tui.js +0 -445
- package/cli/utils/updateChecker.js +0 -358
- package/dist/ui/assets/index-BWfJSBGG.js +0 -8
- package/index.js +0 -275
- package/lib/constants.js +0 -64
- package/lib/deviceApproval.js +0 -152
- package/lib/router.js +0 -134
- package/lib/socketio.js +0 -240
package/cli/utils/tui.js
DELETED
|
@@ -1,445 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Terminal UI utilities — native ESM, no external deps
|
|
3
|
-
* Provides: selectMenu(), renderProgress(), showBanner(), confirm()
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
import readline from "readline";
|
|
7
|
-
import http from "http";
|
|
8
|
-
import { openPermissionPane } from "./permissions.js";
|
|
9
|
-
|
|
10
|
-
export { openPermissionPane };
|
|
11
|
-
|
|
12
|
-
// Brand color: orange #E68A6E
|
|
13
|
-
const C = {
|
|
14
|
-
reset: "\x1b[0m",
|
|
15
|
-
bold: "\x1b[1m",
|
|
16
|
-
dim: "\x1b[2m",
|
|
17
|
-
orange: "\x1b[38;2;230;138;110m",
|
|
18
|
-
green: "\x1b[32m",
|
|
19
|
-
red: "\x1b[31m",
|
|
20
|
-
yellow: "\x1b[33m",
|
|
21
|
-
cyan: "\x1b[36m",
|
|
22
|
-
white: "\x1b[37m",
|
|
23
|
-
};
|
|
24
|
-
|
|
25
|
-
const W = () => Math.min(44, process.stdout.columns || 44);
|
|
26
|
-
|
|
27
|
-
// ── Banner ────────────────────────────────────────────────────────────────────
|
|
28
|
-
|
|
29
|
-
export function getBannerText(currentVersion, latestVersion = null) {
|
|
30
|
-
const w = W();
|
|
31
|
-
const inner = w - 2;
|
|
32
|
-
|
|
33
|
-
const line = (text = "") => {
|
|
34
|
-
const plain = text.replace(/\x1b\[[0-9;]*m/g, "");
|
|
35
|
-
const pad = Math.max(0, inner - plain.length);
|
|
36
|
-
return C.orange + "║" + C.reset + text + " ".repeat(pad) + C.orange + "║" + C.reset;
|
|
37
|
-
};
|
|
38
|
-
|
|
39
|
-
const center = (text, colorFn = (s) => s) => {
|
|
40
|
-
const plain = text.replace(/\x1b\[[0-9;]*m/g, "");
|
|
41
|
-
const lp = Math.floor((inner - plain.length) / 2);
|
|
42
|
-
const rp = inner - plain.length - lp;
|
|
43
|
-
return C.orange + "║" + C.reset + " ".repeat(lp) + colorFn(text) + " ".repeat(rp) + C.orange + "║" + C.reset;
|
|
44
|
-
};
|
|
45
|
-
|
|
46
|
-
const lines = [
|
|
47
|
-
"",
|
|
48
|
-
C.orange + "╔" + "═".repeat(inner) + "╗" + C.reset,
|
|
49
|
-
line(),
|
|
50
|
-
center(`🚀 9Remote v${currentVersion}`, (s) => C.bold + C.orange + s + C.reset),
|
|
51
|
-
center("Remote terminal access from anywhere", (s) => C.dim + s + C.reset),
|
|
52
|
-
line(),
|
|
53
|
-
];
|
|
54
|
-
|
|
55
|
-
if (latestVersion) {
|
|
56
|
-
lines.push(center(`⬆ New version v${latestVersion} available!`, (s) => C.yellow + C.bold + s + C.reset));
|
|
57
|
-
lines.push(center(`Run: npm i -g 9remote@latest`, (s) => C.dim + s + C.reset));
|
|
58
|
-
lines.push(line());
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
lines.push(C.orange + "╚" + "═".repeat(inner) + "╝" + C.reset);
|
|
62
|
-
lines.push("");
|
|
63
|
-
return lines.join("\n");
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
export function showBanner(currentVersion, latestVersion = null) {
|
|
67
|
-
console.log(getBannerText(currentVersion, latestVersion));
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
// ── Progress ──────────────────────────────────────────────────────────────────
|
|
71
|
-
|
|
72
|
-
const SPINNER_FRAMES = ["⣾", "⣽", "⣻", "⢿", "⡿", "⣟", "⣯", "⣷"];
|
|
73
|
-
|
|
74
|
-
const STEPS = [
|
|
75
|
-
{ label: "Preparing", desc: "Checking dependencies" },
|
|
76
|
-
{ label: "Connecting", desc: "Creating session" },
|
|
77
|
-
{ label: "Starting tunnel", desc: "Spawning tunnel" },
|
|
78
|
-
{ label: "Verifying tunnel", desc: "Health check" },
|
|
79
|
-
{ label: "Ready", desc: "Tunnel is live" },
|
|
80
|
-
];
|
|
81
|
-
|
|
82
|
-
const IS_WIN = process.platform === "win32";
|
|
83
|
-
const SPINNER_INTERVAL_MS = IS_WIN ? 120 : 80;
|
|
84
|
-
|
|
85
|
-
// Ensure cursor is restored on any unexpected exit
|
|
86
|
-
process.on("exit", () => process.stdout.write("\x1b[?25h"));
|
|
87
|
-
process.on("SIGINT", () => { process.stdout.write("\x1b[?25h"); });
|
|
88
|
-
process.on("SIGTERM", () => { process.stdout.write("\x1b[?25h"); });
|
|
89
|
-
|
|
90
|
-
let _progressLines = 0;
|
|
91
|
-
let _spinnerInterval = null;
|
|
92
|
-
let _spinnerFrame = 0;
|
|
93
|
-
let _activeIdx = -1;
|
|
94
|
-
let _activeDesc = null;
|
|
95
|
-
let _infoLine = null;
|
|
96
|
-
let _cursorHidden = false;
|
|
97
|
-
|
|
98
|
-
function _buildLines() {
|
|
99
|
-
const lines = [];
|
|
100
|
-
const isFinal = _activeIdx === STEPS.length - 1;
|
|
101
|
-
STEPS.forEach((step, i) => {
|
|
102
|
-
const desc = (i === _activeIdx && _activeDesc) ? _activeDesc : step.desc;
|
|
103
|
-
if (i < _activeIdx || (isFinal && i === _activeIdx)) {
|
|
104
|
-
lines.push(` ${C.green}✓${C.reset} ${C.dim}${step.label}${C.reset}`);
|
|
105
|
-
if (_infoLine && i === _infoLine.afterIdx) {
|
|
106
|
-
lines.push(` ${C.green}✓${C.reset} ${C.cyan}${_infoLine.text}${C.reset}`);
|
|
107
|
-
}
|
|
108
|
-
} else if (i === _activeIdx) {
|
|
109
|
-
const frame = SPINNER_FRAMES[_spinnerFrame % SPINNER_FRAMES.length];
|
|
110
|
-
lines.push(` ${C.orange}${frame}${C.reset} ${C.bold}${step.label}${C.reset} ${C.dim}${desc}${C.reset}`);
|
|
111
|
-
} else {
|
|
112
|
-
lines.push(` ${C.dim}○ ${step.label}${C.reset}`);
|
|
113
|
-
}
|
|
114
|
-
});
|
|
115
|
-
return lines;
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
function _fullRedraw() {
|
|
119
|
-
const lines = _buildLines();
|
|
120
|
-
if (_progressLines > 0) {
|
|
121
|
-
process.stdout.write(`\x1b[${_progressLines}A\x1b[0J`);
|
|
122
|
-
}
|
|
123
|
-
process.stdout.write(lines.join("\n") + "\n");
|
|
124
|
-
_progressLines = lines.length;
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
// Only repaint the active spinner line to avoid flicker on Windows conhost
|
|
128
|
-
function _tickSpinner() {
|
|
129
|
-
if (_progressLines === 0 || _activeIdx < 0) return;
|
|
130
|
-
const lines = _buildLines();
|
|
131
|
-
if (lines.length !== _progressLines) {
|
|
132
|
-
_fullRedraw();
|
|
133
|
-
return;
|
|
134
|
-
}
|
|
135
|
-
const activeLineOffset = _activeIdx + (_infoLine && _infoLine.afterIdx < _activeIdx ? 1 : 0);
|
|
136
|
-
const up = _progressLines - activeLineOffset;
|
|
137
|
-
// Move up, clear line, write, move back down — single write = no flicker
|
|
138
|
-
process.stdout.write(`\x1b[${up}A\r\x1b[2K${lines[activeLineOffset]}\x1b[${up}B\r`);
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
function _hideCursor() {
|
|
142
|
-
if (!_cursorHidden) {
|
|
143
|
-
process.stdout.write("\x1b[?25l");
|
|
144
|
-
_cursorHidden = true;
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
function _showCursor() {
|
|
149
|
-
if (_cursorHidden) {
|
|
150
|
-
process.stdout.write("\x1b[?25h");
|
|
151
|
-
_cursorHidden = false;
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
export function renderProgress(activeIdx, redraw = false, desc = null) {
|
|
156
|
-
if (_spinnerInterval) {
|
|
157
|
-
clearInterval(_spinnerInterval);
|
|
158
|
-
_spinnerInterval = null;
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
_activeIdx = activeIdx;
|
|
162
|
-
_activeDesc = desc;
|
|
163
|
-
_spinnerFrame = 0;
|
|
164
|
-
|
|
165
|
-
if (!redraw) _progressLines = 0;
|
|
166
|
-
_fullRedraw();
|
|
167
|
-
|
|
168
|
-
if (activeIdx < STEPS.length - 1) {
|
|
169
|
-
_hideCursor();
|
|
170
|
-
_spinnerInterval = setInterval(() => {
|
|
171
|
-
_spinnerFrame++;
|
|
172
|
-
_tickSpinner();
|
|
173
|
-
}, SPINNER_INTERVAL_MS);
|
|
174
|
-
} else {
|
|
175
|
-
_showCursor();
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
/** Show an extra info line after a completed step */
|
|
180
|
-
export function setProgressInfo(afterIdx, text) {
|
|
181
|
-
_infoLine = text ? { afterIdx, text } : null;
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
/** Update desc of current active step without changing step index */
|
|
185
|
-
export function updateProgressDesc(desc) {
|
|
186
|
-
_activeDesc = desc;
|
|
187
|
-
if (_progressLines > 0) _tickSpinner();
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
export function resetProgress() {
|
|
191
|
-
if (_spinnerInterval) {
|
|
192
|
-
clearInterval(_spinnerInterval);
|
|
193
|
-
_spinnerInterval = null;
|
|
194
|
-
}
|
|
195
|
-
_showCursor();
|
|
196
|
-
_progressLines = 0;
|
|
197
|
-
_activeIdx = -1;
|
|
198
|
-
_activeDesc = null;
|
|
199
|
-
_infoLine = null;
|
|
200
|
-
_spinnerFrame = 0;
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
// ── selectMenu ────────────────────────────────────────────────────────────────
|
|
204
|
-
|
|
205
|
-
/**
|
|
206
|
-
* Interactive arrow-key menu. Clears full screen on each render.
|
|
207
|
-
* Mirrors 9router_cli pattern exactly: emitKeypressEvents → setRawMode → on("keypress") → resume.
|
|
208
|
-
* cleanup: setRawMode(false) → removeListener → pause.
|
|
209
|
-
* Subsequent readline.createInterface calls work because they resume stdin internally.
|
|
210
|
-
*
|
|
211
|
-
* @param {string} title
|
|
212
|
-
* @param {Array<{label: string}>} items
|
|
213
|
-
* @param {number} defaultIndex
|
|
214
|
-
* @param {string} headerContent — pre-built string shown above menu
|
|
215
|
-
* @param {(setRedraw: () => void, forceExit?: () => void) => void} onRedrawInit — receive redraw + forceExit triggers (for SSE updates / external prompts)
|
|
216
|
-
* @returns {Promise<number>} selected index, -1 on ESC, -2 on forceExit (caller should re-render)
|
|
217
|
-
*/
|
|
218
|
-
export function selectMenu(title, items, defaultIndex = 0, headerContent = "", onRedrawInit = null, onCtrlC = null) {
|
|
219
|
-
return new Promise((resolve) => {
|
|
220
|
-
let selected = defaultIndex;
|
|
221
|
-
let isActive = true;
|
|
222
|
-
const isWin = process.platform === "win32";
|
|
223
|
-
|
|
224
|
-
const renderMenu = () => {
|
|
225
|
-
if (!isActive) return;
|
|
226
|
-
process.stdout.write("\x1b[2J\x1b[H");
|
|
227
|
-
// Support both static string and dynamic getter function
|
|
228
|
-
const header = typeof headerContent === "function" ? headerContent() : headerContent;
|
|
229
|
-
if (header) {
|
|
230
|
-
process.stdout.write(header + "\n");
|
|
231
|
-
}
|
|
232
|
-
if (title) process.stdout.write(`${C.dim}${title}${C.reset}\n\n`);
|
|
233
|
-
items.forEach((item, i) => {
|
|
234
|
-
const icon = i === selected ? (isWin ? ">" : "★") : (isWin ? " " : "☆");
|
|
235
|
-
if (i === selected) {
|
|
236
|
-
console.log(` \x1b[7m${C.bold}${icon} ${item.label}${C.reset}`);
|
|
237
|
-
} else {
|
|
238
|
-
console.log(` ${icon} ${item.label}`);
|
|
239
|
-
}
|
|
240
|
-
});
|
|
241
|
-
};
|
|
242
|
-
|
|
243
|
-
const cleanup = () => {
|
|
244
|
-
if (!isActive) return;
|
|
245
|
-
isActive = false;
|
|
246
|
-
if (process.stdin.isTTY) {
|
|
247
|
-
try { process.stdin.setRawMode(false); } catch {}
|
|
248
|
-
}
|
|
249
|
-
process.stdin.removeListener("keypress", onKeypress);
|
|
250
|
-
process.stdin.pause();
|
|
251
|
-
};
|
|
252
|
-
|
|
253
|
-
const onKeypress = (str, key) => {
|
|
254
|
-
if (!isActive || !key) return;
|
|
255
|
-
if (key.name === "up") {
|
|
256
|
-
selected = (selected - 1 + items.length) % items.length;
|
|
257
|
-
renderMenu();
|
|
258
|
-
} else if (key.name === "down") {
|
|
259
|
-
selected = (selected + 1) % items.length;
|
|
260
|
-
renderMenu();
|
|
261
|
-
} else if (key.name === "return") {
|
|
262
|
-
cleanup();
|
|
263
|
-
resolve(selected);
|
|
264
|
-
} else if (key.name === "escape") {
|
|
265
|
-
cleanup();
|
|
266
|
-
resolve(-1);
|
|
267
|
-
} else if (key.ctrl && key.name === "c") {
|
|
268
|
-
cleanup();
|
|
269
|
-
if (onCtrlC) onCtrlC();
|
|
270
|
-
process.exit(0);
|
|
271
|
-
}
|
|
272
|
-
};
|
|
273
|
-
|
|
274
|
-
// Exact same order as 9router_cli
|
|
275
|
-
process.stdin.removeAllListeners("keypress");
|
|
276
|
-
readline.emitKeypressEvents(process.stdin);
|
|
277
|
-
if (process.stdin.isTTY) {
|
|
278
|
-
try { process.stdin.setRawMode(true); } catch { resolve(-1); return; }
|
|
279
|
-
}
|
|
280
|
-
process.stdin.on("keypress", onKeypress);
|
|
281
|
-
process.stdin.resume();
|
|
282
|
-
renderMenu();
|
|
283
|
-
|
|
284
|
-
// Allow external code to force-exit this menu (e.g. to show a prompt that needs stdin).
|
|
285
|
-
// Resolves with -2 so caller knows to re-render/restart the menu with a fresh stdin state.
|
|
286
|
-
const forceExit = () => {
|
|
287
|
-
if (!isActive) return;
|
|
288
|
-
cleanup();
|
|
289
|
-
resolve(-2);
|
|
290
|
-
};
|
|
291
|
-
|
|
292
|
-
// Allow external code (SSE) to trigger a re-render without disrupting navigation
|
|
293
|
-
if (onRedrawInit) onRedrawInit(renderMenu, forceExit);
|
|
294
|
-
});
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
// ── confirm ───────────────────────────────────────────────────────────────────
|
|
298
|
-
|
|
299
|
-
/**
|
|
300
|
-
* Yes/no prompt. Uses readline.createInterface which resumes stdin internally.
|
|
301
|
-
* Works after selectMenu.cleanup() which pauses stdin.
|
|
302
|
-
*/
|
|
303
|
-
export function confirm(message) {
|
|
304
|
-
return new Promise((resolve) => {
|
|
305
|
-
// Clean state
|
|
306
|
-
process.stdin.removeAllListeners("keypress");
|
|
307
|
-
if (process.stdin.isTTY) { try { process.stdin.setRawMode(false); } catch {} }
|
|
308
|
-
process.stdin.pause();
|
|
309
|
-
|
|
310
|
-
process.stdout.write(`${message} (y/N): `);
|
|
311
|
-
|
|
312
|
-
// Use raw keypress (same pattern as selectMenu)
|
|
313
|
-
readline.emitKeypressEvents(process.stdin);
|
|
314
|
-
if (process.stdin.isTTY) { try { process.stdin.setRawMode(true); } catch {} }
|
|
315
|
-
process.stdin.resume();
|
|
316
|
-
|
|
317
|
-
const onKeypress = (str, key) => {
|
|
318
|
-
if (!key) return;
|
|
319
|
-
process.stdin.removeListener("keypress", onKeypress);
|
|
320
|
-
if (process.stdin.isTTY) { try { process.stdin.setRawMode(false); } catch {} }
|
|
321
|
-
process.stdin.pause();
|
|
322
|
-
|
|
323
|
-
if (key.ctrl && key.name === "c") { process.stdout.write("\n"); process.exit(0); }
|
|
324
|
-
const approved = (key.name || "").toLowerCase() === "y";
|
|
325
|
-
console.log(approved ? "y" : "n");
|
|
326
|
-
resolve(approved);
|
|
327
|
-
};
|
|
328
|
-
|
|
329
|
-
process.stdin.on("keypress", onKeypress);
|
|
330
|
-
});
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
// ── Device Approval Prompt ────────────────────────────────────────────────────
|
|
334
|
-
|
|
335
|
-
/**
|
|
336
|
-
* Show device approval prompt with raw-mode single-key capture.
|
|
337
|
-
* Fully takes over stdin from selectMenu, resolves with true/false.
|
|
338
|
-
*/
|
|
339
|
-
export function showDeviceApproval(deviceId, ip) {
|
|
340
|
-
return new Promise((resolve) => {
|
|
341
|
-
const shortId = deviceId ? deviceId.slice(0, 8) : "unknown";
|
|
342
|
-
const w = W();
|
|
343
|
-
|
|
344
|
-
// Save existing keypress listeners (e.g. selectMenu's) so we can restore
|
|
345
|
-
// them after the prompt — otherwise the caller's menu loses arrow-key input.
|
|
346
|
-
const savedListeners = process.stdin.listeners("keypress").slice();
|
|
347
|
-
process.stdin.removeAllListeners("keypress");
|
|
348
|
-
if (process.stdin.isTTY) { try { process.stdin.setRawMode(false); } catch {} }
|
|
349
|
-
process.stdin.pause();
|
|
350
|
-
|
|
351
|
-
// Clear screen for clean approval UI
|
|
352
|
-
process.stdout.write("\x1b[2J\x1b[H");
|
|
353
|
-
console.log("");
|
|
354
|
-
console.log(`${C.orange}${'═'.repeat(w)}${C.reset}`);
|
|
355
|
-
console.log(`${C.orange}${C.bold} 🔔 New Device Connection${C.reset}`);
|
|
356
|
-
console.log(`${C.orange}${'═'.repeat(w)}${C.reset}`);
|
|
357
|
-
console.log(` Device: ${C.cyan}${shortId}...${C.reset}`);
|
|
358
|
-
console.log(` IP: ${C.cyan}${ip}${C.reset}`);
|
|
359
|
-
console.log(`${C.orange}${'═'.repeat(w)}${C.reset}`);
|
|
360
|
-
console.log("");
|
|
361
|
-
|
|
362
|
-
process.stdout.write(` Allow this device? ${C.dim}(y/n)${C.reset} `);
|
|
363
|
-
|
|
364
|
-
// Use keypress events (same pattern as selectMenu)
|
|
365
|
-
readline.emitKeypressEvents(process.stdin);
|
|
366
|
-
if (process.stdin.isTTY) { try { process.stdin.setRawMode(true); } catch {} }
|
|
367
|
-
process.stdin.resume();
|
|
368
|
-
|
|
369
|
-
const restoreListeners = () => {
|
|
370
|
-
for (const l of savedListeners) process.stdin.on("keypress", l);
|
|
371
|
-
if (savedListeners.length > 0) {
|
|
372
|
-
// Previous owner (selectMenu) was in raw mode + resumed stdin.
|
|
373
|
-
if (process.stdin.isTTY) { try { process.stdin.setRawMode(true); } catch {} }
|
|
374
|
-
process.stdin.resume();
|
|
375
|
-
}
|
|
376
|
-
};
|
|
377
|
-
|
|
378
|
-
const onKeypress = (str, key) => {
|
|
379
|
-
if (!key) return;
|
|
380
|
-
const ch = (key.name || "").toLowerCase();
|
|
381
|
-
if (ch === "y" || ch === "n" || key.name === "return" || (key.ctrl && key.name === "c")) {
|
|
382
|
-
process.stdin.removeListener("keypress", onKeypress);
|
|
383
|
-
if (process.stdin.isTTY) { try { process.stdin.setRawMode(false); } catch {} }
|
|
384
|
-
process.stdin.pause();
|
|
385
|
-
if (key.ctrl && key.name === "c") process.exit(0);
|
|
386
|
-
|
|
387
|
-
const approved = ch === "y";
|
|
388
|
-
console.log(approved ? `${C.green}y${C.reset}` : `${C.red}n${C.reset}`);
|
|
389
|
-
console.log(approved
|
|
390
|
-
? `\n ${C.green}\u2713 Device approved${C.reset}`
|
|
391
|
-
: `\n ${C.red}\u2717 Device rejected${C.reset}`);
|
|
392
|
-
setTimeout(() => { restoreListeners(); resolve(approved); }, 500);
|
|
393
|
-
}
|
|
394
|
-
};
|
|
395
|
-
|
|
396
|
-
process.stdin.on("keypress", onKeypress);
|
|
397
|
-
});
|
|
398
|
-
}
|
|
399
|
-
|
|
400
|
-
// ── SSE client ───────────────────────────────────────────────────────────────
|
|
401
|
-
|
|
402
|
-
/**
|
|
403
|
-
* Subscribe to server SSE stream. Calls onEvent(type, data) for each event.
|
|
404
|
-
* Returns a cleanup function to close the connection.
|
|
405
|
-
* @param {number} port
|
|
406
|
-
* @param {(type: string, data: object) => void} onEvent
|
|
407
|
-
* @returns {() => void} cleanup
|
|
408
|
-
*/
|
|
409
|
-
export function subscribeSSE(port, onEvent) {
|
|
410
|
-
let req = null;
|
|
411
|
-
let closed = false;
|
|
412
|
-
|
|
413
|
-
const connect = () => {
|
|
414
|
-
if (closed) return;
|
|
415
|
-
req = http.get(`http://localhost:${port}/api/ui/events`, (res) => {
|
|
416
|
-
let buf = "";
|
|
417
|
-
res.on("data", (chunk) => {
|
|
418
|
-
buf += chunk.toString();
|
|
419
|
-
const lines = buf.split("\n");
|
|
420
|
-
buf = lines.pop(); // keep incomplete line
|
|
421
|
-
let eventData = "";
|
|
422
|
-
for (const line of lines) {
|
|
423
|
-
if (line.startsWith("data: ")) {
|
|
424
|
-
eventData = line.slice(6);
|
|
425
|
-
} else if (line === "" && eventData) {
|
|
426
|
-
try {
|
|
427
|
-
const parsed = JSON.parse(eventData);
|
|
428
|
-
onEvent(parsed.type, parsed);
|
|
429
|
-
} catch {}
|
|
430
|
-
eventData = "";
|
|
431
|
-
}
|
|
432
|
-
}
|
|
433
|
-
});
|
|
434
|
-
res.on("end", () => {
|
|
435
|
-
if (!closed) setTimeout(connect, 2000); // reconnect
|
|
436
|
-
});
|
|
437
|
-
});
|
|
438
|
-
req.on("error", () => {
|
|
439
|
-
if (!closed) setTimeout(connect, 2000); // reconnect on error
|
|
440
|
-
});
|
|
441
|
-
};
|
|
442
|
-
|
|
443
|
-
connect();
|
|
444
|
-
return () => { closed = true; req?.destroy(); };
|
|
445
|
-
}
|