herdr-remote-relay 0.2.15 → 0.2.16

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/README.md CHANGED
@@ -51,7 +51,7 @@ docker run -d --name herdr-relay --restart unless-stopped \
51
51
  | `--host-reconnect-grace-ms` | `RELAY_HOST_RECONNECT_GRACE_MS` | `30000` | Host reconnect grace period |
52
52
  | `--config` | `HERDR_RELAY_CONFIG` | *(none)* | JSON configuration file path |
53
53
 
54
- Reverse proxies must pass WebSocket `Upgrade` headers and maintain long idle timeouts. The relay disables WebSocket compression and enables TCP NoDelay for terminal frames. When no browser is attached, business heartbeats stop and only WebSocket liveness probes remain. Deployment examples for nginx, systemd, and Docker Compose are in `deploy/`.
54
+ Reverse proxies must pass WebSocket `Upgrade` headers and maintain reasonable idle timeouts. The relay disables WebSocket compression and enables TCP NoDelay for terminal frames. When no browser is attached, business heartbeats stop while low-frequency transport keepalive (minimal idle heartbeat and WebSocket ping/pong watchdog) remains active to prevent proxies from dropping idle connections. Deployment examples for nginx, systemd, and Docker Compose are in `deploy/`.
55
55
 
56
56
  ## Multiple workstations and isolation
57
57
 
package/README.zh-CN.md CHANGED
@@ -51,7 +51,7 @@ docker run -d --name herdr-relay --restart unless-stopped \
51
51
  | `--host-reconnect-grace-ms` | `RELAY_HOST_RECONNECT_GRACE_MS` | `30000` | 工作站断线恢复宽限时间 |
52
52
  | `--config` | `HERDR_RELAY_CONFIG` | *(无)* | JSON 配置文件路径 |
53
53
 
54
- 反向代理必须转发 WebSocket `Upgrade` 头,并设置较长空闲超时时间。relay 会关闭 WebSocket 压缩并启用 TCP NoDelay;没有浏览器连接时,工作站会停止业务 heartbeat,仅保留 WebSocket 存活探测以节省流量。`deploy/` 目录下提供 nginx、systemd 与 Docker Compose 示例。
54
+ 反向代理必须转发 WebSocket `Upgrade` 头,并设置合理的空闲超时时间。relay 会关闭 WebSocket 压缩并启用 TCP NoDelay;没有浏览器连接时,工作站会停止业务 heartbeat,同时保留低频 transport keepalive(最小空闲 heartbeat 与 WebSocket ping/pong 探针)以防止反代断开空闲连接并维持 relay 存活。`deploy/` 目录下提供 nginx、systemd 与 Docker Compose 示例。
55
55
 
56
56
  ## 多工作站与隔离
57
57
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "herdr-remote-relay",
3
- "version": "0.2.15",
3
+ "version": "0.2.16",
4
4
  "description": "Standalone relay server and web terminal for Herdr Remote",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -52,6 +52,17 @@ function closeSocket(socket, code = 1000, reason = '') {
52
52
  } catch {}
53
53
  }
54
54
 
