herdr-remote 0.2.4 → 0.2.6

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.
@@ -7,7 +7,11 @@
7
7
  "port": 8787,
8
8
  "lanHost": "",
9
9
  "publicUrl": "",
10
- "remoteUrl": ""
10
+ "remoteUrl": "",
11
+ "maxHosts": 1024,
12
+ "maxPendingHandshakes": 1024,
13
+ "maxBufferedBytesPerClient": 4194304,
14
+ "hostReconnectGraceMs": 30000
11
15
  },
12
16
  "herdr": {
13
17
  "socketPath": null,
package/dist/tui.mjs CHANGED
@@ -78,6 +78,10 @@ var require_config = __commonJS({
78
78
  remoteUrl: "",
79
79
  maxPayloadBytes: 1024 * 1024,
80
80
  maxClientsPerHost: 16,
81
+ maxHosts: 1024,
82
+ maxPendingHandshakes: 1024,
83
+ maxBufferedBytesPerClient: 4 * 1024 * 1024,
84
+ hostReconnectGraceMs: 30 * 1e3,
81
85
  allowedOrigins: []
82
86
  },
83
87
  herdr: {
@@ -203,6 +207,20 @@ var require_config = __commonJS({
203
207
  config.relay.port = parseInteger(config.relay.port, DEFAULTS.relay.port, 1, 65535);
204
208
  config.relay.maxPayloadBytes = parseInteger(config.relay.maxPayloadBytes, DEFAULTS.relay.maxPayloadBytes, 4096, 16 * 1024 * 1024);
205
209
  config.relay.maxClientsPerHost = parseInteger(config.relay.maxClientsPerHost, DEFAULTS.relay.maxClientsPerHost, 1, 256);
210
+ config.relay.maxHosts = parseInteger(config.relay.maxHosts, DEFAULTS.relay.maxHosts, 1, 1e5);
211
+ config.relay.maxPendingHandshakes = parseInteger(config.relay.maxPendingHandshakes, DEFAULTS.relay.maxPendingHandshakes, 16, 1e5);
212
+ config.relay.maxBufferedBytesPerClient = parseInteger(
213
+ config.relay.maxBufferedBytesPerClient,
214
+ DEFAULTS.relay.maxBufferedBytesPerClient,
215
+ 64 * 1024,
216
+ 256 * 1024 * 1024
217
+ );
218
+ config.relay.hostReconnectGraceMs = parseInteger(
219
+ config.relay.hostReconnectGraceMs,
220
+ DEFAULTS.relay.hostReconnectGraceMs,
221
+ 1e3,
222
+ 24 * 60 * 60 * 1e3
223
+ );
206
224
  config.auth.pairingTtlMs = parseInteger(config.auth.pairingTtlMs, DEFAULTS.auth.pairingTtlMs, 30 * 1e3, 24 * 60 * 60 * 1e3);
207
225
  config.auth.deviceTtlMs = parseInteger(config.auth.deviceTtlMs, DEFAULTS.auth.deviceTtlMs, 60 * 1e3, 365 * 24 * 60 * 60 * 1e3);
208
226
  config.auth.maxDevices = parseInteger(config.auth.maxDevices, DEFAULTS.auth.maxDevices, 1, 1e4);
@@ -220,6 +238,10 @@ var require_config = __commonJS({
220
238
  if (!Array.isArray(config.herdr.args) || !config.herdr.args.every((arg) => typeof arg === "string")) {
221
239
  config.herdr.args = [];
222
240
  }
241
+ if (config.herdr.args.includes("--no-session")) {
242
+ config.herdr.args = config.herdr.args.filter((arg) => arg !== "--no-session");
243
+ process.stderr.write("herdr-remote: ignoring removed --no-session argument (removed in Herdr 0.9.0)\n");
244
+ }
223
245
  if (typeof config.herdr.socketPath !== "string" || config.herdr.socketPath.length === 0) {
224
246
  config.herdr.socketPath = null;
225
247
  }
@@ -238,6 +260,10 @@ var require_config = __commonJS({
238
260
  if (env.RELAY_PORT) config.relay.port = env.RELAY_PORT;
239
261
  if (env.RELAY_PUBLIC_URL) config.relay.publicUrl = env.RELAY_PUBLIC_URL;
240
262
  if (env.RELAY_REMOTE_URL) config.relay.remoteUrl = env.RELAY_REMOTE_URL;
263
+ if (env.RELAY_MAX_HOSTS) config.relay.maxHosts = env.RELAY_MAX_HOSTS;
264
+ if (env.RELAY_MAX_PENDING_HANDSHAKES) config.relay.maxPendingHandshakes = env.RELAY_MAX_PENDING_HANDSHAKES;
265
+ if (env.RELAY_MAX_BUFFERED_BYTES_PER_CLIENT) config.relay.maxBufferedBytesPerClient = env.RELAY_MAX_BUFFERED_BYTES_PER_CLIENT;
266
+ if (env.RELAY_HOST_RECONNECT_GRACE_MS) config.relay.hostReconnectGraceMs = env.RELAY_HOST_RECONNECT_GRACE_MS;
241
267
  if (env.HERDR_SOCKET_PATH) config.herdr.socketPath = env.HERDR_SOCKET_PATH;
242
268
  if (env.HERDR_CWD) config.herdr.cwd = env.HERDR_CWD;
243
269
  if (env.HERDR_ARGS_JSON) {
@@ -547,6 +573,7 @@ var require_en = __commonJS({
547
573
  "error.invalidKeepalive": "Unknown keep-alive manager.",
548
574
  "error.unknownField": "Unknown setting.",
549
575
  "error.remoteUrlRequired": "Relay URL is required for self-hosted relay mode.",
576
+ "error.removedHerdrArg": "Herdr 0.9.0 removed --no-session; remove it from the extra arguments.",
550
577
  "error.saveFailed": "Could not save configuration: {message}",
551
578
  "hint.navigate": "\u2191\u2193 move",
552
579
  "hint.select": "\u21B5 select",
@@ -771,6 +798,7 @@ var require_zh = __commonJS({
771
798
  "error.invalidKeepalive": "\u672A\u77E5\u7684\u4FDD\u6D3B\u7BA1\u7406\u65B9\u5F0F\u3002",
772
799
  "error.unknownField": "\u672A\u77E5\u7684\u8BBE\u7F6E\u9879\u3002",
773
800
  "error.remoteUrlRequired": "\u81EA\u5EFA Relay \u6A21\u5F0F\u987B\u586B\u5199 Relay \u5730\u5740\u3002",
801
+ "error.removedHerdrArg": "Herdr 0.9.0 \u5DF2\u79FB\u9664 --no-session\uFF0C\u8BF7\u4ECE\u9644\u52A0\u53C2\u6570\u4E2D\u79FB\u9664\u3002",
774
802
  "error.saveFailed": "\u4FDD\u5B58\u914D\u7F6E\u5931\u8D25\uFF1A{message}",
775
803
  "hint.navigate": "\u2191\u2193 \u79FB\u52A8",
776
804
  "hint.select": "\u21B5 \u9009\u62E9",
@@ -1104,7 +1132,11 @@ var require_settings_model = __commonJS({
1104
1132
  break;
1105
1133
  }
1106
1134
  case "herdrArgs": {
1107
- next.herdr.args = value ? value.split(/\s+/).filter(Boolean) : [];
1135
+ const args = value ? value.split(/\s+/).filter(Boolean) : [];
1136
+ if (args.includes("--no-session")) {
1137
+ return { draft, errorKey: "error.removedHerdrArg" };
1138
+ }
1139
+ next.herdr.args = args;
1108
1140
  break;
1109
1141
  }
1110
1142
  case "language": {
@@ -1550,7 +1582,11 @@ var require_service = __commonJS({
1550
1582
  RELAY_PASSWORD: state.hostToken,
1551
1583
  RELAY_AUTH_STATE_FILE: relayAuthStatePath(),
1552
1584
  RELAY_ALLOWED_ORIGINS: (config.relay.allowedOrigins || []).join(","),
1553
- RELAY_MAX_CLIENTS_PER_HOST: String(config.relay.maxClientsPerHost)
1585
+ RELAY_MAX_CLIENTS_PER_HOST: String(config.relay.maxClientsPerHost),
1586
+ RELAY_MAX_HOSTS: String(config.relay.maxHosts),
1587
+ RELAY_MAX_PENDING_HANDSHAKES: String(config.relay.maxPendingHandshakes),
1588
+ RELAY_MAX_BUFFERED_BYTES_PER_CLIENT: String(config.relay.maxBufferedBytesPerClient),
1589
+ RELAY_HOST_RECONNECT_GRACE_MS: String(config.relay.hostReconnectGraceMs)
1554
1590
  }
1555
1591
  });
1556
1592
  }
@@ -1733,18 +1769,26 @@ var require_service = __commonJS({
1733
1769
  throw lastError || new Error("relay did not become ready");
1734
1770
  }
1735
1771
  async function waitForHost(config, { attempts = 30, delayMs = 100 } = {}) {
1736
- let lastHealth = null;
1772
+ const state = ensureRuntime2();
1773
+ const statusEndpoint = `${resolveAdminOrigin2(config)}/api/status`;
1774
+ let lastStatus = null;
1737
1775
  for (let attempt = 0; attempt < attempts; attempt += 1) {
1738
1776
  try {
1739
- lastHealth = await requestJson2(healthUrl(config), { timeout: 800 });
1740
- if (lastHealth.hosts > 0) return lastHealth;
1777
+ lastStatus = await requestJson2(statusEndpoint, {
1778
+ timeout: 800,
1779
+ headers: {
1780
+ "X-Herdr-Host-Id": state.hostId,
1781
+ "X-Herdr-Host-Token": state.hostToken
1782
+ }
1783
+ });
1784
+ if (lastStatus.hosts?.some((host) => host.id === state.hostId)) return lastStatus;
1741
1785
  } catch (error2) {
1742
- lastHealth = { ok: false, message: error2.message };
1786
+ lastStatus = { ok: false, message: error2.message };
1743
1787
  }
1744
1788
  await new Promise((resolve) => setTimeout(resolve, delayMs));
1745
1789
  }
1746
- const error = new Error(lastHealth?.message || "the Herdr host connector did not register with the relay");
1747
- error.health = lastHealth;
1790
+ const error = new Error(lastStatus?.message || "the Herdr host connector did not register with the relay");
1791
+ error.health = lastStatus;
1748
1792
  throw error;
1749
1793
  }
1750
1794
  async function statusServices() {
@@ -2827,12 +2871,26 @@ function relayStartCommand(config, password) {
2827
2871
  return parts.join(" ");
2828
2872
  }
2829
2873
  async function probeRelay(config) {
2874
+ const origin = (0, import_config.resolveAdminOrigin)(config);
2875
+ let health;
2830
2876
  try {
2831
- const health = await (0, import_service.requestJson)(`${(0, import_config.resolveAdminOrigin)(config)}/healthz`, { timeout: 4e3 });
2832
- return { ok: true, version: health.version, hosts: health.hosts };
2877
+ health = await (0, import_service.requestJson)(`${origin}/healthz`, { timeout: 4e3 });
2833
2878
  } catch (error) {
2834
2879
  return { ok: false, message: error.message };
2835
2880
  }
2881
+ try {
2882
+ const runtime = (0, import_service.ensureRuntime)();
2883
+ const status = await (0, import_service.requestJson)(`${origin}/api/status`, {
2884
+ timeout: 4e3,
2885
+ headers: {
2886
+ "X-Herdr-Host-Id": runtime.hostId,
2887
+ "X-Herdr-Host-Token": runtime.hostToken
2888
+ }
2889
+ });
2890
+ return { ok: true, version: String(health.version || status.version || ""), hosts: Array.isArray(status.hosts) ? status.hosts.length : 0 };
2891
+ } catch {
2892
+ return { ok: true, version: String(health.version || ""), hosts: void 0 };
2893
+ }
2836
2894
  }
2837
2895
  function formatUptime(seconds, t) {
2838
2896
  if (!Number.isFinite(seconds)) return t("common.unknown");
@@ -3831,7 +3889,7 @@ function About({ ctx }) {
3831
3889
  children: updateLabel
3832
3890
  }
3833
3891
  ) }),
3834
- /* @__PURE__ */ jsx10(Row, { label: t("about.version"), children: /* @__PURE__ */ jsx10(Text9, { color: theme.muted, children: "0.2.4" }) }),
3892
+ /* @__PURE__ */ jsx10(Row, { label: t("about.version"), children: /* @__PURE__ */ jsx10(Text9, { color: theme.muted, children: "0.2.6" }) }),
3835
3893
  /* @__PURE__ */ jsx10(Row, { label: t("about.configPath"), children: /* @__PURE__ */ jsx10(Text9, { color: theme.muted, children: (0, import_config.configPath)() }) }),
3836
3894
  /* @__PURE__ */ jsx10(Row, { label: t("about.statePath"), children: /* @__PURE__ */ jsx10(Text9, { color: theme.muted, children: (0, import_config.stateDir)() }) }),
3837
3895
  /* @__PURE__ */ jsx10(Box9, { marginTop: 1, children: /* @__PURE__ */ jsx10(Text9, { color: theme.muted, children: t("about.docs") }) }),
package/herdr-plugin.toml CHANGED
@@ -1,7 +1,7 @@
1
1
  id = "herdr.remote.web"
2
2
  name = "Herdr Remote Web"
3
- version = "0.2.3"
4
- min_herdr_version = "0.8.2"
3
+ version = "0.2.6"
4
+ min_herdr_version = "0.9.0"
5
5
  description = "Browser access to Herdr workspaces via local or self-hosted relay"
6
6
  platforms = ["linux", "macos"]
7
7
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "herdr-remote",
3
- "version": "0.2.4",
3
+ "version": "0.2.6",
4
4
  "description": "Browser access to Herdr workspaces: plugin, host connector, and configuration TUI",
5
5
  "license": "MIT",
6
6
  "repository": {
package/src/config.js CHANGED
@@ -64,6 +64,10 @@ const DEFAULTS = {
64
64
  remoteUrl: '',
65
65
  maxPayloadBytes: 1024 * 1024,
66
66
  maxClientsPerHost: 16,
67
+ maxHosts: 1024,
68
+ maxPendingHandshakes: 1024,
69
+ maxBufferedBytesPerClient: 4 * 1024 * 1024,
70
+ hostReconnectGraceMs: 30 * 1000,
67
71
  allowedOrigins: [],
68
72
  },
69
73
  herdr: {
@@ -230,6 +234,20 @@ function validate(config) {
230
234
  config.relay.port = parseInteger(config.relay.port, DEFAULTS.relay.port, 1, 65535);
231
235
  config.relay.maxPayloadBytes = parseInteger(config.relay.maxPayloadBytes, DEFAULTS.relay.maxPayloadBytes, 4096, 16 * 1024 * 1024);
232
236
  config.relay.maxClientsPerHost = parseInteger(config.relay.maxClientsPerHost, DEFAULTS.relay.maxClientsPerHost, 1, 256);
237
+ config.relay.maxHosts = parseInteger(config.relay.maxHosts, DEFAULTS.relay.maxHosts, 1, 100000);
238
+ config.relay.maxPendingHandshakes = parseInteger(config.relay.maxPendingHandshakes, DEFAULTS.relay.maxPendingHandshakes, 16, 100000);
239
+ config.relay.maxBufferedBytesPerClient = parseInteger(
240
+ config.relay.maxBufferedBytesPerClient,
241
+ DEFAULTS.relay.maxBufferedBytesPerClient,
242
+ 64 * 1024,
243
+ 256 * 1024 * 1024,
244
+ );
245
+ config.relay.hostReconnectGraceMs = parseInteger(
246
+ config.relay.hostReconnectGraceMs,
247
+ DEFAULTS.relay.hostReconnectGraceMs,
248
+ 1000,
249
+ 24 * 60 * 60 * 1000,
250
+ );
233
251
  config.auth.pairingTtlMs = parseInteger(config.auth.pairingTtlMs, DEFAULTS.auth.pairingTtlMs, 30 * 1000, 24 * 60 * 60 * 1000);
234
252
  config.auth.deviceTtlMs = parseInteger(config.auth.deviceTtlMs, DEFAULTS.auth.deviceTtlMs, 60 * 1000, 365 * 24 * 60 * 60 * 1000);
235
253
  config.auth.maxDevices = parseInteger(config.auth.maxDevices, DEFAULTS.auth.maxDevices, 1, 10000);
@@ -255,6 +273,12 @@ function validate(config) {
255
273
  if (!Array.isArray(config.herdr.args) || !config.herdr.args.every((arg) => typeof arg === 'string')) {
256
274
  config.herdr.args = [];
257
275
  }
276
+ // Herdr 0.9.0 removed --no-session; retaining it in saved configs causes
277
+ // session creation to fail. Filter it out if present.
278
+ if (config.herdr.args.includes('--no-session')) {
279
+ config.herdr.args = config.herdr.args.filter((arg) => arg !== '--no-session');
280
+ process.stderr.write('herdr-remote: ignoring removed --no-session argument (removed in Herdr 0.9.0)\n');
281
+ }
258
282
  if (typeof config.herdr.socketPath !== 'string' || config.herdr.socketPath.length === 0) {
259
283
  config.herdr.socketPath = null;
260
284
  }
@@ -277,6 +301,10 @@ function applyEnvironment(config) {
277
301
  if (env.RELAY_PORT) config.relay.port = env.RELAY_PORT;
278
302
  if (env.RELAY_PUBLIC_URL) config.relay.publicUrl = env.RELAY_PUBLIC_URL;
279
303
  if (env.RELAY_REMOTE_URL) config.relay.remoteUrl = env.RELAY_REMOTE_URL;
304
+ if (env.RELAY_MAX_HOSTS) config.relay.maxHosts = env.RELAY_MAX_HOSTS;
305
+ if (env.RELAY_MAX_PENDING_HANDSHAKES) config.relay.maxPendingHandshakes = env.RELAY_MAX_PENDING_HANDSHAKES;
306
+ if (env.RELAY_MAX_BUFFERED_BYTES_PER_CLIENT) config.relay.maxBufferedBytesPerClient = env.RELAY_MAX_BUFFERED_BYTES_PER_CLIENT;
307
+ if (env.RELAY_HOST_RECONNECT_GRACE_MS) config.relay.hostReconnectGraceMs = env.RELAY_HOST_RECONNECT_GRACE_MS;
280
308
  if (env.HERDR_SOCKET_PATH) config.herdr.socketPath = env.HERDR_SOCKET_PATH;
281
309
  if (env.HERDR_CWD) config.herdr.cwd = env.HERDR_CWD;
282
310
  if (env.HERDR_ARGS_JSON) {
package/src/exit-codes.js CHANGED
@@ -10,5 +10,7 @@
10
10
  * one exit the supervisor must respect rather than recover from.
11
11
  */
12
12
  const EXIT_REPLACED = 12;
13
+ /** Credentials need operator intervention; a supervisor must not loop. */
14
+ const EXIT_AUTH_FAILED = 13;
13
15
 
14
- module.exports = { EXIT_REPLACED };
16
+ module.exports = { EXIT_REPLACED, EXIT_AUTH_FAILED };
@@ -1,9 +1,12 @@
1
1
  'use strict';
2
2
 
3
+ const fs = require('node:fs');
3
4
  const os = require('node:os');
5
+ const path = require('node:path');
4
6
  const crypto = require('node:crypto');
5
7
  const { WebSocket } = require('ws');
6
- const { loadConfig, hostWebSocketUrl, resolveHostRelayUrl } = require('./config');
8
+ const { loadConfig, hostWebSocketUrl, resolveHostRelayUrl, stateDir } = require('./config');
9
+ const { ensureDir } = require('./state');
7
10
  const { resolveSocketPath, inspectSocket } = require('./socket-discovery');
8
11
  const { PtySession } = require('./pty-session');
9
12
  const { resolveHerdrCommand } = require('./herdr-command');
@@ -11,7 +14,7 @@ const { resolveHerdrCommand } = require('./herdr-command');
11
14
  // generated from one definition.
12
15
  const { packStreamFrame, unpackStreamFrame, PROTOCOL_VERSION } = require('herdr-remote-relay/protocol');
13
16
  const { resolveHostPalette } = require('./terminal-palette');
14
- const { EXIT_REPLACED } = require('./exit-codes');
17
+ const { EXIT_REPLACED, EXIT_AUTH_FAILED } = require('./exit-codes');
15
18
 
16
19
  function randomId(prefix) {
17
20
  return `${prefix}-${crypto.randomBytes(9).toString('base64url')}`;
@@ -21,9 +24,19 @@ function sendJson(ws, payload) {
21
24
  if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(payload));
22
25
  }
23
26
 
24
- function closeSocket(ws) {
27
+ function closeSocket(ws, reason = 'host connector stopping') {
25
28
  if (!ws || ws.readyState === WebSocket.CLOSED || ws.readyState === WebSocket.CLOSING) return;
26
- try { ws.close(1000, 'host connector stopping'); } catch {}
29
+ try { ws.close(1000, reason); } catch {}
30
+ }
31
+
32
+ function pidAlive(pid) {
33
+ if (!Number.isInteger(pid) || pid <= 0) return false;
34
+ try {
35
+ process.kill(pid, 0);
36
+ return true;
37
+ } catch (error) {
38
+ return error.code === 'EPERM';
39
+ }
27
40
  }
28
41
 
29
42
  class HostConnector {
@@ -55,13 +68,62 @@ class HostConnector {
55
68
  this.reconnectTimer = null;
56
69
  this.heartbeatTimer = null;
57
70
  this.reconnectAttempts = 0;
71
+ this.clientCount = 0;
72
+ this.legacyHeartbeat = false;
73
+ this.ready = false;
74
+ this.authFailure = false;
58
75
  this.stopping = false;
76
+ this.lockPath = options.lockPath
77
+ || process.env.HERDR_REMOTE_HOST_LOCK
78
+ || path.join(stateDir(), 'host-connector.lock');
79
+ this.lockFd = null;
80
+ }
81
+
82
+ acquireLock() {
83
+ if (this.lockFd !== null) return;
84
+ ensureDir(path.dirname(this.lockPath));
85
+ for (let attempt = 0; attempt < 2; attempt += 1) {
86
+ try {
87
+ const fd = fs.openSync(this.lockPath, 'wx', 0o600);
88
+ fs.writeFileSync(fd, `${JSON.stringify({ pid: process.pid, hostId: this.hostId, startedAt: new Date().toISOString() })}\n`);
89
+ this.lockFd = fd;
90
+ return;
91
+ } catch (error) {
92
+ if (error.code !== 'EEXIST') throw error;
93
+ let owner = null;
94
+ try { owner = JSON.parse(fs.readFileSync(this.lockPath, 'utf8')); } catch {}
95
+ if (owner && pidAlive(owner.pid)) {
96
+ const duplicate = new Error(`another host connector is already running (pid ${owner.pid})`);
97
+ duplicate.code = 'HOST_ALREADY_RUNNING';
98
+ throw duplicate;
99
+ }
100
+ try { fs.rmSync(this.lockPath, { force: true }); } catch {}
101
+ }
102
+ }
103
+ const stale = new Error('could not acquire host connector lock');
104
+ stale.code = 'HOST_LOCK_FAILED';
105
+ throw stale;
106
+ }
107
+
108
+ releaseLock() {
109
+ const owned = this.lockFd !== null;
110
+ if (this.lockFd !== null) {
111
+ try { fs.closeSync(this.lockFd); } catch {}
112
+ this.lockFd = null;
113
+ }
114
+ if (!owned) return;
115
+ try {
116
+ const owner = JSON.parse(fs.readFileSync(this.lockPath, 'utf8'));
117
+ if (owner.pid !== process.pid) return;
118
+ } catch {}
119
+ try { fs.rmSync(this.lockPath, { force: true }); } catch {}
59
120
  }
60
121
 
61
122
  start() {
62
123
  if (!this.hostToken) {
63
124
  throw new Error('RELAY_HOST_TOKEN is required');
64
125
  }
126
+ this.acquireLock();
65
127
  this.stopping = false;
66
128
  this.connect();
67
129
  }
@@ -72,9 +134,14 @@ class HostConnector {
72
134
  if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
73
135
  this.reconnectTimer = null;
74
136
  this.heartbeatTimer = null;
137
+ sendJson(this.ws, { type: 'host_shutdown' });
75
138
  this.destroySessions();
76
- closeSocket(this.ws);
139
+ closeSocket(this.ws, 'host_shutdown');
77
140
  this.ws = null;
141
+ this.ready = false;
142
+ this.clientCount = 0;
143
+ this.legacyHeartbeat = false;
144
+ this.releaseLock();
78
145
  }
79
146
 
80
147
  connect() {
@@ -89,8 +156,9 @@ class HostConnector {
89
156
  this.ws = ws;
90
157
  ws.isAlive = true;
91
158
  ws.on('open', () => {
92
- this.reconnectAttempts = 0;
93
159
  ws.isAlive = true;
160
+ this.ready = false;
161
+ this.authFailure = false;
94
162
  sendJson(ws, {
95
163
  type: 'host_hello',
96
164
  protocol: PROTOCOL_VERSION,
@@ -101,16 +169,24 @@ class HostConnector {
101
169
  platform: process.platform,
102
170
  arch: process.arch,
103
171
  terminalPalette: this.terminalPalette || null,
172
+ capabilities: ['host_handoff', 'idle_heartbeat'],
104
173
  });
105
- this.sendHeartbeat();
106
- this.heartbeatTimer = setInterval(() => this.sendHeartbeat(), this.config.cleanup.heartbeatIntervalMs);
174
+ // The relay sends host_ready with the current browser count. No business
175
+ // heartbeat is started until that message says somebody is watching.
107
176
  });
108
177
  ws.on('pong', () => { ws.isAlive = true; });
109
178
  ws.on('message', (raw, isBinary) => this.handleMessage(raw, isBinary));
110
179
  ws.on('close', (code, rawReason) => {
111
- if (this.ws === ws) this.ws = null;
180
+ // A replacement socket may be live while an older socket is still
181
+ // delivering its close event. Never let that stale event destroy the new
182
+ // session or clear its heartbeat timer.
183
+ if (this.ws !== ws) return;
184
+ this.ws = null;
112
185
  if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
113
186
  this.heartbeatTimer = null;
187
+ this.ready = false;
188
+ this.clientCount = 0;
189
+ this.legacyHeartbeat = false;
114
190
  this.destroySessions();
115
191
 
116
192
  // Another connector has claimed this workstation. Reconnecting would just
@@ -123,6 +199,12 @@ class HostConnector {
123
199
  this.stop();
124
200
  process.exit(EXIT_REPLACED);
125
201
  }
202
+ if (this.authFailure) {
203
+ this.stopping = true;
204
+ process.stderr.write('herdr-remote host connector: authentication failed; update relay credentials and restart the service\n');
205
+ this.releaseLock();
206
+ process.exit(EXIT_AUTH_FAILED);
207
+ }
126
208
  this.scheduleReconnect();
127
209
  });
128
210
  ws.on('error', (error) => {
@@ -160,9 +242,29 @@ class HostConnector {
160
242
  // connection just closed silently and reconnected forever, leaving the user
161
243
  // with an empty log and no idea what was wrong.
162
244
  if (message.type === 'error' && !message.clientId) {
245
+ this.authFailure = ['relay_password_required', 'host_auth_failed', 'invalid_host_credentials'].includes(message.code);
163
246
  process.stderr.write(`herdr-remote host connector: relay rejected the connection: ${message.message || message.code}\n`);
164
247
  return;
165
248
  }
249
+ if (message.type === 'host_ready') {
250
+ this.ready = true;
251
+ this.reconnectAttempts = 0;
252
+ if (Object.hasOwn(message, 'clientCount')) {
253
+ this.legacyHeartbeat = false;
254
+ this.setClientCount(message.clientCount);
255
+ } else {
256
+ // An older relay does not know client_count. Keep its historical
257
+ // telemetry behavior so rolling upgrades do not silently lose status.
258
+ this.legacyHeartbeat = true;
259
+ this.sendHeartbeat(true);
260
+ this.heartbeatTimer = setInterval(() => this.sendHeartbeat(), this.config.cleanup.heartbeatIntervalMs);
261
+ }
262
+ return;
263
+ }
264
+ if (message.type === 'client_count') {
265
+ this.setClientCount(message.clientCount);
266
+ return;
267
+ }
166
268
  if (message.type === 'session_start') this.startSession(message);
167
269
  else if (message.type === 'session_stop') this.stopSession(message.clientId || message.streamId);
168
270
  else if (message.type === 'resize') this.resizeSession(message);
@@ -224,6 +326,22 @@ class HostConnector {
224
326
  this.sendHeartbeat();
225
327
  }
226
328
 
329
+ setClientCount(value) {
330
+ const next = Number.isInteger(value) ? Math.max(0, value) : 0;
331
+ if (next === this.clientCount && (next === 0 || this.heartbeatTimer)) return;
332
+ const wasActive = this.clientCount > 0;
333
+ this.clientCount = next;
334
+ if (next > 0) {
335
+ if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
336
+ this.sendHeartbeat(true);
337
+ this.heartbeatTimer = setInterval(() => this.sendHeartbeat(), this.config.cleanup.heartbeatIntervalMs);
338
+ } else {
339
+ if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
340
+ this.heartbeatTimer = null;
341
+ if (wasActive) this.sendHeartbeat(true);
342
+ }
343
+ }
344
+
227
345
  resizeSession(message) {
228
346
  const id = message.clientId || message.streamId;
229
347
  const session = this.sessions.get(id);
@@ -238,7 +356,8 @@ class HostConnector {
238
356
  this.sessions.clear();
239
357
  }
240
358
 
241
- sendHeartbeat() {
359
+ sendHeartbeat(force = false) {
360
+ if (!force && this.clientCount <= 0 && !this.legacyHeartbeat) return;
242
361
  const memory = process.memoryUsage();
243
362
  const load = os.loadavg();
244
363
  sendJson(this.ws, {
@@ -270,7 +389,7 @@ if (require.main === module) {
270
389
  connector.start();
271
390
  } catch (error) {
272
391
  process.stderr.write(`herdr-remote host connector failed: ${error.message}\n`);
273
- process.exitCode = 1;
392
+ process.exitCode = error.code === 'HOST_ALREADY_RUNNING' ? EXIT_REPLACED : 1;
274
393
  }
275
394
  const stop = () => { connector.stop(); process.exit(0); };
276
395
  process.once('SIGINT', stop);
package/src/i18n/en.js CHANGED
@@ -206,6 +206,7 @@ module.exports = {
206
206
  'error.invalidKeepalive': 'Unknown keep-alive manager.',
207
207
  'error.unknownField': 'Unknown setting.',
208
208
  'error.remoteUrlRequired': 'Relay URL is required for self-hosted relay mode.',
209
+ 'error.removedHerdrArg': 'Herdr 0.9.0 removed --no-session; remove it from the extra arguments.',
209
210
  'error.saveFailed': 'Could not save configuration: {message}',
210
211
 
211
212
  'hint.navigate': '↑↓ move',
package/src/i18n/zh.js CHANGED
@@ -205,6 +205,7 @@ module.exports = {
205
205
  'error.invalidKeepalive': '未知的保活管理方式。',
206
206
  'error.unknownField': '未知的设置项。',
207
207
  'error.remoteUrlRequired': '自建 Relay 模式须填写 Relay 地址。',
208
+ 'error.removedHerdrArg': 'Herdr 0.9.0 已移除 --no-session,请从附加参数中移除。',
208
209
  'error.saveFailed': '保存配置失败:{message}',
209
210
 
210
211
  'hint.navigate': '↑↓ 移动',
package/src/service.js CHANGED
@@ -166,6 +166,10 @@ function serviceSpecs(config = loadConfig(), state = ensureRuntime()) {
166
166
  RELAY_AUTH_STATE_FILE: relayAuthStatePath(),
167
167
  RELAY_ALLOWED_ORIGINS: (config.relay.allowedOrigins || []).join(','),
168
168
  RELAY_MAX_CLIENTS_PER_HOST: String(config.relay.maxClientsPerHost),
169
+ RELAY_MAX_HOSTS: String(config.relay.maxHosts),
170
+ RELAY_MAX_PENDING_HANDSHAKES: String(config.relay.maxPendingHandshakes),
171
+ RELAY_MAX_BUFFERED_BYTES_PER_CLIENT: String(config.relay.maxBufferedBytesPerClient),
172
+ RELAY_HOST_RECONNECT_GRACE_MS: String(config.relay.hostReconnectGraceMs),
169
173
  },
170
174
  });
171
175
  }
@@ -377,18 +381,26 @@ async function waitForRelay(config, { attempts = 20, delayMs = 100 } = {}) {
377
381
  }
378
382
 
379
383
  async function waitForHost(config, { attempts = 30, delayMs = 100 } = {}) {
380
- let lastHealth = null;
384
+ const state = ensureRuntime();
385
+ const statusEndpoint = `${resolveAdminOrigin(config)}/api/status`;
386
+ let lastStatus = null;
381
387
  for (let attempt = 0; attempt < attempts; attempt += 1) {
382
388
  try {
383
- lastHealth = await requestJson(healthUrl(config), { timeout: 800 });
384
- if (lastHealth.hosts > 0) return lastHealth;
389
+ lastStatus = await requestJson(statusEndpoint, {
390
+ timeout: 800,
391
+ headers: {
392
+ 'X-Herdr-Host-Id': state.hostId,
393
+ 'X-Herdr-Host-Token': state.hostToken,
394
+ },
395
+ });
396
+ if (lastStatus.hosts?.some((host) => host.id === state.hostId)) return lastStatus;
385
397
  } catch (error) {
386
- lastHealth = { ok: false, message: error.message };
398
+ lastStatus = { ok: false, message: error.message };
387
399
  }
388
400
  await new Promise((resolve) => setTimeout(resolve, delayMs));
389
401
  }
390
- const error = new Error(lastHealth?.message || 'the Herdr host connector did not register with the relay');
391
- error.health = lastHealth;
402
+ const error = new Error(lastStatus?.message || 'the Herdr host connector did not register with the relay');
403
+ error.health = lastStatus;
392
404
  throw error;
393
405
  }
394
406
 
@@ -204,7 +204,11 @@ function setField(draft, id, rawValue) {
204
204
  break;
205
205
  }
206
206
  case 'herdrArgs': {
207
- next.herdr.args = value ? value.split(/\s+/).filter(Boolean) : [];
207
+ const args = value ? value.split(/\s+/).filter(Boolean) : [];
208
+ if (args.includes('--no-session')) {
209
+ return { draft, errorKey: 'error.removedHerdrArg' };
210
+ }
211
+ next.herdr.args = args;
208
212
  break;
209
213
  }
210
214
  case 'language': {
package/src/supervisor.js CHANGED
@@ -5,7 +5,7 @@ const { spawn } = require('node:child_process');
5
5
  const { PACKAGE_ROOT, loadConfig, runtimeStatePath, stateDir } = require('./config');
6
6
  const { ensureDir, readJson, writeJsonAtomic } = require('./state');
7
7
  const { baseEnvironment, ensureRuntime, logPath, managedPids, pidAlive, recordManagedPid, serviceSpecs } = require('./service');
8
- const { EXIT_REPLACED } = require('./exit-codes');
8
+ const { EXIT_REPLACED, EXIT_AUTH_FAILED } = require('./exit-codes');
9
9
 
10
10
  const MIN_BACKOFF_MS = 500;
11
11
  const MAX_BACKOFF_MS = 30_000;
@@ -132,6 +132,14 @@ class Supervisor {
132
132
  });
133
133
  return;
134
134
  }
135
+ if (code === EXIT_AUTH_FAILED) {
136
+ this.emit({
137
+ type: 'fatal',
138
+ name,
139
+ message: `${name} stopped: relay authentication failed. Update credentials before restarting it.`,
140
+ });
141
+ return;
142
+ }
135
143
  if (uptimeMs >= HEALTHY_UPTIME_MS) entry.backoffMs = MIN_BACKOFF_MS;
136
144
  this.emit({
137
145
  type: 'exited',