@hncode/hncode 0.3.6 → 0.4.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/package.json +4 -2
- package/src/agent-task.js +154 -84
- package/src/agent.js +533 -145
- package/src/clipboard.js +5 -2
- package/src/colors.js +242 -238
- package/src/config.js +217 -17
- package/src/file-history.js +177 -0
- package/src/hooks.js +11 -1
- package/src/i18n.js +389 -0
- package/src/index.js +46 -3
- package/src/llm.js +139 -16
- package/src/permissions.js +120 -0
- package/src/session-hub.js +407 -0
- package/src/session.js +31 -0
- package/src/term.js +19 -3
- package/src/tool-result.js +236 -0
- package/src/tools/agent.js +11 -0
- package/src/tools/bash.js +84 -17
- package/src/tools/edit.js +192 -32
- package/src/tools/file-lines.js +5 -0
- package/src/tools/index.js +4 -0
- package/src/tools/memory.js +88 -0
- package/src/tools/read-media-file.js +85 -68
- package/src/tools/read.js +15 -1
- package/src/tools/utils.js +14 -0
- package/src/tools/write.js +35 -5
- package/src/tui.js +11665 -9179
- package/src/web-auth.js +98 -0
- package/src/web-daemon-cli.js +63 -0
- package/src/web-daemon-client.js +203 -0
- package/src/web-daemon.js +871 -0
- package/src/web-public/app.css +1509 -0
- package/src/web-public/app.js +2913 -0
- package/src/web-public/complete.js +154 -0
- package/src/web-public/fonts/JetBrainsMono-Bold.woff2 +0 -0
- package/src/web-public/fonts/JetBrainsMono-Regular.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-100-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-101-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-102-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-103-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-104-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-105-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-106-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-107-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-108-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-109-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-110-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-111-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-112-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-113-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-114-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-115-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-116-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-117-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-118-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-119-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-21-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-22-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-23-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-24-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-25-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-26-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-27-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-28-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-29-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-30-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-31-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-32-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-33-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-34-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-35-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-36-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-37-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-38-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-39-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-4-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-40-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-41-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-42-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-43-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-44-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-45-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-46-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-47-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-48-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-49-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-5-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-50-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-51-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-52-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-53-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-54-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-55-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-56-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-57-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-58-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-59-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-6-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-60-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-61-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-62-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-63-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-64-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-65-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-66-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-67-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-68-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-69-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-70-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-71-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-72-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-73-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-74-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-75-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-76-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-77-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-78-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-79-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-80-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-81-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-82-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-83-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-84-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-85-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-86-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-87-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-88-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-89-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-90-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-91-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-97-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-98-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-99-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc.css +775 -0
- package/src/web-public/fonts.css +31 -0
- package/src/web-public/home.css +222 -0
- package/src/web-public/home.html +33 -0
- package/src/web-public/home.js +160 -0
- package/src/web-public/i18n.js +983 -0
- package/src/web-public/index.html +196 -0
- package/src/web-public/login.css +123 -0
- package/src/web-public/login.html +25 -0
- package/src/web-public/login.js +46 -0
- package/src/web-public/settings.js +290 -0
- package/src/web-ws.js +226 -0
- package/src/web.js +380 -0
package/src/web-ws.js
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
// Zero-dependency WebSocket server for the local web UI.
|
|
2
|
+
//
|
|
3
|
+
// The project ships with NO runtime dependencies (see package.json), so a
|
|
4
|
+
// WebSocket upgrade cannot use the `ws` package. RFC 6455 is implemented here by
|
|
5
|
+
// hand: the SHA-1 handshake uses node:crypto, and the data framing (text frames,
|
|
6
|
+
// ~7-byte masks, 16/64-bit lengths) is a small loop.
|
|
7
|
+
//
|
|
8
|
+
// This is deliberately the smallest subset the web UI needs: a server that
|
|
9
|
+
// accepts one text-message stream per connection, echoes nothing, and lets the
|
|
10
|
+
// caller push string messages. No ping/pong, no fragmentation (the browser never
|
|
11
|
+
// fragments), no binary. Compression is NOT negotiated, so the browser sends
|
|
12
|
+
// plain frames — enough for our JSON events.
|
|
13
|
+
|
|
14
|
+
import http from 'node:http';
|
|
15
|
+
import crypto from 'node:crypto';
|
|
16
|
+
import fs from 'node:fs';
|
|
17
|
+
import os from 'node:os';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
const KEEPALIVE_MS = 25000;
|
|
20
|
+
|
|
21
|
+
const WS_MAGIC = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
|
|
22
|
+
// Hard cap on a single frame's payload. A malicious/formed peer can declare a
|
|
23
|
+
// 64-bit length; allocating that much would throw RangeError and crash the
|
|
24
|
+
// process, and buffering a slow trickle of an oversized frame would OOM.
|
|
25
|
+
const MAX_FRAME = 64 * 1024 * 1024;
|
|
26
|
+
|
|
27
|
+
/** A single accepted, open WebSocket connection. */
|
|
28
|
+
export class WsConn {
|
|
29
|
+
constructor(socket, onClose) {
|
|
30
|
+
this.socket = socket;
|
|
31
|
+
this._done = onClose; // internal close sink, fired once when the socket dies
|
|
32
|
+
this.open = true;
|
|
33
|
+
// The caller registers a message handler.
|
|
34
|
+
this.onMessage = null;
|
|
35
|
+
socket.on('data', (d) => this._onData(d));
|
|
36
|
+
socket.on('close', () => this._close());
|
|
37
|
+
socket.on('error', () => this._close());
|
|
38
|
+
// Server->client keepalive: idle proxies (EasyTier, the daemon, nginx)
|
|
39
|
+
// drop a silent WebSocket, the front-end reconnects and re-reads the whole
|
|
40
|
+
// snapshot (page appears to reload itself). A repeated ping keeps the path
|
|
41
|
+
// alive; the timer dies with the socket in _close().
|
|
42
|
+
this._ka = setInterval(function () { try { this.ping(); } catch { /* closing */ } }.bind(this), KEEPALIVE_MS);
|
|
43
|
+
if (this._ka.unref) this._ka.unref();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
_buffer = Buffer.alloc(0);
|
|
47
|
+
|
|
48
|
+
_onData(chunk) {
|
|
49
|
+
this._buffer = Buffer.concat([this._buffer, chunk]);
|
|
50
|
+
// Guard against a peer trickling an oversized frame: the incomplete-frame
|
|
51
|
+
// path would otherwise keep appending to the buffer without bound.
|
|
52
|
+
if (this._buffer.length > MAX_FRAME) { try { this._close(); } catch {} return; }
|
|
53
|
+
// Try to parse as many complete frames as the buffer holds.
|
|
54
|
+
for (;;) {
|
|
55
|
+
const parsed = this._parseFrame(this._buffer);
|
|
56
|
+
if (!parsed) return;
|
|
57
|
+
const { consumed, data } = parsed;
|
|
58
|
+
this._buffer = this._buffer.subarray(consumed);
|
|
59
|
+
if (parsed.fatal) { try { this._close(); } catch {} return; }
|
|
60
|
+
if (this.onMessage) {
|
|
61
|
+
try { this.onMessage(data); } catch { /* a handler must not kill the conn */ }
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Parse one WS frame from the front of `buf`. Returns null when the frame is
|
|
67
|
+
incomplete; otherwise `{ consumed, data }` with `data` the UTF-8 string. */
|
|
68
|
+
_parseFrame(buf) {
|
|
69
|
+
if (buf.length < 2) return null;
|
|
70
|
+
const fin = (buf[0] & 0x80) !== 0;
|
|
71
|
+
const opcode = buf[0] & 0x0f;
|
|
72
|
+
const masked = (buf[1] & 0x80) !== 0;
|
|
73
|
+
let len = buf[1] & 0x7f;
|
|
74
|
+
let off = 2;
|
|
75
|
+
if (len === 126) {
|
|
76
|
+
if (buf.length < off + 2) return null;
|
|
77
|
+
len = buf.readUInt16BE(off);
|
|
78
|
+
off += 2;
|
|
79
|
+
} else if (len === 127) {
|
|
80
|
+
if (buf.length < off + 8) return null;
|
|
81
|
+
const hi = buf.readUInt32BE(off);
|
|
82
|
+
const lo = buf.readUInt32BE(off + 4);
|
|
83
|
+
len = hi * 4294967296 + lo;
|
|
84
|
+
off += 8;
|
|
85
|
+
}
|
|
86
|
+
if (len > MAX_FRAME) return { consumed: 1, data: '', fatal: true };
|
|
87
|
+
// Only masked text frames from a browser; ignore anything else gracefully.
|
|
88
|
+
if (!masked) {
|
|
89
|
+
// Not masked is a protocol error. A browser always masks, so this means
|
|
90
|
+
// the stream is corrupted — flag it fatal and let _onData drop the
|
|
91
|
+
// connection instead of guessing a frame length (which could swallow
|
|
92
|
+
// following frames) or spinning forever (consumed:0 froze the server).
|
|
93
|
+
return { consumed: 1, data: '', fatal: true };
|
|
94
|
+
}
|
|
95
|
+
const maskLen = 4;
|
|
96
|
+
if (buf.length < off + maskLen) return null;
|
|
97
|
+
const mask = buf.subarray(off, off + maskLen);
|
|
98
|
+
off += maskLen;
|
|
99
|
+
if (buf.length < off + len) return null;
|
|
100
|
+
const payload = Buffer.alloc(len);
|
|
101
|
+
for (let i = 0; i < len; i++) payload[i] = buf[off + i] ^ mask[i & 3];
|
|
102
|
+
// opcode 0x9 = ping, 0xA = pong, 0x8 = close. We answer pings; skip the rest.
|
|
103
|
+
if (!fin) return { consumed: off + len, data: '' }; // no fragmentation support
|
|
104
|
+
let text = '';
|
|
105
|
+
if (opcode === 0x1) { // text frame
|
|
106
|
+
text = payload.toString('utf8');
|
|
107
|
+
} else if (opcode === 0x9) { // ping -> pong
|
|
108
|
+
this._writeFrame(0xA, payload);
|
|
109
|
+
} else if (opcode === 0x8) { // close -> close back
|
|
110
|
+
this._writeFrame(0x8, Buffer.alloc(0));
|
|
111
|
+
this._close();
|
|
112
|
+
}
|
|
113
|
+
return { consumed: off + len, data: text };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Send a text message as an unmasked frame, per RFC 6455 (server->client
|
|
117
|
+
frames are never masked). */
|
|
118
|
+
send(text) {
|
|
119
|
+
if (!this.open) return;
|
|
120
|
+
this._writeFrame(0x1, Buffer.from(String(text), 'utf8'));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Send a ping control frame (opcode 0x9). The peer answers with a pong,
|
|
124
|
+
keeping the socket and any proxy between us and the browser alive. */
|
|
125
|
+
ping() {
|
|
126
|
+
if (!this.open) return;
|
|
127
|
+
this._writeFrame(0x9, Buffer.alloc(0));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
_writeFrame(opcode, payload) {
|
|
131
|
+
const len = payload.length;
|
|
132
|
+
const header = [0x80 | opcode];
|
|
133
|
+
if (len <= 125) {
|
|
134
|
+
header.push(len);
|
|
135
|
+
} else if (len <= 0xffff) {
|
|
136
|
+
// RFC 6455: opcode+mask bit, then 126, then a 16-bit big-endian length.
|
|
137
|
+
header.push(126);
|
|
138
|
+
const b = Buffer.alloc(2);
|
|
139
|
+
b.writeUInt16BE(len, 0);
|
|
140
|
+
header.push(b[0], b[1]);
|
|
141
|
+
} else {
|
|
142
|
+
// 127, then a 64-bit big-endian length.
|
|
143
|
+
header.push(127);
|
|
144
|
+
const b = Buffer.alloc(8);
|
|
145
|
+
b.writeUInt32BE(Math.floor(len / 4294967296), 0);
|
|
146
|
+
b.writeUInt32BE(len >>> 0, 4);
|
|
147
|
+
header.push(...b);
|
|
148
|
+
}
|
|
149
|
+
const buf = Buffer.concat([Buffer.from(header), payload]);
|
|
150
|
+
try { this.socket.write(buf); } catch { /* closing */ }
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
close() {
|
|
154
|
+
if (!this.open) return;
|
|
155
|
+
try { this._writeFrame(0x8, Buffer.alloc(0)); } catch { /* closing */ }
|
|
156
|
+
this._close();
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
_close() {
|
|
160
|
+
if (this._ka) { clearInterval(this._ka); this._ka = null; }
|
|
161
|
+
if (!this.open) return;
|
|
162
|
+
this.open = false;
|
|
163
|
+
try { this.socket.destroy(); } catch { /* already gone */ }
|
|
164
|
+
if (this._done) { try { this._done(); } catch { /* best-effort */ } }
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Attach a WebSocket upgrade handler to an http.Server.
|
|
170
|
+
*
|
|
171
|
+
* onConnection(conn) — called once a connection is accepted and open. `conn` is
|
|
172
|
+
* a WsConn with `.send(text)` and `.close()`; set `conn.onMessage` to receive
|
|
173
|
+
* client frames.
|
|
174
|
+
* onUpgrade(req) — optional; return a string to reject (sent as the HTTP status
|
|
175
|
+
* for the upgrade), or null to accept.
|
|
176
|
+
*/
|
|
177
|
+
export function attachWebSocket(server, opts = {}) {
|
|
178
|
+
const { path = '/api/ws', onConnection, onUpgrade } = opts;
|
|
179
|
+
server.on('upgrade', (req, socket, head) => {
|
|
180
|
+
const url = req.url || '/';
|
|
181
|
+
const p = url.split('?')[0];
|
|
182
|
+
if (p !== path) { socket.destroy(); return; }
|
|
183
|
+
if (onUpgrade) {
|
|
184
|
+
const reject = onUpgrade(req);
|
|
185
|
+
if (reject) {
|
|
186
|
+
socket.write(
|
|
187
|
+
'HTTP/1.1 403 Forbidden\r\nContent-Type: text/plain\r\nConnection: close\r\n\r\n' + reject,
|
|
188
|
+
);
|
|
189
|
+
socket.destroy();
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
const key = req.headers['sec-websocket-key'];
|
|
194
|
+
if (!key) { socket.destroy(); return; }
|
|
195
|
+
const accept = crypto
|
|
196
|
+
.createHash('sha1')
|
|
197
|
+
.update(key + WS_MAGIC)
|
|
198
|
+
.digest('base64');
|
|
199
|
+
socket.write(
|
|
200
|
+
'HTTP/1.1 101 Switching Protocols\r\n'
|
|
201
|
+
+ 'Upgrade: websocket\r\n'
|
|
202
|
+
+ 'Connection: Upgrade\r\n'
|
|
203
|
+
+ `Sec-WebSocket-Accept: ${accept}\r\n\r\n`,
|
|
204
|
+
);
|
|
205
|
+
try {
|
|
206
|
+
const conn = new WsConn(socket, () => {
|
|
207
|
+
// The caller decides what to do when a connection drops.
|
|
208
|
+
if (opts.onClose) opts.onClose(conn);
|
|
209
|
+
});
|
|
210
|
+
if (onConnection) onConnection(conn);
|
|
211
|
+
} catch (e) {
|
|
212
|
+
// A connection that fails to construct must not kill the process; log it.
|
|
213
|
+
try { fs.appendFileSync(path.join(os.tmpdir(), 'hncode-web-ws.log'), '[ws] onConnection threw: ' + (e && e.message) + '\n'); } catch { /* best-effort */ }
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Convenience: is the incoming `req` a WebSocket upgrade for our path? Lets an
|
|
220
|
+
* http server's normal request handler skip it (the upgrade event is handled
|
|
221
|
+
* separately, so this is informational).
|
|
222
|
+
*/
|
|
223
|
+
export function isUpgradeRequest(req) {
|
|
224
|
+
const up = String(req.headers.upgrade || '').toLowerCase();
|
|
225
|
+
return up === 'websocket';
|
|
226
|
+
}
|
package/src/web.js
ADDED
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
// Local web UI server (/web) — serves the browser client and bridges it to a
|
|
2
|
+
// running session through the SessionHub.
|
|
3
|
+
//
|
|
4
|
+
// Zero dependencies, like the rest of the project: node:http, a hand-written
|
|
5
|
+
// router, Server-Sent Events for the server->browser direction and small JSON
|
|
6
|
+
// POSTs for the other. SSE rather than WebSockets because the traffic is
|
|
7
|
+
// one-directional and heavy (streamed tokens, tool output), it survives proxies,
|
|
8
|
+
// and it needs no upgrade handshake or framing code.
|
|
9
|
+
//
|
|
10
|
+
// AUTH is enforced in ONE place — `handle()` — so a new route cannot accidentally
|
|
11
|
+
// ship unauthenticated. The only unauthenticated routes are the login page itself
|
|
12
|
+
// and the health check (which reveals nothing but "a server is here").
|
|
13
|
+
|
|
14
|
+
import http from 'node:http';
|
|
15
|
+
import fs from 'node:fs';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
import { fileURLToPath } from 'node:url';
|
|
18
|
+
import { newToken, isAuthed, isLoopback, sameOriginOk, safeEqual } from './web-auth.js';
|
|
19
|
+
import { t, LANGUAGES, DEFAULT_LANG } from './i18n.js';
|
|
20
|
+
// The session directory lives in web-daemon.js (it is the daemon that serves the
|
|
21
|
+
// landing page listing every session). The session page needs the SAME list for
|
|
22
|
+
// its left rail, so it imports the reader rather than growing a second one.
|
|
23
|
+
import { listSavedSessions } from './web-daemon.js';
|
|
24
|
+
import { attachWebSocket } from './web-ws.js';
|
|
25
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
26
|
+
const PUBLIC_DIR = path.join(HERE, 'web-public');
|
|
27
|
+
const COOKIE = 'hncode_web';
|
|
28
|
+
const MAX_BODY = 1 << 20; // 1 MiB — a prompt, not a file upload
|
|
29
|
+
const LOGIN_DELAY_MS = 400; // slows online guessing; costs a human nothing
|
|
30
|
+
|
|
31
|
+
// Actions a browser may invoke. Shared by the HTTP /api/action handler AND the
|
|
32
|
+
// WebSocket command channel, so registering a hub handler is never enough on its
|
|
33
|
+
// own to expose it, and neither transport can drift from the other.
|
|
34
|
+
const ALLOWED_ACTIONS = [
|
|
35
|
+
// conversation
|
|
36
|
+
'submit', 'dispatch', 'interrupt', 'approve', 'answerQuestion', 'stopTask',
|
|
37
|
+
'setMode', 'shell', 'steer', 'editQueued', 'dropQueued',
|
|
38
|
+
// session settings
|
|
39
|
+
'setTitle', 'getConfig', 'setConfig',
|
|
40
|
+
// providers and models
|
|
41
|
+
'listProviders', 'addProvider', 'removeProvider', 'discoverModels',
|
|
42
|
+
'listKnownProviders', 'importKnownProvider',
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Pick a language for a server-rendered string. The browser's own choice lives in
|
|
47
|
+
* localStorage (client side), so the server cannot read it; `Accept-Language` is
|
|
48
|
+
* the next best signal and is what a curl client or a direct visit sends. An
|
|
49
|
+
* explicit `?lang=` wins, so a link can pin the language.
|
|
50
|
+
*/
|
|
51
|
+
function pickLang(req, url) {
|
|
52
|
+
const q = url && url.searchParams.get('lang');
|
|
53
|
+
if (q && LANGUAGES.some((l) => l.id === q)) return q;
|
|
54
|
+
const header = String(req.headers['accept-language'] || '');
|
|
55
|
+
for (const part of header.split(',')) {
|
|
56
|
+
const id = part.split(';')[0].trim().toLowerCase().split('-')[0];
|
|
57
|
+
if (LANGUAGES.some((l) => l.id === id)) return id;
|
|
58
|
+
}
|
|
59
|
+
return DEFAULT_LANG;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Read a static asset from web-public/. Returns null when missing/unsafe. */
|
|
63
|
+
function readAsset(urlPath) {
|
|
64
|
+
// Strip the query, decode, and REFUSE anything that escapes the directory.
|
|
65
|
+
// Serving `../src/tui.js` here would hand out the source to an authed client —
|
|
66
|
+
// not catastrophic, but a path traversal is a bug wherever it appears.
|
|
67
|
+
let rel = decodeURIComponent(String(urlPath || '/').split('?')[0]);
|
|
68
|
+
if (rel === '/' || rel === '') rel = '/index.html';
|
|
69
|
+
const full = path.resolve(PUBLIC_DIR, '.' + rel);
|
|
70
|
+
if (!full.startsWith(PUBLIC_DIR + path.sep)) return null;
|
|
71
|
+
try {
|
|
72
|
+
const st = fs.statSync(full);
|
|
73
|
+
if (!st.isFile()) return null;
|
|
74
|
+
return { body: fs.readFileSync(full), type: contentType(full), mtime: st.mtimeMs };
|
|
75
|
+
} catch { return null; }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function contentType(file) {
|
|
79
|
+
const ext = path.extname(file).toLowerCase();
|
|
80
|
+
return ({
|
|
81
|
+
'.html': 'text/html; charset=utf-8',
|
|
82
|
+
'.css': 'text/css; charset=utf-8',
|
|
83
|
+
'.js': 'text/javascript; charset=utf-8',
|
|
84
|
+
'.json': 'application/json; charset=utf-8',
|
|
85
|
+
'.svg': 'image/svg+xml',
|
|
86
|
+
'.ico': 'image/x-icon',
|
|
87
|
+
// The self-hosted faces under web-public/fonts. Without these the browser
|
|
88
|
+
// receives `application/octet-stream` and refuses to apply the font.
|
|
89
|
+
'.woff2': 'font/woff2',
|
|
90
|
+
'.woff': 'font/woff',
|
|
91
|
+
'.ttf': 'font/ttf',
|
|
92
|
+
})[ext] || 'application/octet-stream';
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function send(res, code, body, headers = {}) {
|
|
96
|
+
const buf = Buffer.isBuffer(body) ? body : Buffer.from(String(body), 'utf8');
|
|
97
|
+
res.writeHead(code, { 'content-length': buf.length, ...headers });
|
|
98
|
+
res.end(buf);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function json(res, code, obj) {
|
|
102
|
+
send(res, code, JSON.stringify(obj), { 'content-type': 'application/json; charset=utf-8' });
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function readBody(req) {
|
|
106
|
+
return new Promise((resolve, reject) => {
|
|
107
|
+
let size = 0;
|
|
108
|
+
const chunks = [];
|
|
109
|
+
req.on('data', (d) => {
|
|
110
|
+
size += d.length;
|
|
111
|
+
if (size > MAX_BODY) { reject(new Error('body too large')); req.destroy(); return; }
|
|
112
|
+
chunks.push(d);
|
|
113
|
+
});
|
|
114
|
+
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
|
115
|
+
req.on('error', reject);
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Start the web server.
|
|
121
|
+
*
|
|
122
|
+
* hub SessionHub — the shared session state
|
|
123
|
+
* bindIp string — address to listen on (default 127.0.0.1)
|
|
124
|
+
* port number — 0 asks the OS for a free port
|
|
125
|
+
* token string — pre-set token (tests); otherwise generated
|
|
126
|
+
* storeDir string — session store for /api/sessions (default: the user's)
|
|
127
|
+
* onNotice (msg, kind) => void — surface startup info in the TUI transcript
|
|
128
|
+
* @returns {Promise<{server, port, host, url, token, close}>}
|
|
129
|
+
*/
|
|
130
|
+
export async function startWebServer(opts = {}) {
|
|
131
|
+
const hub = opts.hub;
|
|
132
|
+
if (!hub) throw new Error('startWebServer: hub is required');
|
|
133
|
+
const bindIp = opts.bindIp || '127.0.0.1';
|
|
134
|
+
const token = opts.token || newToken();
|
|
135
|
+
const loopback = isLoopback(bindIp);
|
|
136
|
+
// Hosts we accept in Origin. A non-loopback bind may be reached under any name
|
|
137
|
+
// the client used, so the listen port is matched rather than a fixed hostname.
|
|
138
|
+
let expectedHosts = [];
|
|
139
|
+
const onNotice = typeof opts.onNotice === 'function' ? opts.onNotice : () => {};
|
|
140
|
+
|
|
141
|
+
const server = http.createServer((req, res) => {
|
|
142
|
+
handle(req, res).catch((e) => {
|
|
143
|
+
try { json(res, 500, { ok: false, error: (e && e.message) || String(e) }); } catch { /* headers sent */ }
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
// Live events over WebSocket (RFC 6455, zero deps). A client that upgrades to
|
|
148
|
+
// /api/ws gets the current snapshot, then every hub event pushed as a JSON
|
|
149
|
+
// line. Auth is checked in onUpgrade (cookie), so an unauthorised handshake is
|
|
150
|
+
// rejected before a connection is made.
|
|
151
|
+
const wsTargets = new Set();
|
|
152
|
+
const wsBroadcast = (obj) => {
|
|
153
|
+
const line = JSON.stringify(obj);
|
|
154
|
+
for (const conn of wsTargets) { try { conn.send(line); } catch { /* closing */ } }
|
|
155
|
+
};
|
|
156
|
+
const wsOff = hub.subscribe(wsBroadcast);
|
|
157
|
+
attachWebSocket(server, {
|
|
158
|
+
onUpgrade: (req) => {
|
|
159
|
+
// The browser carries the session cookie (standalone /web), while the
|
|
160
|
+
// daemon reverse-proxy injects `Authorization: Bearer <session token>` —
|
|
161
|
+
// isAuthed reads both, so a handshake through either path is accepted.
|
|
162
|
+
if (!isAuthed(req, token, COOKIE)) return 'unauthorized';
|
|
163
|
+
return null;
|
|
164
|
+
},
|
|
165
|
+
onConnection: (conn) => {
|
|
166
|
+
wsTargets.add(conn);
|
|
167
|
+
conn.send(JSON.stringify({ type: 'snapshot', ...hub.snapshot() }));
|
|
168
|
+
// Bidirectional: a client text frame carries {action, args} and is run
|
|
169
|
+
// through the SAME hub dispatch the HTTP /api/action path uses. This is
|
|
170
|
+
// how the UI sends a message WITHOUT waiting on a blocking HTTP round
|
|
171
|
+
// trip. The handler is run side-by-side (not awaited here) so the socket
|
|
172
|
+
// never stalls on a long action; the result, if the caller wants one,
|
|
173
|
+
// is pushed back as a matching frame below.
|
|
174
|
+
conn.onMessage = (text) => {
|
|
175
|
+
let req;
|
|
176
|
+
try { req = JSON.parse(text); } catch { return; }
|
|
177
|
+
const name = String((req && req.action) || '');
|
|
178
|
+
if (!name) return;
|
|
179
|
+
// Same allowlist as the HTTP path: a hub handler is not exposed just by
|
|
180
|
+
// existing.
|
|
181
|
+
if (!ALLOWED_ACTIONS.includes(name)) {
|
|
182
|
+
try { conn.send(JSON.stringify({ type: 'ack', action: name, ok: false, error: `unknown action: ${name}` })); } catch {}
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
const args = Array.isArray(req && req.args) ? req.args : [];
|
|
186
|
+
Promise.resolve(hub.call(name, ...args))
|
|
187
|
+
.then((r) => {
|
|
188
|
+
try { conn.send(JSON.stringify({ type: 'ack', action: name, ...(r || {}) })); } catch {}
|
|
189
|
+
})
|
|
190
|
+
.catch((err) => {
|
|
191
|
+
try { conn.send(JSON.stringify({ type: 'ack', action: name, ok: false, error: (err && err.message) || String(err) })); } catch {}
|
|
192
|
+
});
|
|
193
|
+
};
|
|
194
|
+
},
|
|
195
|
+
onClose: (conn) => {
|
|
196
|
+
wsTargets.delete(conn);
|
|
197
|
+
},
|
|
198
|
+
});
|
|
199
|
+
async function handle(req, res) {
|
|
200
|
+
const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
|
|
201
|
+
const p = url.pathname;
|
|
202
|
+
const method = (req.method || 'GET').toUpperCase();
|
|
203
|
+
|
|
204
|
+
// --- unauthenticated ------------------------------------------------------
|
|
205
|
+
// The login page and its assets must load before a token exists, and the login
|
|
206
|
+
// script imports the message table — so i18n.js is served here too. NONE of
|
|
207
|
+
// these files contain session data: they are the page, its styles, its script
|
|
208
|
+
// and the UI strings.
|
|
209
|
+
if (p === '/login' || p === '/login.html' || p === '/login.css'
|
|
210
|
+
|| p === '/login.js' || p === '/i18n.js'
|
|
211
|
+
// The self-hosted faces. Neither file carries session data, and the login
|
|
212
|
+
// page cannot render without them.
|
|
213
|
+
|| p === '/fonts.css' || p.startsWith('/fonts/')) {
|
|
214
|
+
const asset = readAsset(p === '/login' ? '/login.html' : p);
|
|
215
|
+
if (!asset) return send(res, 404, t(pickLang(req, url), 'http.notFound'));
|
|
216
|
+
return send(res, 200, asset.body, { 'content-type': asset.type, 'cache-control': 'no-store' });
|
|
217
|
+
}
|
|
218
|
+
// Health check: reveals only that a server exists. Used by the TUI to confirm
|
|
219
|
+
// the bind worked, and by the user to test reachability from another machine.
|
|
220
|
+
if (p === '/healthz') return json(res, 200, { ok: true, service: 'hncode-web' });
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
// --- token exchange ------------------------------------------------------
|
|
224
|
+
// POST /api/login { token } -> sets an HttpOnly session cookie. The token is
|
|
225
|
+
// accepted in the BODY, never the query string, so it does not land in the
|
|
226
|
+
// server log or the browser history.
|
|
227
|
+
if (p === '/api/login' && method === 'POST') {
|
|
228
|
+
let body = {};
|
|
229
|
+
try { body = JSON.parse(await readBody(req) || '{}'); } catch { /* handled below */ }
|
|
230
|
+
if (!safeEqual(String(body.token || ''), token)) {
|
|
231
|
+
await new Promise((r) => setTimeout(r, LOGIN_DELAY_MS));
|
|
232
|
+
return json(res, 401, { ok: false, error: 'invalid token' });
|
|
233
|
+
}
|
|
234
|
+
const secure = !loopback && String(req.headers['x-forwarded-proto'] || '') === 'https';
|
|
235
|
+
const cookie = [
|
|
236
|
+
`${COOKIE}=${encodeURIComponent(token)}`,
|
|
237
|
+
'HttpOnly',
|
|
238
|
+
'SameSite=Strict',
|
|
239
|
+
'Path=/',
|
|
240
|
+
// Not `Secure` on loopback: the browser would refuse to send it over http
|
|
241
|
+
// and login would appear to succeed then fail on every later request.
|
|
242
|
+
secure ? 'Secure' : '',
|
|
243
|
+
`Max-Age=${60 * 60 * 12}`,
|
|
244
|
+
].filter(Boolean).join('; ');
|
|
245
|
+
return send(res, 200, JSON.stringify({ ok: true }), {
|
|
246
|
+
'content-type': 'application/json; charset=utf-8',
|
|
247
|
+
'set-cookie': cookie,
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// --- everything below requires the token ---------------------------------
|
|
252
|
+
if (!isAuthed(req, token, COOKIE)) {
|
|
253
|
+
if (p.startsWith('/api/')) return json(res, 401, { ok: false, error: 'unauthorized' });
|
|
254
|
+
// An unauthenticated page request goes to the login form, not a 401 body.
|
|
255
|
+
if (method === 'GET') {
|
|
256
|
+
res.writeHead(302, { location: '/login' });
|
|
257
|
+
return res.end();
|
|
258
|
+
}
|
|
259
|
+
return send(res, 401, t(pickLang(req, url), 'http.unauthorized'));
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// CSRF: a state-changing request must come from a page we served. Same-origin
|
|
263
|
+
// requests carry a matching Origin; a missing Origin means a non-browser
|
|
264
|
+
// client (curl, a test) that already proved it holds the token.
|
|
265
|
+
if (method !== 'GET' && method !== 'HEAD') {
|
|
266
|
+
const ok = sameOriginOk(req, expectedHosts);
|
|
267
|
+
if (ok === false) return json(res, 403, { ok: false, error: 'cross-origin request refused' });
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// --- API ------------------------------------------------------------------
|
|
271
|
+
if (p === '/api/state') return json(res, 200, { ok: true, ...hub.snapshot() });
|
|
272
|
+
|
|
273
|
+
// The session list for the left rail. `listSavedSessions` already powers the
|
|
274
|
+
// daemon's landing page, so a session page shows the same names in the same
|
|
275
|
+
// order whether it was opened standalone or through the daemon. Entries are
|
|
276
|
+
// metadata only — no transcript — so this stays cheap to poll.
|
|
277
|
+
if (p === '/api/sessions') {
|
|
278
|
+
const saved = listSavedSessions(opts.storeDir);
|
|
279
|
+
// Which row is THIS page. It may not be on disk yet (a brand-new session
|
|
280
|
+
// that has not been saved), so the current id is reported separately
|
|
281
|
+
// instead of being inferred from the list.
|
|
282
|
+
const current = String((hub.snapshot().status || {}).session || '');
|
|
283
|
+
return json(res, 200, { ok: true, current, sessions: saved });
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
if (p === '/api/events') return sse(req, res, hub);
|
|
288
|
+
|
|
289
|
+
if (p === '/api/action' && method === 'POST') {
|
|
290
|
+
let body = {};
|
|
291
|
+
try { body = JSON.parse((await readBody(req)) || '{}'); } catch {
|
|
292
|
+
return json(res, 400, { ok: false, error: 'invalid JSON body' });
|
|
293
|
+
}
|
|
294
|
+
const name = String(body.action || '');
|
|
295
|
+
const args = Array.isArray(body.args) ? body.args : [];
|
|
296
|
+
// The security boundary: only these may be invoked from a browser. Kept as
|
|
297
|
+
// an explicit list rather than derived from `hub.actions`, so registering a
|
|
298
|
+
// handler on the hub is never enough on its own to expose it over HTTP.
|
|
299
|
+
const ALLOWED = ALLOWED_ACTIONS;
|
|
300
|
+
if (!ALLOWED.includes(name)) return json(res, 400, { ok: false, error: `unknown action: ${name}` });
|
|
301
|
+
const result = await hub.call(name, ...args);
|
|
302
|
+
return json(res, result.ok ? 200 : 409, result);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// A standalone /web serves exactly ONE session (this process's). The left
|
|
306
|
+
// rail still links to `/s/<id>/` so the SAME page markup works behind the
|
|
307
|
+
// daemon; standalone those links have no route and 404'd. Redirect them back
|
|
308
|
+
// to the root, which is this session's page.
|
|
309
|
+
if (p.startsWith('/s/')) {
|
|
310
|
+
res.writeHead(302, { location: '/' });
|
|
311
|
+
return res.end();
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// --- static client ---------------------------------------------------------
|
|
315
|
+
if (method === 'GET' || method === 'HEAD') {
|
|
316
|
+
const asset = readAsset(p);
|
|
317
|
+
if (!asset) return send(res, 404, t(pickLang(req, url), 'http.notFound'));
|
|
318
|
+
return send(res, 200, method === 'HEAD' ? '' : asset.body, {
|
|
319
|
+
'content-type': asset.type,
|
|
320
|
+
// The client is a handful of files served from disk; a no-store keeps the
|
|
321
|
+
// browser from running a stale UI against a newer server (the mismatch
|
|
322
|
+
// that makes a UI look broken after an update).
|
|
323
|
+
'cache-control': 'no-store',
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
return send(res, 405, t(pickLang(req, url), 'http.badMethod'));
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// --- Server-Sent Events -----------------------------------------------------
|
|
330
|
+
function sse(req, res, hubRef) {
|
|
331
|
+
res.writeHead(200, {
|
|
332
|
+
'content-type': 'text/event-stream; charset=utf-8',
|
|
333
|
+
'cache-control': 'no-store',
|
|
334
|
+
connection: 'keep-alive',
|
|
335
|
+
// Without this a proxy may buffer the whole stream and the UI looks frozen.
|
|
336
|
+
'x-accel-buffering': 'no',
|
|
337
|
+
});
|
|
338
|
+
const write = (obj) => {
|
|
339
|
+
try { res.write(`data: ${JSON.stringify(obj)}\n\n`); } catch { /* closing */ }
|
|
340
|
+
};
|
|
341
|
+
// A late subscriber needs the current view FIRST, then the live stream.
|
|
342
|
+
write({ type: 'snapshot', ...hubRef.snapshot() });
|
|
343
|
+
const off = hubRef.subscribe(write);
|
|
344
|
+
// Comment frames keep the connection alive through idle proxies and let the
|
|
345
|
+
// server notice a dead peer on its own.
|
|
346
|
+
const ping = setInterval(() => { try { res.write(': ping\n\n'); } catch { /* closing */ } }, 20000);
|
|
347
|
+
const done = () => { clearInterval(ping); off(); try { res.end(); } catch { /* already gone */ } };
|
|
348
|
+
req.on('close', done);
|
|
349
|
+
req.on('error', done);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// --- listen -----------------------------------------------------------------
|
|
353
|
+
const port = await new Promise((resolve, reject) => {
|
|
354
|
+
server.once('error', reject);
|
|
355
|
+
server.listen(opts.port == null ? 0 : opts.port, bindIp, () => resolve(server.address().port));
|
|
356
|
+
}).catch((e) => {
|
|
357
|
+
throw new Error(`could not bind ${bindIp}:${opts.port || 0} — ${e.message}`);
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
const hostForUrl = bindIp.includes(':') ? `[${bindIp}]` : bindIp;
|
|
361
|
+
const url = `http://${hostForUrl}:${port}/`;
|
|
362
|
+
expectedHosts = [`${bindIp}:${port}`, `localhost:${port}`, `127.0.0.1:${port}`];
|
|
363
|
+
if (bindIp === '0.0.0.0' || bindIp === '::') expectedHosts.push(`0.0.0.0:${port}`);
|
|
364
|
+
|
|
365
|
+
return {
|
|
366
|
+
server,
|
|
367
|
+
port,
|
|
368
|
+
host: bindIp,
|
|
369
|
+
url,
|
|
370
|
+
token,
|
|
371
|
+
loopback,
|
|
372
|
+
close() {
|
|
373
|
+
if (typeof wsOff === 'function') { try { wsOff(); } catch { /* best-effort */ } }
|
|
374
|
+
for (const conn of wsTargets) { try { conn.close(); } catch { /* already gone */ } }
|
|
375
|
+
try { server.close(); } catch { /* already closed */ }
|
|
376
|
+
},
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
export { PUBLIC_DIR };
|