herdr-remote 0.2.18 → 0.2.19
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/bin/herdr-remote.js +3 -225
- package/dist/agent-status.js +130 -0
- package/dist/binary-plist.js +157 -0
- package/dist/cli.js +255 -0
- package/dist/config.js +270 -0
- package/dist/connector/agent-watch.js +110 -0
- package/dist/connector/font-server.js +165 -0
- package/dist/connector/host-connector.js +430 -0
- package/dist/connector/lock.js +66 -0
- package/dist/connector/main.js +18 -0
- package/dist/connector/sessions.js +366 -0
- package/dist/connector/update-report.js +67 -0
- package/dist/connector/wire.js +42 -0
- package/{src → dist}/exit-codes.js +1 -4
- package/dist/font-subset.js +110 -0
- package/dist/herdr-api.js +246 -0
- package/dist/herdr-command.js +223 -0
- package/dist/herdr-plugin.js +116 -0
- package/dist/herdr-server.js +189 -0
- package/dist/i18n/en.js +223 -0
- package/dist/i18n/index.js +48 -0
- package/dist/i18n/zh.js +222 -0
- package/dist/keepalive/detached.js +78 -0
- package/dist/keepalive/environment.js +44 -0
- package/dist/keepalive/index.js +110 -0
- package/dist/keepalive/launchd.js +117 -0
- package/dist/keepalive/systemd.js +108 -0
- package/dist/keepalive/types.js +1 -0
- package/dist/lib/process.js +12 -0
- package/dist/lifecycle.js +59 -0
- package/dist/net-interfaces.js +73 -0
- package/dist/pasted-files.js +115 -0
- package/dist/paths.js +62 -0
- package/dist/pty-session.js +96 -0
- package/dist/relay-client.js +106 -0
- package/dist/relay-urls.js +99 -0
- package/dist/runtime.js +106 -0
- package/dist/service.js +260 -0
- package/dist/settings-model.js +298 -0
- package/dist/socket-discovery.js +48 -0
- package/dist/supervisor.js +319 -0
- package/dist/terminal-font/deps.js +73 -0
- package/dist/terminal-font/files.js +354 -0
- package/dist/terminal-font/formats.js +335 -0
- package/dist/terminal-font/index.js +187 -0
- package/dist/terminal-font/readers.js +316 -0
- package/dist/terminal-font/terminals.js +128 -0
- package/dist/terminal-palette.js +339 -0
- package/dist/tui/App.js +195 -0
- package/dist/tui/api.js +79 -0
- package/dist/tui/components/TextField.js +50 -0
- package/dist/tui/components/common.js +80 -0
- package/dist/tui/index.js +36 -0
- package/dist/tui/mouse/index.js +164 -0
- package/dist/tui/mouse/protocol.js +96 -0
- package/dist/tui/mouse/source.js +74 -0
- package/dist/tui/screens/About.js +166 -0
- package/dist/tui/screens/Herdr.js +74 -0
- package/dist/tui/screens/Keepalive.js +84 -0
- package/dist/tui/screens/Overview.js +36 -0
- package/dist/tui/screens/Pair.js +74 -0
- package/dist/tui/screens/Relay.js +242 -0
- package/dist/tui/screens/Services.js +63 -0
- package/dist/tui/screens/Wizard.js +235 -0
- package/dist/tui/terminal.js +39 -0
- package/dist/tui/theme.js +33 -0
- package/dist/updater.js +382 -0
- package/herdr-plugin.toml +1 -1
- package/package.json +8 -8
- package/dist/tui.mjs +0 -5775
- package/src/agent-status.js +0 -139
- package/src/binary-plist.js +0 -146
- package/src/config.js +0 -454
- package/src/font-subset.js +0 -110
- package/src/herdr-api.js +0 -244
- package/src/herdr-command.js +0 -231
- package/src/herdr-plugin.js +0 -130
- package/src/herdr-server.js +0 -201
- package/src/host-connector.js +0 -1069
- package/src/i18n/en.js +0 -240
- package/src/i18n/index.js +0 -56
- package/src/i18n/zh.js +0 -239
- package/src/keepalive.js +0 -460
- package/src/lifecycle.js +0 -62
- package/src/net-interfaces.js +0 -76
- package/src/pasted-files.js +0 -176
- package/src/pty-session.js +0 -92
- package/src/service.js +0 -530
- package/src/settings-model.js +0 -323
- package/src/socket-discovery.js +0 -60
- package/src/state.js +0 -47
- package/src/supervisor.js +0 -250
- package/src/terminal-font.js +0 -1204
- package/src/terminal-palette.js +0 -325
- package/src/updater.js +0 -394
package/src/host-connector.js
DELETED
|
@@ -1,1069 +0,0 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
const fs = require('node:fs');
|
|
4
|
-
const os = require('node:os');
|
|
5
|
-
const path = require('node:path');
|
|
6
|
-
const crypto = require('node:crypto');
|
|
7
|
-
const { WebSocket } = require('ws');
|
|
8
|
-
const { loadConfig, hostWebSocketUrl, resolveHostRelayUrl, stateDir } = require('./config');
|
|
9
|
-
const { ensureDir } = require('./state');
|
|
10
|
-
const { resolveSocketPath, inspectSocket } = require('./socket-discovery');
|
|
11
|
-
const { PtySession } = require('./pty-session');
|
|
12
|
-
const {
|
|
13
|
-
resolveHerdrCommand,
|
|
14
|
-
verifyHerdrCommand,
|
|
15
|
-
herdrNotFoundMessage,
|
|
16
|
-
herdrOutdatedMessage,
|
|
17
|
-
herdrVersion,
|
|
18
|
-
} = require('./herdr-command');
|
|
19
|
-
// The wire format lives in the relay package so both ends of the protocol are
|
|
20
|
-
// generated from one definition.
|
|
21
|
-
const {
|
|
22
|
-
packStreamFrame,
|
|
23
|
-
unpackStreamFrame,
|
|
24
|
-
packStreamFrameV2,
|
|
25
|
-
FRAME_TYPE_OUTPUT,
|
|
26
|
-
PROTOCOL_VERSION,
|
|
27
|
-
TERMINAL_FONT_CHUNK_BYTES,
|
|
28
|
-
} = require('herdr-remote-relay/protocol');
|
|
29
|
-
const { resolveHostPalette } = require('./terminal-palette');
|
|
30
|
-
const { loadHostTerminalFont, publicTerminalFont, readFontChunk } = require('./terminal-font');
|
|
31
|
-
const { FontSubsetter } = require('./font-subset');
|
|
32
|
-
const { requestHerdr, subscribeHerdr } = require('./herdr-api');
|
|
33
|
-
const { sameSummary, summarizeAgents } = require('./agent-status');
|
|
34
|
-
const { EXIT_REPLACED, EXIT_AUTH_FAILED } = require('./exit-codes');
|
|
35
|
-
const { savePastedFile, cleanPastedDir } = require('./pasted-files');
|
|
36
|
-
const { ensureHerdrServer, probeHerdrServer } = require('./herdr-server');
|
|
37
|
-
const {
|
|
38
|
-
checkForUpdate,
|
|
39
|
-
compareVersions,
|
|
40
|
-
currentVersion,
|
|
41
|
-
installedVersionOnDisk,
|
|
42
|
-
updateChecksEnabled,
|
|
43
|
-
} = require('./updater');
|
|
44
|
-
|
|
45
|
-
/** Windows opening within this long of a check reuse its answer. */
|
|
46
|
-
const UPDATE_RECHECK_MS = 60_000;
|
|
47
|
-
|
|
48
|
-
/** Cut fonts kept for the slices a browser has yet to fetch, by total size. */
|
|
49
|
-
const FONT_SUBSET_CACHE_BYTES = 48 * 1024 * 1024;
|
|
50
|
-
|
|
51
|
-
/** How often the agent summary is re-read while a browser is watching. */
|
|
52
|
-
const AGENT_STATUS_POLL_MS = 5_000;
|
|
53
|
-
const AGENT_STATUS_EVENT_DEBOUNCE_MS = 150;
|
|
54
|
-
const AGENT_STATUS_SUBSCRIPTIONS = [
|
|
55
|
-
{ type: 'pane.focused' },
|
|
56
|
-
{ type: 'tab.focused' },
|
|
57
|
-
{ type: 'workspace.focused' },
|
|
58
|
-
{ type: 'pane.agent_detected' },
|
|
59
|
-
];
|
|
60
|
-
|
|
61
|
-
function randomId(prefix) {
|
|
62
|
-
return `${prefix}-${crypto.randomBytes(9).toString('base64url')}`;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
function sendJson(ws, payload) {
|
|
66
|
-
if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(payload));
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
function closeSocket(ws, reason = 'host connector stopping') {
|
|
70
|
-
if (!ws || ws.readyState === WebSocket.CLOSED || ws.readyState === WebSocket.CLOSING) return;
|
|
71
|
-
try { ws.close(1000, reason); } catch {}
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
function terminateSocket(ws) {
|
|
75
|
-
if (!ws || ws.readyState === WebSocket.CLOSED) return;
|
|
76
|
-
try {
|
|
77
|
-
if (typeof ws.terminate === 'function') {
|
|
78
|
-
ws.terminate();
|
|
79
|
-
} else if (typeof ws.destroy === 'function') {
|
|
80
|
-
ws.destroy();
|
|
81
|
-
} else if (typeof ws.close === 'function') {
|
|
82
|
-
ws.close();
|
|
83
|
-
}
|
|
84
|
-
} catch {}
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
function pidAlive(pid) {
|
|
88
|
-
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
89
|
-
try {
|
|
90
|
-
process.kill(pid, 0);
|
|
91
|
-
return true;
|
|
92
|
-
} catch (error) {
|
|
93
|
-
return error.code === 'EPERM';
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
class HostConnector {
|
|
98
|
-
/**
|
|
99
|
-
* A session that dies this fast never really started. Anything longer is a
|
|
100
|
-
* session the user actually used and then left.
|
|
101
|
-
*/
|
|
102
|
-
static FAST_FAILURE_MS = 1500;
|
|
103
|
-
/** How many broken starts in a row before we stop calling them exits. */
|
|
104
|
-
static FAST_FAILURE_LIMIT = 3;
|
|
105
|
-
|
|
106
|
-
constructor(options = {}) {
|
|
107
|
-
const config = options.config || loadConfig();
|
|
108
|
-
this.config = config;
|
|
109
|
-
this.relayUrl = options.relayUrl
|
|
110
|
-
|| (process.env.RELAY_URL ? hostWebSocketUrl(process.env.RELAY_URL) : resolveHostRelayUrl(config));
|
|
111
|
-
this.hostId = options.hostId || process.env.RELAY_HOST_ID || randomId('host');
|
|
112
|
-
this.hostToken = options.hostToken || process.env.RELAY_HOST_TOKEN || '';
|
|
113
|
-
// Optional; a relay without a password accepts any workstation.
|
|
114
|
-
this.relayPassword = options.relayPassword ?? process.env.RELAY_PASSWORD ?? '';
|
|
115
|
-
this.socketPath = options.socketPath || resolveSocketPath(config.herdr.socketPath);
|
|
116
|
-
// Where to look for Herdr when the command has to be resolved again; the
|
|
117
|
-
// real environment unless a caller narrows it.
|
|
118
|
-
this.herdrLookup = options.herdrLookup || {};
|
|
119
|
-
this.herdrCommand = options.herdrCommand || resolveHerdrCommand(this.herdrLookup);
|
|
120
|
-
this.readHerdrVersion = options.readHerdrVersion || herdrVersion;
|
|
121
|
-
this.herdrVersionWarned = false;
|
|
122
|
-
this.herdrArgs = options.herdrArgs || config.herdr.args;
|
|
123
|
-
this.cwd = options.cwd || config.herdr.cwd;
|
|
124
|
-
/**
|
|
125
|
-
* What the workstation's own terminal looks like. A browser has no way to
|
|
126
|
-
* know it — the PTY carries color indices, not colors — so the host reports
|
|
127
|
-
* it and the browser renders the session the way the workstation sees it.
|
|
128
|
-
* Normally captured by the start path that still had a terminal and passed
|
|
129
|
-
* down in the environment; `null` when nobody could ask.
|
|
130
|
-
*/
|
|
131
|
-
this.terminalPalette = options.terminalPalette !== undefined
|
|
132
|
-
? options.terminalPalette
|
|
133
|
-
: resolveHostPalette();
|
|
134
|
-
/**
|
|
135
|
-
* The terminal's font, with the local files behind it. Only the family,
|
|
136
|
-
* size and file hashes leave this process; a browser that lacks the font
|
|
137
|
-
* fetches the files by hash, one chunk at a time. Injectable for tests.
|
|
138
|
-
*/
|
|
139
|
-
this.loadTerminalFont = options.loadTerminalFont || loadHostTerminalFont;
|
|
140
|
-
this.terminalFont = options.terminalFont !== undefined
|
|
141
|
-
? options.terminalFont
|
|
142
|
-
: this.loadTerminalFont();
|
|
143
|
-
/** Cuts characters out of large fonts; see font-subset.js. */
|
|
144
|
-
this.fontSubsetter = options.fontSubsetter || new FontSubsetter();
|
|
145
|
-
/** Subsets too large for one message, by hash, until fetched. */
|
|
146
|
-
this.fontSubsets = new Map();
|
|
147
|
-
this.ws = null;
|
|
148
|
-
this.sessions = new Map();
|
|
149
|
-
this.streamIndexToId = new Map();
|
|
150
|
-
this.PtySession = options.PtySession || PtySession;
|
|
151
|
-
/** Is Herdr's server answering, and how to start it; injectable for tests. */
|
|
152
|
-
this.probeHerdr = options.probeHerdr || probeHerdrServer;
|
|
153
|
-
this.ensureHerdr = options.ensureHerdr || ensureHerdrServer;
|
|
154
|
-
this.herdrLogPath = options.herdrLogPath || path.join(stateDir(), 'herdr-server.log');
|
|
155
|
-
/** Windows whose session waits on a Herdr that is not running, by stream. */
|
|
156
|
-
this.waitingForHerdr = new Map();
|
|
157
|
-
/** Session starts between their liveness check and their PTY, by stream. */
|
|
158
|
-
this.probingStarts = new Map();
|
|
159
|
-
/** One start at a time, however many windows asked for it. */
|
|
160
|
-
this.herdrLaunch = null;
|
|
161
|
-
/**
|
|
162
|
-
* Whether a newer herdr-remote is out, asked when a window opens. The
|
|
163
|
-
* version this process runs is fixed at start; the one on disk moves when
|
|
164
|
-
* somebody updates without restarting.
|
|
165
|
-
*/
|
|
166
|
-
this.checkUpdate = options.checkUpdate !== undefined
|
|
167
|
-
? options.checkUpdate
|
|
168
|
-
: (updateChecksEnabled() ? checkForUpdate : null);
|
|
169
|
-
this.readInstalledVersion = options.readInstalledVersion || installedVersionOnDisk;
|
|
170
|
-
this.runningVersion = options.runningVersion || currentVersion();
|
|
171
|
-
this.updateCheckedAt = 0;
|
|
172
|
-
this.updateCheck = null;
|
|
173
|
-
/** The side channel that answers "does anything need me?"; see startAgentStatus. */
|
|
174
|
-
this.requestHerdr = options.requestHerdr || requestHerdr;
|
|
175
|
-
this.subscribeHerdr = options.subscribeHerdr || subscribeHerdr;
|
|
176
|
-
this.agentStatusTimer = null;
|
|
177
|
-
this.agentStatusDebounceTimer = null;
|
|
178
|
-
this.agentStatusSubscription = null;
|
|
179
|
-
this.agentStatusPending = false;
|
|
180
|
-
this.lastAgentSummary = null;
|
|
181
|
-
this.fastFailures = 0;
|
|
182
|
-
this.reconnectTimer = null;
|
|
183
|
-
this.heartbeatTimer = null;
|
|
184
|
-
this.keepaliveTimer = null;
|
|
185
|
-
this.reconnectAttempts = 0;
|
|
186
|
-
this.clientCount = 0;
|
|
187
|
-
this.legacyHeartbeat = false;
|
|
188
|
-
this.ready = false;
|
|
189
|
-
this.authFailure = false;
|
|
190
|
-
this.stopping = false;
|
|
191
|
-
this.lockPath = options.lockPath
|
|
192
|
-
|| process.env.HERDR_REMOTE_HOST_LOCK
|
|
193
|
-
|| path.join(stateDir(), 'host-connector.lock');
|
|
194
|
-
this.lockFd = null;
|
|
195
|
-
try { cleanPastedDir(); } catch {}
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
acquireLock() {
|
|
199
|
-
if (this.lockFd !== null) return;
|
|
200
|
-
ensureDir(path.dirname(this.lockPath));
|
|
201
|
-
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
202
|
-
try {
|
|
203
|
-
const fd = fs.openSync(this.lockPath, 'wx', 0o600);
|
|
204
|
-
fs.writeFileSync(fd, `${JSON.stringify({ pid: process.pid, hostId: this.hostId, startedAt: new Date().toISOString() })}\n`);
|
|
205
|
-
this.lockFd = fd;
|
|
206
|
-
return;
|
|
207
|
-
} catch (error) {
|
|
208
|
-
if (error.code !== 'EEXIST') throw error;
|
|
209
|
-
let owner = null;
|
|
210
|
-
try { owner = JSON.parse(fs.readFileSync(this.lockPath, 'utf8')); } catch {}
|
|
211
|
-
if (owner && pidAlive(owner.pid)) {
|
|
212
|
-
const duplicate = new Error(`another host connector is already running (pid ${owner.pid})`);
|
|
213
|
-
duplicate.code = 'HOST_ALREADY_RUNNING';
|
|
214
|
-
throw duplicate;
|
|
215
|
-
}
|
|
216
|
-
try { fs.rmSync(this.lockPath, { force: true }); } catch {}
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
const stale = new Error('could not acquire host connector lock');
|
|
220
|
-
stale.code = 'HOST_LOCK_FAILED';
|
|
221
|
-
throw stale;
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
releaseLock() {
|
|
225
|
-
const owned = this.lockFd !== null;
|
|
226
|
-
if (this.lockFd !== null) {
|
|
227
|
-
try { fs.closeSync(this.lockFd); } catch {}
|
|
228
|
-
this.lockFd = null;
|
|
229
|
-
}
|
|
230
|
-
if (!owned) return;
|
|
231
|
-
try {
|
|
232
|
-
const owner = JSON.parse(fs.readFileSync(this.lockPath, 'utf8'));
|
|
233
|
-
if (owner.pid !== process.pid) return;
|
|
234
|
-
} catch {}
|
|
235
|
-
try { fs.rmSync(this.lockPath, { force: true }); } catch {}
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
start() {
|
|
239
|
-
if (!this.hostToken) {
|
|
240
|
-
throw new Error('RELAY_HOST_TOKEN is required');
|
|
241
|
-
}
|
|
242
|
-
this.acquireLock();
|
|
243
|
-
this.stopping = false;
|
|
244
|
-
this.connect();
|
|
245
|
-
// Off unless the user switched it on: Herdr is theirs to start.
|
|
246
|
-
if (this.config.herdr?.autoStart) {
|
|
247
|
-
this.startHerdr().catch((error) => {
|
|
248
|
-
process.stderr.write(`herdr-remote host connector: could not start Herdr: ${error.message}\n`);
|
|
249
|
-
});
|
|
250
|
-
}
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
stop() {
|
|
254
|
-
this.stopping = true;
|
|
255
|
-
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
|
|
256
|
-
if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
|
|
257
|
-
this.stopKeepalive();
|
|
258
|
-
this.stopAgentStatus();
|
|
259
|
-
this.reconnectTimer = null;
|
|
260
|
-
this.heartbeatTimer = null;
|
|
261
|
-
sendJson(this.ws, { type: 'host_shutdown' });
|
|
262
|
-
this.destroySessions();
|
|
263
|
-
closeSocket(this.ws, 'host_shutdown');
|
|
264
|
-
this.ws = null;
|
|
265
|
-
this.ready = false;
|
|
266
|
-
this.clientCount = 0;
|
|
267
|
-
this.legacyHeartbeat = false;
|
|
268
|
-
this.releaseLock();
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
connect() {
|
|
272
|
-
if (this.stopping || (this.ws && [WebSocket.OPEN, WebSocket.CONNECTING].includes(this.ws.readyState))) return;
|
|
273
|
-
this.stopKeepalive();
|
|
274
|
-
let ws;
|
|
275
|
-
try {
|
|
276
|
-
ws = new WebSocket(this.relayUrl);
|
|
277
|
-
} catch (error) {
|
|
278
|
-
this.scheduleReconnect(error);
|
|
279
|
-
return;
|
|
280
|
-
}
|
|
281
|
-
this.ws = ws;
|
|
282
|
-
ws.isAlive = true;
|
|
283
|
-
ws.on('open', () => {
|
|
284
|
-
// Disable Nagle's algorithm to prevent small frames from stalling ~40ms when delayed ACK is active.
|
|
285
|
-
try { ws._socket?.setNoDelay(true); } catch {}
|
|
286
|
-
ws.isAlive = true;
|
|
287
|
-
this.ready = false;
|
|
288
|
-
this.authFailure = false;
|
|
289
|
-
sendJson(ws, {
|
|
290
|
-
type: 'host_hello',
|
|
291
|
-
protocol: PROTOCOL_VERSION,
|
|
292
|
-
hostId: this.hostId,
|
|
293
|
-
token: this.hostToken,
|
|
294
|
-
password: this.relayPassword || null,
|
|
295
|
-
hostname: os.hostname(),
|
|
296
|
-
platform: process.platform,
|
|
297
|
-
arch: process.arch,
|
|
298
|
-
terminalPalette: this.terminalPalette || null,
|
|
299
|
-
terminalFont: publicTerminalFont(this.terminalFont),
|
|
300
|
-
capabilities: ['host_handoff', 'idle_heartbeat', 'binary_frame_v2'],
|
|
301
|
-
});
|
|
302
|
-
// The relay sends host_ready with the current browser count. No business
|
|
303
|
-
// heartbeat is started until that message says somebody is watching.
|
|
304
|
-
});
|
|
305
|
-
ws.on('pong', () => { ws.isAlive = true; });
|
|
306
|
-
ws.on('message', (raw, isBinary) => this.handleMessage(raw, isBinary));
|
|
307
|
-
ws.on('close', (code, rawReason) => {
|
|
308
|
-
// A replacement socket may be live while an older socket is still
|
|
309
|
-
// delivering its close event. Never let that stale event destroy the new
|
|
310
|
-
// session or clear its heartbeat timer.
|
|
311
|
-
if (this.ws !== ws) return;
|
|
312
|
-
this.ws = null;
|
|
313
|
-
if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
|
|
314
|
-
this.heartbeatTimer = null;
|
|
315
|
-
this.stopKeepalive();
|
|
316
|
-
// Nothing to report to, so stop asking Herdr. A reconnect brings the
|
|
317
|
-
// watcher back with the relay's next client count.
|
|
318
|
-
this.stopAgentStatus();
|
|
319
|
-
this.ready = false;
|
|
320
|
-
this.clientCount = 0;
|
|
321
|
-
this.legacyHeartbeat = false;
|
|
322
|
-
this.destroySessions();
|
|
323
|
-
|
|
324
|
-
// Another connector has claimed this workstation. Reconnecting would just
|
|
325
|
-
// displace it in turn, and each swap drops every attached browser, so
|
|
326
|
-
// stand down instead of fighting for the slot.
|
|
327
|
-
const reason = rawReason ? rawReason.toString() : '';
|
|
328
|
-
if (reason === 'host_replaced') {
|
|
329
|
-
this.stopping = true;
|
|
330
|
-
process.stderr.write('herdr-remote host connector: another instance took over this workstation; exiting\n');
|
|
331
|
-
this.stop();
|
|
332
|
-
process.exit(EXIT_REPLACED);
|
|
333
|
-
}
|
|
334
|
-
if (this.authFailure) {
|
|
335
|
-
this.stopping = true;
|
|
336
|
-
process.stderr.write('herdr-remote host connector: authentication failed; update relay credentials and restart the service\n');
|
|
337
|
-
this.releaseLock();
|
|
338
|
-
process.exit(EXIT_AUTH_FAILED);
|
|
339
|
-
}
|
|
340
|
-
this.scheduleReconnect();
|
|
341
|
-
});
|
|
342
|
-
ws.on('error', (error) => {
|
|
343
|
-
process.stderr.write(`herdr-remote host connector: ${error.message}\n`);
|
|
344
|
-
});
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
scheduleReconnect(error = null) {
|
|
348
|
-
if (this.stopping || this.reconnectTimer) return;
|
|
349
|
-
if (error) process.stderr.write(`herdr-remote host connector: ${error.message}\n`);
|
|
350
|
-
const delay = Math.min(30000, 500 * (1.5 ** this.reconnectAttempts) + Math.floor(Math.random() * 250));
|
|
351
|
-
this.reconnectAttempts += 1;
|
|
352
|
-
this.reconnectTimer = setTimeout(() => {
|
|
353
|
-
this.reconnectTimer = null;
|
|
354
|
-
this.connect();
|
|
355
|
-
}, delay);
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
handleMessage(raw, isBinary) {
|
|
359
|
-
if (isBinary) {
|
|
360
|
-
let frame;
|
|
361
|
-
try {
|
|
362
|
-
frame = unpackStreamFrame(raw);
|
|
363
|
-
} catch (error) {
|
|
364
|
-
process.stderr.write(`herdr-remote host connector: invalid relay frame: ${error.message}\n`);
|
|
365
|
-
return;
|
|
366
|
-
}
|
|
367
|
-
if (frame.type === 'input') {
|
|
368
|
-
const streamId = frame.version === 2 ? this.streamIndexToId.get(frame.streamIndex) : frame.streamId;
|
|
369
|
-
if (streamId) this.sessions.get(streamId)?.pty.write(frame.payload);
|
|
370
|
-
}
|
|
371
|
-
return;
|
|
372
|
-
}
|
|
373
|
-
let message;
|
|
374
|
-
try { message = JSON.parse(raw.toString('utf8')); } catch { return; }
|
|
375
|
-
// A relay-level error (no clientId) is a rejected handshake — a wrong relay
|
|
376
|
-
// password, or a host token the relay does not recognise. Without this the
|
|
377
|
-
// connection just closed silently and reconnected forever, leaving the user
|
|
378
|
-
// with an empty log and no idea what was wrong.
|
|
379
|
-
if (message.type === 'error' && !message.clientId) {
|
|
380
|
-
this.authFailure = ['relay_password_required', 'host_auth_failed', 'invalid_host_credentials'].includes(message.code);
|
|
381
|
-
process.stderr.write(`herdr-remote host connector: relay rejected the connection: ${message.message || message.code}\n`);
|
|
382
|
-
return;
|
|
383
|
-
}
|
|
384
|
-
if (message.type === 'host_ready') {
|
|
385
|
-
this.ready = true;
|
|
386
|
-
this.reconnectAttempts = 0;
|
|
387
|
-
this.startKeepalive();
|
|
388
|
-
if (Object.hasOwn(message, 'clientCount')) {
|
|
389
|
-
this.legacyHeartbeat = false;
|
|
390
|
-
this.setClientCount(message.clientCount);
|
|
391
|
-
} else {
|
|
392
|
-
// An older relay does not know client_count. Keep its historical
|
|
393
|
-
// telemetry behavior so rolling upgrades do not silently lose status.
|
|
394
|
-
this.legacyHeartbeat = true;
|
|
395
|
-
this.sendHeartbeat(true);
|
|
396
|
-
this.heartbeatTimer = setInterval(() => this.sendHeartbeat(), this.config.cleanup.heartbeatIntervalMs);
|
|
397
|
-
}
|
|
398
|
-
return;
|
|
399
|
-
}
|
|
400
|
-
if (message.type === 'client_count') {
|
|
401
|
-
this.setClientCount(message.clientCount);
|
|
402
|
-
return;
|
|
403
|
-
}
|
|
404
|
-
if (message.type === 'session_start') return this.startSession(message);
|
|
405
|
-
if (message.type === 'herdr_start') return this.handleHerdrStart(message);
|
|
406
|
-
if (message.type === 'session_stop') this.stopSession(message.clientId || message.streamId);
|
|
407
|
-
else if (message.type === 'resize') this.resizeSession(message);
|
|
408
|
-
else if (message.type === 'paste_file') this.handlePasteFile(message);
|
|
409
|
-
else if (message.type === 'host_font_chunk_request') this.handleFontChunkRequest(message);
|
|
410
|
-
else if (message.type === 'host_font_subset_request') this.handleFontSubsetRequest(message);
|
|
411
|
-
else if (message.type === 'host_font_refresh') this.handleFontRefresh();
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
/**
|
|
415
|
-
* A service started at boot runs before anything has seen a terminal, so it
|
|
416
|
-
* starts with no font. The first `herdr-remote start` from a terminal (or
|
|
417
|
-
* from Herdr's plugin hook) writes one down; the next window to open picks
|
|
418
|
-
* it up here instead of waiting for a restart.
|
|
419
|
-
*/
|
|
420
|
-
pickUpTerminalFont() {
|
|
421
|
-
if (this.terminalFont) return;
|
|
422
|
-
let next = null;
|
|
423
|
-
try {
|
|
424
|
-
next = this.loadTerminalFont();
|
|
425
|
-
} catch {
|
|
426
|
-
return;
|
|
427
|
-
}
|
|
428
|
-
if (!next) return;
|
|
429
|
-
this.terminalFont = next;
|
|
430
|
-
sendJson(this.ws, { type: 'terminal_font', terminalFont: publicTerminalFont(next) });
|
|
431
|
-
}
|
|
432
|
-
|
|
433
|
-
/** One slice of a terminal font file, for the window that asked for it. */
|
|
434
|
-
handleFontChunkRequest(message) {
|
|
435
|
-
const streamId = message.clientId || message.streamId;
|
|
436
|
-
const chunk = this.subsetChunk(message.sha256, message.index)
|
|
437
|
-
|| readFontChunk(this.terminalFont, message.sha256, message.index);
|
|
438
|
-
if (!chunk) {
|
|
439
|
-
sendJson(this.ws, {
|
|
440
|
-
type: 'error',
|
|
441
|
-
clientId: streamId,
|
|
442
|
-
code: 'host_font_unavailable',
|
|
443
|
-
message: 'The terminal font changed or is no longer on this workstation',
|
|
444
|
-
});
|
|
445
|
-
return;
|
|
446
|
-
}
|
|
447
|
-
sendJson(this.ws, {
|
|
448
|
-
type: 'host_font_chunk',
|
|
449
|
-
clientId: streamId,
|
|
450
|
-
sha256: message.sha256,
|
|
451
|
-
index: message.index,
|
|
452
|
-
total: chunk.total,
|
|
453
|
-
dataBase64: chunk.data.toString('base64'),
|
|
454
|
-
});
|
|
455
|
-
}
|
|
456
|
-
|
|
457
|
-
/** A slice of a cut font still held for the browser that asked for it. */
|
|
458
|
-
subsetChunk(sha256, index) {
|
|
459
|
-
const data = this.fontSubsets.get(sha256);
|
|
460
|
-
if (!data) return null;
|
|
461
|
-
const total = Math.ceil(data.length / TERMINAL_FONT_CHUNK_BYTES);
|
|
462
|
-
if (!Number.isInteger(index) || index < 0 || index >= total) return null;
|
|
463
|
-
const start = index * TERMINAL_FONT_CHUNK_BYTES;
|
|
464
|
-
return { data: data.subarray(start, start + TERMINAL_FONT_CHUNK_BYTES), total };
|
|
465
|
-
}
|
|
466
|
-
|
|
467
|
-
/**
|
|
468
|
-
* The characters a window is about to draw, cut out of a large font (CJK).
|
|
469
|
-
* A small result travels in the answer itself; a large one (the common
|
|
470
|
-
* characters fetched up front) is held here and pulled in slices.
|
|
471
|
-
*/
|
|
472
|
-
handleFontSubsetRequest(message) {
|
|
473
|
-
const streamId = message.clientId || message.streamId;
|
|
474
|
-
const source = this.terminalFont?.subsets?.find((candidate) => candidate.sha256 === message.sha256);
|
|
475
|
-
const fail = (code, text) => sendJson(this.ws, { type: 'error', clientId: streamId, code, message: text });
|
|
476
|
-
if (!source) {
|
|
477
|
-
fail('host_font_unavailable', 'The terminal font changed or is no longer on this workstation');
|
|
478
|
-
return;
|
|
479
|
-
}
|
|
480
|
-
let stat;
|
|
481
|
-
try { stat = fs.statSync(source.path); } catch {}
|
|
482
|
-
if (!stat || stat.size !== source.bytes || stat.mtimeMs !== source.mtimeMs) {
|
|
483
|
-
fail('host_font_unavailable', 'The terminal font changed or is no longer on this workstation');
|
|
484
|
-
return;
|
|
485
|
-
}
|
|
486
|
-
const codepoints = [...new Set(Array.from(String(message.text || ''), (char) => char.codePointAt(0)))];
|
|
487
|
-
let data;
|
|
488
|
-
try {
|
|
489
|
-
data = this.fontSubsetter.subset(source, codepoints);
|
|
490
|
-
} catch (error) {
|
|
491
|
-
fail('host_font_subset_failed', error.message || 'The font could not be subset');
|
|
492
|
-
return;
|
|
493
|
-
}
|
|
494
|
-
const subsetSha = crypto.createHash('sha256').update(data).digest('hex');
|
|
495
|
-
const answer = {
|
|
496
|
-
type: 'host_font_subset_ready',
|
|
497
|
-
clientId: streamId,
|
|
498
|
-
requestId: message.requestId,
|
|
499
|
-
sha256: source.sha256,
|
|
500
|
-
subsetSha,
|
|
501
|
-
bytes: data.length,
|
|
502
|
-
};
|
|
503
|
-
if (data.length <= TERMINAL_FONT_CHUNK_BYTES) {
|
|
504
|
-
answer.dataBase64 = data.toString('base64');
|
|
505
|
-
} else {
|
|
506
|
-
this.fontSubsets.delete(subsetSha);
|
|
507
|
-
this.fontSubsets.set(subsetSha, data);
|
|
508
|
-
let held = [...this.fontSubsets.values()].reduce((sum, item) => sum + item.length, 0);
|
|
509
|
-
for (const [key, item] of this.fontSubsets) {
|
|
510
|
-
if (held <= FONT_SUBSET_CACHE_BYTES || key === subsetSha) break;
|
|
511
|
-
this.fontSubsets.delete(key);
|
|
512
|
-
held -= item.length;
|
|
513
|
-
}
|
|
514
|
-
}
|
|
515
|
-
sendJson(this.ws, answer);
|
|
516
|
-
}
|
|
517
|
-
|
|
518
|
-
/**
|
|
519
|
-
* Read the terminal's font settings again, at a browser's request. Every
|
|
520
|
-
* window of this workstation hears the answer, since they all draw with it.
|
|
521
|
-
*/
|
|
522
|
-
handleFontRefresh() {
|
|
523
|
-
try {
|
|
524
|
-
const next = this.loadTerminalFont({ refresh: true });
|
|
525
|
-
if (next) this.terminalFont = next;
|
|
526
|
-
} catch (error) {
|
|
527
|
-
process.stderr.write(`herdr-remote host connector: could not read the terminal font: ${error.message}\n`);
|
|
528
|
-
}
|
|
529
|
-
sendJson(this.ws, { type: 'terminal_font', terminalFont: publicTerminalFont(this.terminalFont) });
|
|
530
|
-
}
|
|
531
|
-
|
|
532
|
-
/**
|
|
533
|
-
* Confirm Herdr is really there before the name reaches `pty.spawn`.
|
|
534
|
-
*
|
|
535
|
-
* node-pty resolves the command with `execvp(3)` inside the forked child, so
|
|
536
|
-
* a missing binary is not a spawn error anyone can catch: the child writes
|
|
537
|
-
* "execvp(3) failed.: No such file or directory" into the PTY and exits. The
|
|
538
|
-
* `session_exit` that follows makes the relay drop the browser's socket, the
|
|
539
|
-
* browser reconnects into the same broken start, and that is the reconnect
|
|
540
|
-
* loop of issue #1. Refusing to start keeps the socket up and puts the actual
|
|
541
|
-
* reason in front of the user.
|
|
542
|
-
*
|
|
543
|
-
* Re-resolved per session rather than cached from the constructor: a service
|
|
544
|
-
* that started before Herdr was installed should pick it up without a restart.
|
|
545
|
-
*/
|
|
546
|
-
ensureHerdrCommand() {
|
|
547
|
-
const resolved = verifyHerdrCommand(this.herdrCommand, this.herdrLookup);
|
|
548
|
-
if (resolved.found) {
|
|
549
|
-
this.herdrCommand = resolved.command;
|
|
550
|
-
this.warnIfHerdrOutdated();
|
|
551
|
-
return null;
|
|
552
|
-
}
|
|
553
|
-
return herdrNotFoundMessage(this.herdrLookup);
|
|
554
|
-
}
|
|
555
|
-
|
|
556
|
-
/**
|
|
557
|
-
* Say once that the installed Herdr is older than this release expects.
|
|
558
|
-
*
|
|
559
|
-
* Never a refusal to start. An older Herdr still runs — it just misbehaves in
|
|
560
|
-
* ways a browser window notices — and the comment on `startSession` explains
|
|
561
|
-
* what a refused start does to a reconnecting browser.
|
|
562
|
-
*/
|
|
563
|
-
warnIfHerdrOutdated() {
|
|
564
|
-
if (this.herdrVersionWarned) return;
|
|
565
|
-
const installed = this.readHerdrVersion({ command: this.herdrCommand });
|
|
566
|
-
if (installed.supported) {
|
|
567
|
-
// A version we could not read is checked again on the next session: the
|
|
568
|
-
// install may simply not have been finished writing.
|
|
569
|
-
if (installed.version) this.herdrVersionWarned = true;
|
|
570
|
-
return;
|
|
571
|
-
}
|
|
572
|
-
this.herdrVersionWarned = true;
|
|
573
|
-
process.stderr.write(`herdr-remote host connector: ${herdrOutdatedMessage(installed.version)}\n`);
|
|
574
|
-
}
|
|
575
|
-
|
|
576
|
-
startSession(message) {
|
|
577
|
-
const streamId = typeof message.streamId === 'string' ? message.streamId : message.clientId;
|
|
578
|
-
if (!streamId) return undefined;
|
|
579
|
-
this.stopSession(streamId);
|
|
580
|
-
// A window opening is when a person is looking; that is when to ask.
|
|
581
|
-
this.reportUpdateStatus();
|
|
582
|
-
this.pickUpTerminalFont();
|
|
583
|
-
const missingHerdr = this.ensureHerdrCommand();
|
|
584
|
-
if (missingHerdr) {
|
|
585
|
-
process.stderr.write(`herdr-remote host connector: ${missingHerdr}\n`);
|
|
586
|
-
sendJson(this.ws, { type: 'error', clientId: streamId, code: 'herdr_not_found', message: missingHerdr });
|
|
587
|
-
return undefined;
|
|
588
|
-
}
|
|
589
|
-
const socketInfo = inspectSocket(this.socketPath);
|
|
590
|
-
if (!socketInfo.ok && !socketInfo.missing) {
|
|
591
|
-
sendJson(this.ws, { type: 'error', clientId: streamId, code: 'herdr_socket_unavailable', message: socketInfo.reason });
|
|
592
|
-
return undefined;
|
|
593
|
-
}
|
|
594
|
-
if (!socketInfo.ok) {
|
|
595
|
-
this.waitForHerdr(streamId, message);
|
|
596
|
-
return undefined;
|
|
597
|
-
}
|
|
598
|
-
// The socket file alone proves nothing: a `herdr` client pointed at a dead
|
|
599
|
-
// one starts a server of its own, inside this process's service, where the
|
|
600
|
-
// next restart of herdr-remote would kill it. Only a server that accepts a
|
|
601
|
-
// connection gets a client.
|
|
602
|
-
const pending = { message };
|
|
603
|
-
this.probingStarts.set(streamId, pending);
|
|
604
|
-
return this.probeHerdr(this.socketPath).then((probe) => {
|
|
605
|
-
// Stopped, or started again, while the answer was in flight.
|
|
606
|
-
if (this.probingStarts.get(streamId) !== pending) return;
|
|
607
|
-
this.probingStarts.delete(streamId);
|
|
608
|
-
if (probe.state === 'running') this.spawnSession(pending.message);
|
|
609
|
-
else if (probe.state === 'stopped') this.waitForHerdr(streamId, pending.message);
|
|
610
|
-
else sendJson(this.ws, { type: 'error', clientId: streamId, code: 'herdr_socket_unavailable', message: probe.reason });
|
|
611
|
-
});
|
|
612
|
-
}
|
|
613
|
-
|
|
614
|
-
/**
|
|
615
|
-
* Tell every window whether this workstation's herdr-remote is behind the
|
|
616
|
-
* newest release. Asked at most once a minute however many windows open;
|
|
617
|
-
* the relay keeps the last answer for windows that open in between. A failed
|
|
618
|
-
* check says nothing: offline is not news.
|
|
619
|
-
*/
|
|
620
|
-
reportUpdateStatus({ now = Date.now() } = {}) {
|
|
621
|
-
if (!this.checkUpdate || this.updateCheck) return this.updateCheck;
|
|
622
|
-
if (now - this.updateCheckedAt < UPDATE_RECHECK_MS) return null;
|
|
623
|
-
this.updateCheckedAt = now;
|
|
624
|
-
this.updateCheck = Promise.resolve()
|
|
625
|
-
.then(() => this.checkUpdate())
|
|
626
|
-
.then((result) => {
|
|
627
|
-
if (!result?.ok || !result.latest) return;
|
|
628
|
-
const running = this.runningVersion;
|
|
629
|
-
const installed = this.readInstalledVersion() || running;
|
|
630
|
-
sendJson(this.ws, {
|
|
631
|
-
type: 'update_status',
|
|
632
|
-
current: running,
|
|
633
|
-
installed,
|
|
634
|
-
latest: result.latest,
|
|
635
|
-
updateAvailable: compareVersions(result.latest, running) > 0,
|
|
636
|
-
// Updated on disk, still running the old code: a restart finishes it.
|
|
637
|
-
restartPending: compareVersions(installed, running) > 0,
|
|
638
|
-
});
|
|
639
|
-
})
|
|
640
|
-
.catch(() => {})
|
|
641
|
-
.finally(() => { this.updateCheck = null; });
|
|
642
|
-
return this.updateCheck;
|
|
643
|
-
}
|
|
644
|
-
|
|
645
|
-
/**
|
|
646
|
-
* Hold a window's session until Herdr runs, and ask the browser whether it
|
|
647
|
-
* should be started. Nothing starts until somebody says yes.
|
|
648
|
-
*/
|
|
649
|
-
waitForHerdr(streamId, message) {
|
|
650
|
-
this.waitingForHerdr.set(streamId, message);
|
|
651
|
-
sendJson(this.ws, {
|
|
652
|
-
type: 'error',
|
|
653
|
-
clientId: streamId,
|
|
654
|
-
code: 'herdr_not_running',
|
|
655
|
-
message: `Herdr is not running on this workstation (${this.socketPath}).`,
|
|
656
|
-
});
|
|
657
|
-
}
|
|
658
|
-
|
|
659
|
-
/**
|
|
660
|
-
* Start this workstation's Herdr server, once, however many windows or
|
|
661
|
-
* callers ask at the same time.
|
|
662
|
-
*/
|
|
663
|
-
startHerdr() {
|
|
664
|
-
if (!this.herdrLaunch) {
|
|
665
|
-
this.herdrLaunch = Promise.resolve()
|
|
666
|
-
.then(() => {
|
|
667
|
-
const missing = this.ensureHerdrCommand();
|
|
668
|
-
if (missing) throw new Error(missing);
|
|
669
|
-
return this.ensureHerdr({
|
|
670
|
-
command: this.herdrCommand,
|
|
671
|
-
args: this.herdrArgs,
|
|
672
|
-
socketPath: this.socketPath,
|
|
673
|
-
cwd: this.cwd,
|
|
674
|
-
logPath: this.herdrLogPath,
|
|
675
|
-
});
|
|
676
|
-
})
|
|
677
|
-
.finally(() => { this.herdrLaunch = null; });
|
|
678
|
-
}
|
|
679
|
-
return this.herdrLaunch;
|
|
680
|
-
}
|
|
681
|
-
|
|
682
|
-
/**
|
|
683
|
-
* A browser said yes. The relay only ever routes this from a window paired
|
|
684
|
-
* to *this* workstation, so what starts is this user's own Herdr, at the
|
|
685
|
-
* socket this connector is configured for — never one named by the message.
|
|
686
|
-
* Once it runs, every window waiting on it gets its session.
|
|
687
|
-
*/
|
|
688
|
-
async handleHerdrStart(message) {
|
|
689
|
-
const streamId = typeof message.streamId === 'string' ? message.streamId : message.clientId;
|
|
690
|
-
try {
|
|
691
|
-
const result = await this.startHerdr();
|
|
692
|
-
if (result.started) process.stderr.write('herdr-remote host connector: started Herdr at a browser\'s request\n');
|
|
693
|
-
} catch (error) {
|
|
694
|
-
process.stderr.write(`herdr-remote host connector: could not start Herdr: ${error.message}\n`);
|
|
695
|
-
if (streamId) {
|
|
696
|
-
sendJson(this.ws, { type: 'error', clientId: streamId, code: 'herdr_start_failed', message: error.message });
|
|
697
|
-
}
|
|
698
|
-
return;
|
|
699
|
-
}
|
|
700
|
-
const waiting = [...this.waitingForHerdr.values()];
|
|
701
|
-
this.waitingForHerdr.clear();
|
|
702
|
-
for (const held of waiting) this.spawnSession(held);
|
|
703
|
-
}
|
|
704
|
-
|
|
705
|
-
/** Start the PTY for a window once Herdr is known to be running. */
|
|
706
|
-
spawnSession(message) {
|
|
707
|
-
const streamId = typeof message.streamId === 'string' ? message.streamId : message.clientId;
|
|
708
|
-
const streamIndex = typeof message.streamIndex === 'number' ? message.streamIndex : null;
|
|
709
|
-
const pty = new this.PtySession({
|
|
710
|
-
command: this.herdrCommand,
|
|
711
|
-
args: this.herdrArgs,
|
|
712
|
-
cwd: this.cwd,
|
|
713
|
-
socketPath: this.socketPath,
|
|
714
|
-
});
|
|
715
|
-
const session = {
|
|
716
|
-
id: streamId,
|
|
717
|
-
streamIndex,
|
|
718
|
-
pty,
|
|
719
|
-
cols: Number(message.cols) || PtySession.DEFAULT_COLS,
|
|
720
|
-
rows: Number(message.rows) || PtySession.DEFAULT_ROWS,
|
|
721
|
-
createdAt: new Date().toISOString(),
|
|
722
|
-
startedAtMs: Date.now(),
|
|
723
|
-
clientId: streamId,
|
|
724
|
-
pendingOutput: [],
|
|
725
|
-
flushImmediate: null,
|
|
726
|
-
};
|
|
727
|
-
const flushOutput = () => {
|
|
728
|
-
if (session.flushImmediate) {
|
|
729
|
-
clearImmediate(session.flushImmediate);
|
|
730
|
-
session.flushImmediate = null;
|
|
731
|
-
}
|
|
732
|
-
if (session.pendingOutput.length === 0) return;
|
|
733
|
-
const chunks = session.pendingOutput;
|
|
734
|
-
session.pendingOutput = [];
|
|
735
|
-
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
736
|
-
const payload = Buffer.concat(chunks);
|
|
737
|
-
const frame = typeof session.streamIndex === 'number'
|
|
738
|
-
? packStreamFrameV2(FRAME_TYPE_OUTPUT, session.streamIndex, payload)
|
|
739
|
-
: packStreamFrame('output', streamId, payload);
|
|
740
|
-
this.ws.send(frame);
|
|
741
|
-
}
|
|
742
|
-
};
|
|
743
|
-
try {
|
|
744
|
-
pty.start({
|
|
745
|
-
cols: session.cols,
|
|
746
|
-
rows: session.rows,
|
|
747
|
-
onData: (data) => {
|
|
748
|
-
const chunk = Buffer.isBuffer(data) ? data : Buffer.from(data, 'utf8');
|
|
749
|
-
if (chunk.length === 0) return;
|
|
750
|
-
session.pendingOutput.push(chunk);
|
|
751
|
-
if (!session.flushImmediate) {
|
|
752
|
-
// Coalesce burst output from the same tick into a single frame to reduce
|
|
753
|
-
// packet overhead without introducing timer latency.
|
|
754
|
-
session.flushImmediate = setImmediate(flushOutput);
|
|
755
|
-
}
|
|
756
|
-
},
|
|
757
|
-
onExit: ({ exitCode }) => {
|
|
758
|
-
if (this.sessions.get(streamId) !== session) return;
|
|
759
|
-
this.sessions.delete(streamId);
|
|
760
|
-
if (typeof session.streamIndex === 'number') {
|
|
761
|
-
this.streamIndexToId.delete(session.streamIndex);
|
|
762
|
-
}
|
|
763
|
-
if (session.flushImmediate) {
|
|
764
|
-
clearImmediate(session.flushImmediate);
|
|
765
|
-
session.flushImmediate = null;
|
|
766
|
-
}
|
|
767
|
-
session.pendingOutput = [];
|
|
768
|
-
this.reportSessionExit(session, exitCode);
|
|
769
|
-
this.sendHeartbeat();
|
|
770
|
-
},
|
|
771
|
-
});
|
|
772
|
-
} catch (error) {
|
|
773
|
-
if (session.flushImmediate) {
|
|
774
|
-
clearImmediate(session.flushImmediate);
|
|
775
|
-
session.flushImmediate = null;
|
|
776
|
-
}
|
|
777
|
-
session.pendingOutput = [];
|
|
778
|
-
sendJson(this.ws, { type: 'error', clientId: streamId, code: 'pty_start_failed', message: error.message });
|
|
779
|
-
pty.kill();
|
|
780
|
-
return;
|
|
781
|
-
}
|
|
782
|
-
this.sessions.set(streamId, session);
|
|
783
|
-
if (typeof streamIndex === 'number') {
|
|
784
|
-
this.streamIndexToId.set(streamIndex, streamId);
|
|
785
|
-
}
|
|
786
|
-
sendJson(this.ws, { type: 'session_ready', clientId: streamId });
|
|
787
|
-
this.sendHeartbeat();
|
|
788
|
-
}
|
|
789
|
-
|
|
790
|
-
/**
|
|
791
|
-
* Tell the relay how a session ended.
|
|
792
|
-
*
|
|
793
|
-
* `session_exit` is the honest answer for a session that ran, and the relay
|
|
794
|
-
* closes the browser's socket on it so the window can start a fresh one. That
|
|
795
|
-
* is also what turns a start that keeps failing into a reconnect loop: exit,
|
|
796
|
-
* close, reconnect, exit. Once a few starts in a row have died immediately
|
|
797
|
-
* with a non-zero code the failure is systemic, not a session ending, so it is
|
|
798
|
-
* reported as an error — which the relay forwards without dropping the socket,
|
|
799
|
-
* leaving the user with a message instead of a spinner.
|
|
800
|
-
*/
|
|
801
|
-
reportSessionExit(session, exitCode) {
|
|
802
|
-
const streamId = session.id;
|
|
803
|
-
const lifetimeMs = Date.now() - session.startedAtMs;
|
|
804
|
-
const failedFast = exitCode !== 0 && lifetimeMs < HostConnector.FAST_FAILURE_MS;
|
|
805
|
-
this.fastFailures = failedFast ? this.fastFailures + 1 : 0;
|
|
806
|
-
if (this.fastFailures < HostConnector.FAST_FAILURE_LIMIT) {
|
|
807
|
-
sendJson(this.ws, { type: 'session_exit', clientId: streamId, code: exitCode });
|
|
808
|
-
return;
|
|
809
|
-
}
|
|
810
|
-
const message = `"${this.herdrCommand}" exited immediately with code ${exitCode} on ${this.fastFailures} attempts in a row. `
|
|
811
|
-
+ 'Check that it runs from a terminal, and see the host connector log for what it printed.';
|
|
812
|
-
process.stderr.write(`herdr-remote host connector: ${message}\n`);
|
|
813
|
-
sendJson(this.ws, { type: 'error', clientId: streamId, code: 'herdr_start_failed', message });
|
|
814
|
-
}
|
|
815
|
-
|
|
816
|
-
stopSession(streamId) {
|
|
817
|
-
this.waitingForHerdr.delete(streamId);
|
|
818
|
-
this.probingStarts.delete(streamId);
|
|
819
|
-
const session = this.sessions.get(streamId);
|
|
820
|
-
if (!session) return;
|
|
821
|
-
this.sessions.delete(streamId);
|
|
822
|
-
if (typeof session.streamIndex === 'number') {
|
|
823
|
-
this.streamIndexToId.delete(session.streamIndex);
|
|
824
|
-
}
|
|
825
|
-
if (session.flushImmediate) {
|
|
826
|
-
clearImmediate(session.flushImmediate);
|
|
827
|
-
session.flushImmediate = null;
|
|
828
|
-
}
|
|
829
|
-
session.pendingOutput = [];
|
|
830
|
-
session.pty.kill();
|
|
831
|
-
this.sendHeartbeat();
|
|
832
|
-
}
|
|
833
|
-
|
|
834
|
-
startKeepalive() {
|
|
835
|
-
this.stopKeepalive();
|
|
836
|
-
if (!this.ws || this.stopping) return;
|
|
837
|
-
this.ws.isAlive = true;
|
|
838
|
-
const interval = this.config?.cleanup?.heartbeatIntervalMs || 30_000;
|
|
839
|
-
this.keepaliveTimer = setInterval(() => this.tickKeepalive(), interval);
|
|
840
|
-
}
|
|
841
|
-
|
|
842
|
-
stopKeepalive() {
|
|
843
|
-
if (this.keepaliveTimer) {
|
|
844
|
-
clearInterval(this.keepaliveTimer);
|
|
845
|
-
this.keepaliveTimer = null;
|
|
846
|
-
}
|
|
847
|
-
}
|
|
848
|
-
|
|
849
|
-
tickKeepalive() {
|
|
850
|
-
const ws = this.ws;
|
|
851
|
-
if (!ws || this.stopping) return;
|
|
852
|
-
if (ws.readyState === WebSocket.CLOSING) {
|
|
853
|
-
terminateSocket(ws);
|
|
854
|
-
return;
|
|
855
|
-
}
|
|
856
|
-
if (ws.readyState !== WebSocket.OPEN) return;
|
|
857
|
-
|
|
858
|
-
if (!ws.isAlive) {
|
|
859
|
-
terminateSocket(ws);
|
|
860
|
-
return;
|
|
861
|
-
}
|
|
862
|
-
|
|
863
|
-
ws.isAlive = false;
|
|
864
|
-
try {
|
|
865
|
-
if (typeof ws.ping === 'function') ws.ping();
|
|
866
|
-
} catch {}
|
|
867
|
-
|
|
868
|
-
if (this.clientCount <= 0 && !this.legacyHeartbeat) {
|
|
869
|
-
sendJson(ws, {
|
|
870
|
-
type: 'heartbeat',
|
|
871
|
-
load: {},
|
|
872
|
-
ptys: [],
|
|
873
|
-
});
|
|
874
|
-
}
|
|
875
|
-
}
|
|
876
|
-
|
|
877
|
-
setClientCount(value) {
|
|
878
|
-
const next = Number.isInteger(value) ? Math.max(0, value) : 0;
|
|
879
|
-
if (next === this.clientCount && (next === 0 || this.heartbeatTimer)) return;
|
|
880
|
-
const wasActive = this.clientCount > 0;
|
|
881
|
-
this.clientCount = next;
|
|
882
|
-
if (next > 0) {
|
|
883
|
-
if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
|
|
884
|
-
this.sendHeartbeat(true);
|
|
885
|
-
this.heartbeatTimer = setInterval(() => this.sendHeartbeat(), this.config.cleanup.heartbeatIntervalMs);
|
|
886
|
-
} else {
|
|
887
|
-
if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
|
|
888
|
-
this.heartbeatTimer = null;
|
|
889
|
-
if (wasActive) this.sendHeartbeat(true);
|
|
890
|
-
}
|
|
891
|
-
// Nobody watching, nothing to ask Herdr about itself.
|
|
892
|
-
if (next > 0) this.startAgentStatus();
|
|
893
|
-
else this.stopAgentStatus();
|
|
894
|
-
}
|
|
895
|
-
|
|
896
|
-
/**
|
|
897
|
-
* Watch what the agents on this workstation are doing.
|
|
898
|
-
*
|
|
899
|
-
* A side channel, on its own socket, alive only while a browser is attached.
|
|
900
|
-
* Herdr's server can be restarted underneath a running herdr-remote — the
|
|
901
|
-
* PTYs survive it — so every failure here is a retry, never anything a
|
|
902
|
-
* terminal session notices.
|
|
903
|
-
*
|
|
904
|
-
* A long-lived subscription provides fast refreshes on pane and workspace
|
|
905
|
-
* focus changes; the five-second poll still repairs missed events and a
|
|
906
|
-
* restarted Herdr server. Both paths only read the side-channel snapshot.
|
|
907
|
-
*/
|
|
908
|
-
startAgentStatus() {
|
|
909
|
-
if (this.agentStatusTimer || this.stopping) return;
|
|
910
|
-
this.agentStatusTimer = setInterval(() => this.readAgentStatus(), AGENT_STATUS_POLL_MS);
|
|
911
|
-
if (typeof this.agentStatusTimer.unref === 'function') this.agentStatusTimer.unref();
|
|
912
|
-
try {
|
|
913
|
-
this.agentStatusSubscription = this.subscribeHerdr(
|
|
914
|
-
this.socketPath,
|
|
915
|
-
AGENT_STATUS_SUBSCRIPTIONS,
|
|
916
|
-
() => this.scheduleAgentStatusRead(),
|
|
917
|
-
);
|
|
918
|
-
} catch {
|
|
919
|
-
// A missing or restarting Herdr server leaves the regular poll in place.
|
|
920
|
-
}
|
|
921
|
-
this.readAgentStatus();
|
|
922
|
-
}
|
|
923
|
-
|
|
924
|
-
scheduleAgentStatusRead() {
|
|
925
|
-
if (!this.agentStatusTimer) return;
|
|
926
|
-
if (this.agentStatusDebounceTimer) clearTimeout(this.agentStatusDebounceTimer);
|
|
927
|
-
this.agentStatusDebounceTimer = setTimeout(() => {
|
|
928
|
-
this.agentStatusDebounceTimer = null;
|
|
929
|
-
this.readAgentStatus();
|
|
930
|
-
}, AGENT_STATUS_EVENT_DEBOUNCE_MS);
|
|
931
|
-
if (typeof this.agentStatusDebounceTimer.unref === 'function') this.agentStatusDebounceTimer.unref();
|
|
932
|
-
}
|
|
933
|
-
|
|
934
|
-
stopAgentStatus() {
|
|
935
|
-
if (this.agentStatusTimer) {
|
|
936
|
-
clearInterval(this.agentStatusTimer);
|
|
937
|
-
this.agentStatusTimer = null;
|
|
938
|
-
}
|
|
939
|
-
if (this.agentStatusDebounceTimer) {
|
|
940
|
-
clearTimeout(this.agentStatusDebounceTimer);
|
|
941
|
-
this.agentStatusDebounceTimer = null;
|
|
942
|
-
}
|
|
943
|
-
if (this.agentStatusSubscription) {
|
|
944
|
-
this.agentStatusSubscription.close();
|
|
945
|
-
this.agentStatusSubscription = null;
|
|
946
|
-
}
|
|
947
|
-
this.lastAgentSummary = null;
|
|
948
|
-
}
|
|
949
|
-
|
|
950
|
-
/**
|
|
951
|
-
* Read the summary and forward it if it changed.
|
|
952
|
-
*
|
|
953
|
-
* A slow answer must not stack up behind the next tick, and a failed one
|
|
954
|
-
* leaves the last summary standing rather than blanking the browser's chip:
|
|
955
|
-
* Herdr's server not running is an ordinary state, not news.
|
|
956
|
-
*/
|
|
957
|
-
async readAgentStatus() {
|
|
958
|
-
if (this.agentStatusPending || !this.agentStatusTimer) return;
|
|
959
|
-
this.agentStatusPending = true;
|
|
960
|
-
let snapshot;
|
|
961
|
-
try {
|
|
962
|
-
const result = await this.requestHerdr(this.socketPath, 'session.snapshot', {});
|
|
963
|
-
snapshot = result?.snapshot || result;
|
|
964
|
-
} catch {
|
|
965
|
-
return;
|
|
966
|
-
} finally {
|
|
967
|
-
this.agentStatusPending = false;
|
|
968
|
-
}
|
|
969
|
-
// Stopped while the answer was in flight; the browser it was for is gone.
|
|
970
|
-
if (!this.agentStatusTimer) return;
|
|
971
|
-
const summary = summarizeAgents(snapshot);
|
|
972
|
-
if (sameSummary(this.lastAgentSummary, summary)) return;
|
|
973
|
-
this.lastAgentSummary = summary;
|
|
974
|
-
sendJson(this.ws, { type: 'agent_status', ...summary });
|
|
975
|
-
}
|
|
976
|
-
|
|
977
|
-
resizeSession(message) {
|
|
978
|
-
const id = message.clientId || message.streamId;
|
|
979
|
-
// A window still waiting on Herdr starts at the size it has by then.
|
|
980
|
-
const held = this.waitingForHerdr.get(id) || this.probingStarts.get(id)?.message;
|
|
981
|
-
if (held) {
|
|
982
|
-
if (Number.isInteger(message.cols)) held.cols = message.cols;
|
|
983
|
-
if (Number.isInteger(message.rows)) held.rows = message.rows;
|
|
984
|
-
}
|
|
985
|
-
const session = this.sessions.get(id);
|
|
986
|
-
if (!session) return;
|
|
987
|
-
session.cols = Number.isInteger(message.cols) ? Math.min(500, Math.max(2, message.cols)) : session.cols;
|
|
988
|
-
session.rows = Number.isInteger(message.rows) ? Math.min(500, Math.max(2, message.rows)) : session.rows;
|
|
989
|
-
session.pty.resize(session.cols, session.rows);
|
|
990
|
-
}
|
|
991
|
-
|
|
992
|
-
handlePasteFile(message) {
|
|
993
|
-
const streamId = message.clientId || message.streamId;
|
|
994
|
-
try {
|
|
995
|
-
const savedPath = savePastedFile({
|
|
996
|
-
mime: message.mime,
|
|
997
|
-
dataBase64: message.dataBase64,
|
|
998
|
-
});
|
|
999
|
-
sendJson(this.ws, {
|
|
1000
|
-
type: 'paste_file_ready',
|
|
1001
|
-
clientId: streamId,
|
|
1002
|
-
path: savedPath,
|
|
1003
|
-
});
|
|
1004
|
-
} catch (error) {
|
|
1005
|
-
sendJson(this.ws, {
|
|
1006
|
-
type: 'error',
|
|
1007
|
-
clientId: streamId,
|
|
1008
|
-
code: 'paste_file_write_failed',
|
|
1009
|
-
message: error.message || 'Failed to save pasted file',
|
|
1010
|
-
});
|
|
1011
|
-
}
|
|
1012
|
-
}
|
|
1013
|
-
|
|
1014
|
-
destroySessions() {
|
|
1015
|
-
for (const session of this.sessions.values()) {
|
|
1016
|
-
if (session.flushImmediate) {
|
|
1017
|
-
clearImmediate(session.flushImmediate);
|
|
1018
|
-
session.flushImmediate = null;
|
|
1019
|
-
}
|
|
1020
|
-
session.pendingOutput = [];
|
|
1021
|
-
session.pty.kill();
|
|
1022
|
-
}
|
|
1023
|
-
this.sessions.clear();
|
|
1024
|
-
this.streamIndexToId.clear();
|
|
1025
|
-
this.waitingForHerdr.clear();
|
|
1026
|
-
this.probingStarts.clear();
|
|
1027
|
-
}
|
|
1028
|
-
|
|
1029
|
-
sendHeartbeat(force = false) {
|
|
1030
|
-
if (!force && this.clientCount <= 0 && !this.legacyHeartbeat) return;
|
|
1031
|
-
const memory = process.memoryUsage();
|
|
1032
|
-
const load = os.loadavg();
|
|
1033
|
-
sendJson(this.ws, {
|
|
1034
|
-
type: 'heartbeat',
|
|
1035
|
-
load: {
|
|
1036
|
-
load1m: load[0] || 0,
|
|
1037
|
-
load5m: load[1] || 0,
|
|
1038
|
-
load15m: load[2] || 0,
|
|
1039
|
-
rssBytes: memory.rss,
|
|
1040
|
-
heapUsedBytes: memory.heapUsed,
|
|
1041
|
-
},
|
|
1042
|
-
ptys: [...this.sessions.values()].map((session) => ({
|
|
1043
|
-
id: session.id,
|
|
1044
|
-
pid: session.pty.terminal?.pid || null,
|
|
1045
|
-
command: session.pty.command,
|
|
1046
|
-
cols: session.cols,
|
|
1047
|
-
rows: session.rows,
|
|
1048
|
-
cwd: session.pty.cwd,
|
|
1049
|
-
createdAt: session.createdAt,
|
|
1050
|
-
activeClients: 1,
|
|
1051
|
-
})),
|
|
1052
|
-
});
|
|
1053
|
-
}
|
|
1054
|
-
}
|
|
1055
|
-
|
|
1056
|
-
if (require.main === module) {
|
|
1057
|
-
const connector = new HostConnector();
|
|
1058
|
-
try {
|
|
1059
|
-
connector.start();
|
|
1060
|
-
} catch (error) {
|
|
1061
|
-
process.stderr.write(`herdr-remote host connector failed: ${error.message}\n`);
|
|
1062
|
-
process.exitCode = error.code === 'HOST_ALREADY_RUNNING' ? EXIT_REPLACED : 1;
|
|
1063
|
-
}
|
|
1064
|
-
const stop = () => { connector.stop(); process.exit(0); };
|
|
1065
|
-
process.once('SIGINT', stop);
|
|
1066
|
-
process.once('SIGTERM', stop);
|
|
1067
|
-
}
|
|
1068
|
-
|
|
1069
|
-
module.exports = { HostConnector, PROTOCOL_VERSION };
|