55
+ function terminateSocket(socket) {
56
+ if (!socket || socket.readyState === WebSocket.CLOSED) return;
57
+ try {
58
+ if (typeof socket.terminate === 'function') {
59
+ socket.terminate();
60
+ } else if (typeof socket.destroy === 'function') {
61
+ socket.destroy();
62
+ }
63
+ } catch {}
64
+ }
65
+
55
66
  function parseJson(data) {
56
67
  if (typeof data !== 'string') return null;
57
68
  try {
@@ -668,6 +679,10 @@ class RelayServer {
668
679
  ws.isAlive = true;
669
680
  if (pending.host) pending.host.lastSeenAt = Date.now();
670
681
  });
682
+ ws.on('ping', () => {
683
+ ws.isAlive = true;
684
+ if (pending.host) pending.host.lastSeenAt = Date.now();
685
+ });
671
686
  ws.on('message', (raw, isBinary) => {
672
687
  if (!pending.authenticated) {
673
688
  if (isBinary) return this.rejectHandshake(ws, 'host hello must be JSON');
@@ -975,6 +990,21 @@ class RelayServer {
975
990
  const deadline = setTimeout(() => {
976
991
  if (!pending.authenticated) closeSocket(ws, 1008, 'client hello timeout');
977
992
  }, 10000);
993
+ ws.isAlive = true;
994
+ ws.on('pong', () => {
995
+ ws.isAlive = true;
996
+ if (pending.client) {
997
+ pending.client.lastSeenAt = Date.now();
998
+ pending.client.lastPingAt = new Date().toISOString();
999
+ }
1000
+ });
1001
+ ws.on('ping', () => {
1002
+ ws.isAlive = true;
1003
+ if (pending.client) {
1004
+ pending.client.lastSeenAt = Date.now();
1005
+ pending.client.lastPingAt = new Date().toISOString();
1006
+ }
1007
+ });
978
1008
  ws.on('message', (raw, isBinary) => {
979
1009
  if (!pending.authenticated) {
980
1010
  if (isBinary) return this.rejectHandshake(ws, 'client hello must be JSON');
@@ -1036,11 +1066,6 @@ class RelayServer {
1036
1066
  host.clients.add(client.id);
1037
1067
  this.notifyHostClientCount(host);
1038
1068
  ws.isAlive = true;
1039
- ws.on('pong', () => {
1040
- ws.isAlive = true;
1041
- client.lastSeenAt = Date.now();
1042
- client.lastPingAt = new Date().toISOString();
1043
- });
1044
1069
  if (paired) jsonSend(ws, { type: 'paired', token: paired.token, deviceId: paired.deviceId, hostId: paired.hostId, expiresAt: paired.expiresAtIso });
1045
1070
  // Expose the relay-assigned connection id so clients can distinguish
1046
1071
  // their own controller lease from another device's lease. The browser
@@ -1255,7 +1280,7 @@ class RelayServer {
1255
1280
  return closed;
1256
1281
  }
1257
1282
 
1258
- detachClient(client, { notify = true, reason = 'client_disconnected', closeCode = 1000 } = {}) {
1283
+ detachClient(client, { notify = true, reason = 'client_disconnected', closeCode = 1000, terminate = false } = {}) {
1259
1284
  if (!client || !this.clients.has(client.id)) return;
1260
1285
  this.clients.delete(client.id);
1261
1286
  const host = this.hosts.get(client.hostId);
@@ -1281,12 +1306,18 @@ class RelayServer {
1281
1306
  if (host.reconnecting) this.detachHost(host, { notify: false, reason: 'no_clients' });
1282
1307
  }
1283
1308
  }
1284
- if (notify) jsonSend(client.ws, { type: 'error', code: reason, message: reason === 'host_offline' ? 'Herdr host is offline' : 'connection closed' });
1285
- closeSocket(client.ws, closeCode, reason);
1309
+ const clientSocket = client.ws;
1310
+ client.ws = null;
1311
+ if (notify) jsonSend(clientSocket, { type: 'error', code: reason, message: reason === 'host_offline' ? 'Herdr host is offline' : 'connection closed' });
1312
+ if (terminate || clientSocket?.readyState === WebSocket.CLOSING) {
1313
+ terminateSocket(clientSocket);
1314
+ } else {
1315
+ closeSocket(clientSocket, closeCode, reason);
1316
+ }
1286
1317
  this.metrics.recordCleanup('closedPtysCleaned');
1287
1318
  }
1288
1319
 
1289
- detachHost(host, { notify = true, reason = 'host_disconnected' } = {}) {
1320
+ detachHost(host, { notify = true, reason = 'host_disconnected', terminate = false } = {}) {
1290
1321
  if (!host || this.hosts.get(host.id) !== host) return;
1291
1322
  if (host.reconnectTimer) clearTimeout(host.reconnectTimer);
1292
1323
  host.reconnectTimer = null;
@@ -1302,25 +1333,41 @@ class RelayServer {
1302
1333
  this.streams.delete(client.session.streamId);
1303
1334
  client.session = null;
1304
1335
  }
1305
- if (notify) jsonSend(client.ws, { type: 'error', code: reason, message: 'Herdr host disconnected' });
1306
- closeSocket(client.ws, 1012, reason);
1336
+ const clientSocket = client.ws;
1337
+ client.ws = null;
1338
+ if (notify) jsonSend(clientSocket, { type: 'error', code: reason, message: 'Herdr host disconnected' });
1339
+ if (terminate || clientSocket?.readyState === WebSocket.CLOSING) {
1340
+ terminateSocket(clientSocket);
1341
+ } else {
1342
+ closeSocket(clientSocket, 1012, reason);
1343
+ }
1307
1344
  }
1308
1345
  host.clients.clear();
1309
1346
  this.metrics.forgetHost(host.id);
1310
1347
  const hostSocket = host.ws;
1311
1348
  host.ws = null;
1312
- closeSocket(hostSocket, 1000, reason);
1349
+ if (terminate || hostSocket?.readyState === WebSocket.CLOSING) {
1350
+ terminateSocket(hostSocket);
1351
+ } else {
1352
+ closeSocket(hostSocket, 1000, reason);
1353
+ }
1313
1354
  }
1314
1355
 
1315
1356
  heartbeat() {
1316
1357
  const sockets = [
1317
1358
  ...[...this.hosts.values()].map((host) => host.ws),
1318
1359
  ...[...this.clients.values()].map((client) => client.ws),
1319
- ].filter(isOpen);
1360
+ ].filter((socket) => socket && socket.readyState !== WebSocket.CLOSED);
1320
1361
  for (const socket of sockets) {
1362
+ if (socket.readyState === WebSocket.CLOSING) {
1363
+ this.metrics.recordCleanup('deadConnectionsClosed');
1364
+ terminateSocket(socket);
1365
+ continue;
1366
+ }
1367
+ if (socket.readyState !== WebSocket.OPEN) continue;
1321
1368
  if (!socket.isAlive) {
1322
1369
  this.metrics.recordCleanup('deadConnectionsClosed');
1323
- closeSocket(socket, 1001, 'heartbeat timeout');
1370
+ terminateSocket(socket);
1324
1371
  continue;
1325
1372
  }
1326
1373
  socket.isAlive = false;
@@ -1345,14 +1392,14 @@ class RelayServer {
1345
1392
  for (const client of [...this.clients.values()]) {
1346
1393
  if (now - client.lastSeenAt > staleAfter) {
1347
1394
  this.metrics.recordCleanup('staleClientsPurged');
1348
- this.detachClient(client, { notify: false, reason: 'stale_client' });
1395
+ this.detachClient(client, { notify: false, reason: 'stale_client', terminate: true });
1349
1396
  }
1350
1397
  }
1351
1398
  for (const host of [...this.hosts.values()]) {
1352
1399
  if (host.reconnecting) continue;
1353
1400
  if (now - host.lastSeenAt > staleAfter) {
1354
1401
  this.metrics.recordCleanup('deadConnectionsClosed');
1355
- this.detachHost(host, { notify: true, reason: 'stale_host' });
1402
+ this.detachHost(host, { notify: true, reason: 'stale_host', terminate: true });
1356
1403
  }
1357
1404
  }
1358
1405
  const authCleanup = this.auth.cleanup(now);