herdr-remote 0.2.4 → 0.2.5

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);
@@ -238,6 +256,10 @@ var require_config = __commonJS({
238
256
  if (env.RELAY_PORT) config.relay.port = env.RELAY_PORT;
239
257
  if (env.RELAY_PUBLIC_URL) config.relay.publicUrl = env.RELAY_PUBLIC_URL;
240
258
  if (env.RELAY_REMOTE_URL) config.relay.remoteUrl = env.RELAY_REMOTE_URL;
259
+ if (env.RELAY_MAX_HOSTS) config.relay.maxHosts = env.RELAY_MAX_HOSTS;
260
+ if (env.RELAY_MAX_PENDING_HANDSHAKES) config.relay.maxPendingHandshakes = env.RELAY_MAX_PENDING_HANDSHAKES;
261
+ if (env.RELAY_MAX_BUFFERED_BYTES_PER_CLIENT) config.relay.maxBufferedBytesPerClient = env.RELAY_MAX_BUFFERED_BYTES_PER_CLIENT;
262
+ if (env.RELAY_HOST_RECONNECT_GRACE_MS) config.relay.hostReconnectGraceMs = env.RELAY_HOST_RECONNECT_GRACE_MS;
241
263
  if (env.HERDR_SOCKET_PATH) config.herdr.socketPath = env.HERDR_SOCKET_PATH;
242
264
  if (env.HERDR_CWD) config.herdr.cwd = env.HERDR_CWD;
243
265
  if (env.HERDR_ARGS_JSON) {
@@ -1550,7 +1572,11 @@ var require_service = __commonJS({
1550
1572
  RELAY_PASSWORD: state.hostToken,
1551
1573
  RELAY_AUTH_STATE_FILE: relayAuthStatePath(),
1552
1574
  RELAY_ALLOWED_ORIGINS: (config.relay.allowedOrigins || []).join(","),
1553
- RELAY_MAX_CLIENTS_PER_HOST: String(config.relay.maxClientsPerHost)
1575
+ RELAY_MAX_CLIENTS_PER_HOST: String(config.relay.maxClientsPerHost),
1576
+ RELAY_MAX_HOSTS: String(config.relay.maxHosts),
1577
+ RELAY_MAX_PENDING_HANDSHAKES: String(config.relay.maxPendingHandshakes),
1578
+ RELAY_MAX_BUFFERED_BYTES_PER_CLIENT: String(config.relay.maxBufferedBytesPerClient),
1579
+ RELAY_HOST_RECONNECT_GRACE_MS: String(config.relay.hostReconnectGraceMs)
1554
1580
  }
1555
1581
  });
1556
1582
  }
@@ -1733,18 +1759,26 @@ var require_service = __commonJS({
1733
1759
  throw lastError || new Error("relay did not become ready");
1734
1760
  }
1735
1761
  async function waitForHost(config, { attempts = 30, delayMs = 100 } = {}) {
1736
- let lastHealth = null;
1762
+ const state = ensureRuntime2();
1763
+ const statusEndpoint = `${resolveAdminOrigin2(config)}/api/status`;
1764
+ let lastStatus = null;
1737
1765
  for (let attempt = 0; attempt < attempts; attempt += 1) {
1738
1766
  try {
1739
- lastHealth = await requestJson2(healthUrl(config), { timeout: 800 });
1740
- if (lastHealth.hosts > 0) return lastHealth;
1767
+ lastStatus = await requestJson2(statusEndpoint, {
1768
+ timeout: 800,
1769
+ headers: {
1770
+ "X-Herdr-Host-Id": state.hostId,
1771
+ "X-Herdr-Host-Token": state.hostToken
1772
+ }
1773
+ });
1774
+ if (lastStatus.hosts?.some((host) => host.id === state.hostId)) return lastStatus;
1741
1775
  } catch (error2) {
1742
- lastHealth = { ok: false, message: error2.message };
1776
+ lastStatus = { ok: false, message: error2.message };
1743
1777
  }
1744
1778
  await new Promise((resolve) => setTimeout(resolve, delayMs));
1745
1779
  }
1746
- const error = new Error(lastHealth?.message || "the Herdr host connector did not register with the relay");
1747
- error.health = lastHealth;
1780
+ const error = new Error(lastStatus?.message || "the Herdr host connector did not register with the relay");
1781
+ error.health = lastStatus;
1748
1782
  throw error;
1749
1783
  }
1750
1784
  async function statusServices() {
@@ -2827,12 +2861,26 @@ function relayStartCommand(config, password) {
2827
2861
  return parts.join(" ");
2828
2862
  }
2829
2863
  async function probeRelay(config) {
2864
+ const origin = (0, import_config.resolveAdminOrigin)(config);
2865
+ let health;
2830
2866
  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 };
2867
+ health = await (0, import_service.requestJson)(`${origin}/healthz`, { timeout: 4e3 });
2833
2868
  } catch (error) {
2834
2869
  return { ok: false, message: error.message };
2835
2870
  }
2871
+ try {
2872
+ const runtime = (0, import_service.ensureRuntime)();
2873
+ const status = await (0, import_service.requestJson)(`${origin}/api/status`, {
2874
+ timeout: 4e3,
2875
+ headers: {
2876
+ "X-Herdr-Host-Id": runtime.hostId,
2877
+ "X-Herdr-Host-Token": runtime.hostToken
2878
+ }
2879
+ });
2880
+ return { ok: true, version: String(health.version || status.version || ""), hosts: Array.isArray(status.hosts) ? status.hosts.length : 0 };
2881
+ } catch {
2882
+ return { ok: true, version: String(health.version || ""), hosts: void 0 };
2883
+ }
2836
2884
  }
2837
2885
  function formatUptime(seconds, t) {
2838
2886
  if (!Number.isFinite(seconds)) return t("common.unknown");
@@ -3831,7 +3879,7 @@ function About({ ctx }) {
3831
3879
  children: updateLabel
3832
3880
  }
3833
3881
  ) }),
