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 +1 -1
- package/README.zh-CN.md +1 -1
- package/package.json +1 -1
- package/src/relay-server.js +63 -16
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
|
|
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`
|
|
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
package/src/relay-server.js
CHANGED
|
@@ -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
|
-
|
|
1285
|
-
|
|
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
|
-
|
|
1306
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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);
|