3834
- /* @__PURE__ */ jsx10(Row, { label: t("about.version"), children: /* @__PURE__ */ jsx10(Text9, { color: theme.muted, children: "0.2.4" }) }),
3882
+ /* @__PURE__ */ jsx10(Row, { label: t("about.version"), children: /* @__PURE__ */ jsx10(Text9, { color: theme.muted, children: "0.2.5" }) }),
3835
3883
  /* @__PURE__ */ jsx10(Row, { label: t("about.configPath"), children: /* @__PURE__ */ jsx10(Text9, { color: theme.muted, children: (0, import_config.configPath)() }) }),
3836
3884
  /* @__PURE__ */ jsx10(Row, { label: t("about.statePath"), children: /* @__PURE__ */ jsx10(Text9, { color: theme.muted, children: (0, import_config.stateDir)() }) }),
3837
3885
  /* @__PURE__ */ jsx10(Box9, { marginTop: 1, children: /* @__PURE__ */ jsx10(Text9, { color: theme.muted, children: t("about.docs") }) }),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "herdr-remote",
3
- "version": "0.2.4",
3
+ "version": "0.2.5",
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);
@@ -277,6 +295,10 @@ function applyEnvironment(config) {
277
295
  if (env.RELAY_PORT) config.relay.port = env.RELAY_PORT;
278
296
  if (env.RELAY_PUBLIC_URL) config.relay.publicUrl = env.RELAY_PUBLIC_URL;
279
297
  if (env.RELAY_REMOTE_URL) config.relay.remoteUrl = env.RELAY_REMOTE_URL;
298
+ if (env.RELAY_MAX_HOSTS) config.relay.maxHosts = env.RELAY_MAX_HOSTS;
299
+ if (env.RELAY_MAX_PENDING_HANDSHAKES) config.relay.maxPendingHandshakes = env.RELAY_MAX_PENDING_HANDSHAKES;
300
+ if (env.RELAY_MAX_BUFFERED_BYTES_PER_CLIENT) config.relay.maxBufferedBytesPerClient = env.RELAY_MAX_BUFFERED_BYTES_PER_CLIENT;
301
+ if (env.RELAY_HOST_RECONNECT_GRACE_MS) config.relay.hostReconnectGraceMs = env.RELAY_HOST_RECONNECT_GRACE_MS;
280
302
  if (env.HERDR_SOCKET_PATH) config.herdr.socketPath = env.HERDR_SOCKET_PATH;
281
303
  if (env.HERDR_CWD) config.herdr.cwd = env.HERDR_CWD;
282
304
  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/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
 
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',