herdr-remote-relay 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.
- package/package.json +1 -1
- package/src/metrics.js +32 -2
- package/src/relay-server.js +140 -211
package/package.json
CHANGED
package/src/metrics.js
CHANGED
|
@@ -12,6 +12,29 @@ function bytesPerSecond(current, previous, elapsedMs) {
|
|
|
12
12
|
return Math.max(0, (current - previous) * 1000 / elapsedMs);
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
/**
|
|
16
|
+
* How many people are attached, rather than how many sockets are open.
|
|
17
|
+
*
|
|
18
|
+
* A phone and a laptop belonging to one person are two connections but one
|
|
19
|
+
* paired device each, and one person opening three browser windows is three
|
|
20
|
+
* connections and one device — so the distinct paired device is the closest
|
|
21
|
+
* thing the relay has to a user. Connections that predate device pairing carry
|
|
22
|
+
* no `deviceId`; they fall back to their own connection id so they still count
|
|
23
|
+
* once instead of collapsing into a single anonymous user.
|
|
24
|
+
*/
|
|
25
|
+
function countActiveUsers(clients = []) {
|
|
26
|
+
const identities = new Set();
|
|
27
|
+
for (const client of clients) {
|
|
28
|
+
if (!client) continue;
|
|
29
|
+
identities.add(
|
|
30
|
+
typeof client.deviceId === 'string' && client.deviceId
|
|
31
|
+
? `device:${client.deviceId}`
|
|
32
|
+
: `client:${client.id}`
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
return identities.size;
|
|
36
|
+
}
|
|
37
|
+
|
|
15
38
|
class RelayMetrics {
|
|
16
39
|
constructor({ version = '0.1.0', protocolVersion = 1 } = {}) {
|
|
17
40
|
this.version = version;
|
|
@@ -143,7 +166,13 @@ class RelayMetrics {
|
|
|
143
166
|
};
|
|
144
167
|
}
|
|
145
168
|
|
|
146
|
-
|
|
169
|
+
/**
|
|
170
|
+
* `activeUserCount` is passed in rather than derived from `clients`: the
|
|
171
|
+
* public `/api/status` rows deliberately omit `deviceId`, so only the caller
|
|
172
|
+
* still holding the full connection records can count devices correctly.
|
|
173
|
+
* Omitting it falls back to counting whatever the given rows identify.
|
|
174
|
+
*/
|
|
175
|
+
snapshot({ clients = [], hosts = [], ptys = [], sample = true, scopeHostId = null, activeUserCount = null } = {}) {
|
|
147
176
|
const now = Date.now();
|
|
148
177
|
const elapsedMs = now - this.lastSample.at;
|
|
149
178
|
const throughput = {
|
|
@@ -181,6 +210,7 @@ class RelayMetrics {
|
|
|
181
210
|
clientCount: clients.length,
|
|
182
211
|
hostCount: hosts.length,
|
|
183
212
|
ptyCount: ptys.length,
|
|
213
|
+
activeUserCount: Number.isFinite(activeUserCount) ? activeUserCount : countActiveUsers(clients),
|
|
184
214
|
throughput: scopedThroughput,
|
|
185
215
|
cpu: {
|
|
186
216
|
load1m: finite(load[0]),
|
|
@@ -208,4 +238,4 @@ class RelayMetrics {
|
|
|
208
238
|
}
|
|
209
239
|
}
|
|
210
240
|
|
|
211
|
-
module.exports = { RelayMetrics };
|
|
241
|
+
module.exports = { RelayMetrics, countActiveUsers };
|
package/src/relay-server.js
CHANGED
|
@@ -9,7 +9,7 @@ const { URL } = require('node:url');
|
|
|
9
9
|
const { WebSocketServer, WebSocket } = require('ws');
|
|
10
10
|
const { loadRelayConfig, defaultStateDir, PACKAGE_ROOT } = require('./relay-config');
|
|
11
11
|
const { AuthStore } = require('./auth-store');
|
|
12
|
-
const { RelayMetrics } = require('./metrics');
|
|
12
|
+
const { RelayMetrics, countActiveUsers } = require('./metrics');
|
|
13
13
|
const { unpackStreamFrame, packStreamFrame, sanitizeTerminalPalette } = require('./stream-frame');
|
|
14
14
|
const { ensureDir } = require('./state');
|
|
15
15
|
|
|
@@ -17,19 +17,9 @@ const VERSION = require('../package.json').version;
|
|
|
17
17
|
const { PROTOCOL_VERSION } = require('./stream-frame');
|
|
18
18
|
const MAX_DIMENSION = 500;
|
|
19
19
|
|
|
20
|
-
/**
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
* A browser that joins a session already in progress has missed everything
|
|
24
|
-
* printed before it arrived. Replaying the tail of the stream is what makes
|
|
25
|
-
* "every window shows the same thing" true on the *first* frame rather than
|
|
26
|
-
* only after the next repaint.
|
|
27
|
-
*/
|
|
28
|
-
const SESSION_REPLAY_BYTES = 512 * 1024;
|
|
29
|
-
|
|
30
|
-
/** Never shrink a shared grid below something a program can still draw in. */
|
|
31
|
-
const MIN_SHARED_COLS = 20;
|
|
32
|
-
const MIN_SHARED_ROWS = 6;
|
|
20
|
+
/** Never shrink an individual session grid below something a program can still draw in. */
|
|
21
|
+
const MIN_SESSION_COLS = 20;
|
|
22
|
+
const MIN_SESSION_ROWS = 6;
|
|
33
23
|
|
|
34
24
|
function randomId(prefix) {
|
|
35
25
|
return `${prefix}-${crypto.randomBytes(9).toString('base64url')}`;
|
|
@@ -97,6 +87,7 @@ class RelayServer {
|
|
|
97
87
|
this.relayMode = config.relay?.mode === 'local' ? 'local' : 'remote';
|
|
98
88
|
this.hosts = new Map();
|
|
99
89
|
this.clients = new Map();
|
|
90
|
+
this.streams = new Map();
|
|
100
91
|
this.pairAttempts = new Map();
|
|
101
92
|
this.clientHandshakeAttempts = new Map();
|
|
102
93
|
this.hostHandshakeAttempts = new Map();
|
|
@@ -160,6 +151,7 @@ class RelayServer {
|
|
|
160
151
|
this.cleanupTimer = null;
|
|
161
152
|
for (const client of [...this.clients.values()]) this.detachClient(client, { notify: false });
|
|
162
153
|
for (const host of [...this.hosts.values()]) this.detachHost(host, { notify: false });
|
|
154
|
+
this.streams.clear();
|
|
163
155
|
this.pairAttempts.clear();
|
|
164
156
|
this.clientHandshakeAttempts.clear();
|
|
165
157
|
this.hostHandshakeAttempts.clear();
|
|
@@ -516,10 +508,6 @@ class RelayServer {
|
|
|
516
508
|
arch: typeof message.arch === 'string' ? message.arch.slice(0, 32) : process.arch,
|
|
517
509
|
connectedAt: new Date(pending.connectedAt).toISOString(),
|
|
518
510
|
connectedAtMs: pending.connectedAt,
|
|
519
|
-
// One shared terminal per workstation. Every browser attached to this
|
|
520
|
-
// host reads and writes the same PTY, so what one of them shows is what
|
|
521
|
-
// all of them show.
|
|
522
|
-
session: null,
|
|
523
511
|
terminalPalette: sanitizeTerminalPalette(message.terminalPalette),
|
|
524
512
|
lastSeenAt: Date.now(),
|
|
525
513
|
clients,
|
|
@@ -555,7 +543,13 @@ class RelayServer {
|
|
|
555
543
|
host.lastSeenAt = Date.now();
|
|
556
544
|
host.load = {};
|
|
557
545
|
host.ptys = [];
|
|
558
|
-
host.
|
|
546
|
+
for (const clientId of host.clients) {
|
|
547
|
+
const client = this.clients.get(clientId);
|
|
548
|
+
if (client?.session) {
|
|
549
|
+
this.streams.delete(client.session.streamId);
|
|
550
|
+
client.session = null;
|
|
551
|
+
}
|
|
552
|
+
}
|
|
559
553
|
this.broadcastToClients(host, () => ({ type: 'host_reconnecting', code: reason }));
|
|
560
554
|
host.reconnectTimer = setTimeout(() => {
|
|
561
555
|
host.reconnectTimer = null;
|
|
@@ -598,10 +592,20 @@ class RelayServer {
|
|
|
598
592
|
if (oldHost.reconnectTimer) clearTimeout(oldHost.reconnectTimer);
|
|
599
593
|
oldHost.reconnectTimer = null;
|
|
600
594
|
if (handoff) {
|
|
601
|
-
|
|
602
|
-
|
|
595
|
+
for (const clientId of oldHost.clients) {
|
|
596
|
+
const client = this.clients.get(clientId);
|
|
597
|
+
if (client?.session) {
|
|
598
|
+
if (isOpen(oldHost.ws)) {
|
|
599
|
+
jsonSend(oldHost.ws, {
|
|
600
|
+
type: 'session_stop',
|
|
601
|
+
clientId: client.session.streamId,
|
|
602
|
+
streamId: client.session.streamId,
|
|
603
|
+
});
|
|
604
|
+
}
|
|
605
|
+
this.streams.delete(client.session.streamId);
|
|
606
|
+
client.session = null;
|
|
607
|
+
}
|
|
603
608
|
}
|
|
604
|
-
oldHost.session = null;
|
|
605
609
|
oldHost.load = {};
|
|
606
610
|
oldHost.ptys = [];
|
|
607
611
|
this.broadcastToClients(oldHost, () => ({ type: 'host_reconnecting', code: 'host_replaced' }));
|
|
@@ -624,7 +628,12 @@ class RelayServer {
|
|
|
624
628
|
clientCount: host.clients.size,
|
|
625
629
|
});
|
|
626
630
|
this.notifyHostClientCount(host);
|
|
627
|
-
if (handoff)
|
|
631
|
+
if (handoff) {
|
|
632
|
+
for (const clientId of host.clients) {
|
|
633
|
+
const client = this.clients.get(clientId);
|
|
634
|
+
if (client) this.startSession(host, client, { restarted: true });
|
|
635
|
+
}
|
|
636
|
+
}
|
|
628
637
|
return;
|
|
629
638
|
}
|
|
630
639
|
this.handleHostMessage(pending.host, raw, isBinary);
|
|
@@ -660,16 +669,13 @@ class RelayServer {
|
|
|
660
669
|
closeSocket(host.ws, 1003, error.message);
|
|
661
670
|
return;
|
|
662
671
|
}
|
|
663
|
-
// Output
|
|
664
|
-
|
|
665
|
-
const
|
|
666
|
-
if (
|
|
667
|
-
this.
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
if (!client || !isOpen(client.ws)) continue;
|
|
671
|
-
this.sendClientBinary(host, client, frame.payload);
|
|
672
|
-
}
|
|
672
|
+
// Output is routed to the single client owning the stream rather than broadcast.
|
|
673
|
+
if (frame.type !== 'output') return;
|
|
674
|
+
const clientId = this.streams.get(frame.streamId);
|
|
675
|
+
if (!clientId) return;
|
|
676
|
+
const client = this.clients.get(clientId);
|
|
677
|
+
if (!client || !isOpen(client.ws)) return;
|
|
678
|
+
this.sendClientBinary(host, client, frame.payload);
|
|
673
679
|
return;
|
|
674
680
|
}
|
|
675
681
|
const message = parseJson(raw.toString());
|
|
@@ -684,28 +690,29 @@ class RelayServer {
|
|
|
684
690
|
if (this.hosts.get(host.id) === host) this.detachHost(host, { notify: true, reason: 'host_shutdown' });
|
|
685
691
|
return;
|
|
686
692
|
}
|
|
687
|
-
// Session
|
|
688
|
-
// shared stream, and every attached browser has to hear it.
|
|
689
|
-
const session = host.session;
|
|
693
|
+
// Session events target the single client that owns this stream.
|
|
690
694
|
const streamId = typeof message.clientId === 'string' ? message.clientId : message.streamId;
|
|
691
|
-
|
|
695
|
+
const clientId = streamId ? this.streams.get(streamId) : null;
|
|
696
|
+
const client = clientId ? this.clients.get(clientId) : null;
|
|
697
|
+
if (!client) return;
|
|
698
|
+
|
|
692
699
|
if (message.type === 'session_ready') {
|
|
693
|
-
session.ready = true;
|
|
694
|
-
|
|
700
|
+
if (client.session) client.session.ready = true;
|
|
701
|
+
jsonSend(client.ws, { type: 'session_ready', clientId: client.id });
|
|
695
702
|
} else if (message.type === 'session_exit') {
|
|
696
703
|
const code = Number.isInteger(message.code) ? message.code : null;
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
if (client) this.detachClient(client, { notify: false });
|
|
704
|
+
jsonSend(client.ws, { type: 'exit', code });
|
|
705
|
+
if (client.session) {
|
|
706
|
+
this.streams.delete(client.session.streamId);
|
|
707
|
+
client.session = null;
|
|
702
708
|
}
|
|
709
|
+
this.detachClient(client, { notify: false });
|
|
703
710
|
} else if (message.type === 'error') {
|
|
704
|
-
|
|
711
|
+
jsonSend(client.ws, {
|
|
705
712
|
type: 'error',
|
|
706
713
|
code: message.code || 'host_error',
|
|
707
714
|
message: String(message.message || 'Host connector error'),
|
|
708
|
-
})
|
|
715
|
+
});
|
|
709
716
|
}
|
|
710
717
|
}
|
|
711
718
|
|
|
@@ -749,147 +756,35 @@ class RelayServer {
|
|
|
749
756
|
}
|
|
750
757
|
}
|
|
751
758
|
|
|
752
|
-
/**
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
/**
|
|
762
|
-
* The grid the shared PTY runs at.
|
|
763
|
-
*
|
|
764
|
-
* The smallest attached window wins, exactly as it does in tmux: a column a
|
|
765
|
-
* phone cannot show is a column the program must not paint, or every other
|
|
766
|
-
* window sees wrapped rubbish. Nothing else keeps a shared terminal legible
|
|
767
|
-
* on two different screens at once.
|
|
768
|
-
*/
|
|
769
|
-
sharedDimensions(host) {
|
|
770
|
-
let cols = MAX_DIMENSION;
|
|
771
|
-
let rows = MAX_DIMENSION;
|
|
772
|
-
let found = false;
|
|
773
|
-
for (const clientId of host.clients) {
|
|
774
|
-
const client = this.clients.get(clientId);
|
|
775
|
-
if (!client) continue;
|
|
776
|
-
found = true;
|
|
777
|
-
cols = Math.min(cols, client.cols);
|
|
778
|
-
rows = Math.min(rows, client.rows);
|
|
779
|
-
}
|
|
780
|
-
if (!found) return null;
|
|
781
|
-
return {
|
|
782
|
-
cols: Math.max(MIN_SHARED_COLS, cols),
|
|
783
|
-
rows: Math.max(MIN_SHARED_ROWS, rows),
|
|
784
|
-
};
|
|
785
|
-
}
|
|
786
|
-
|
|
787
|
-
/** Start the one shared PTY for a host's currently attached clients. */
|
|
788
|
-
startSession(host, { restarted = false } = {}) {
|
|
789
|
-
const firstClientId = [...host.clients][0];
|
|
790
|
-
const firstClient = firstClientId ? this.clients.get(firstClientId) : null;
|
|
791
|
-
if (!firstClient || !isOpen(host.ws)) return;
|
|
792
|
-
host.session = {
|
|
793
|
-
streamId: randomId('session'),
|
|
794
|
-
cols: firstClient.cols,
|
|
795
|
-
rows: firstClient.rows,
|
|
759
|
+
/** Start a dedicated PTY session for one attached client. */
|
|
760
|
+
startSession(host, client, { restarted = false } = {}) {
|
|
761
|
+
if (!client || !isOpen(host.ws)) return;
|
|
762
|
+
const streamId = randomId('session');
|
|
763
|
+
client.session = {
|
|
764
|
+
streamId,
|
|
765
|
+
cols: client.cols,
|
|
766
|
+
rows: client.rows,
|
|
796
767
|
ready: false,
|
|
797
|
-
replay: [],
|
|
798
|
-
replayBytes: 0,
|
|
799
768
|
};
|
|
800
|
-
|
|
801
|
-
host.session.cols = dims.cols;
|
|
802
|
-
host.session.rows = dims.rows;
|
|
769
|
+
this.streams.set(streamId, client.id);
|
|
803
770
|
jsonSend(host.ws, {
|
|
804
771
|
type: 'session_start',
|
|
805
|
-
clientId:
|
|
806
|
-
streamId
|
|
807
|
-
cols:
|
|
808
|
-
rows:
|
|
772
|
+
clientId: streamId,
|
|
773
|
+
streamId,
|
|
774
|
+
cols: Math.max(MIN_SESSION_COLS, client.cols),
|
|
775
|
+
rows: Math.max(MIN_SESSION_ROWS, client.rows),
|
|
809
776
|
role: 'controller',
|
|
810
777
|
});
|
|
811
|
-
this.broadcastToClients(host, () => ({ type: 'shared_resize', cols: dims.cols, rows: dims.rows }));
|
|
812
778
|
if (restarted) {
|
|
813
|
-
|
|
779
|
+
jsonSend(client.ws, {
|
|
814
780
|
type: 'session_restarted',
|
|
815
|
-
streamId
|
|
816
|
-
cols:
|
|
817
|
-
rows:
|
|
781
|
+
streamId,
|
|
782
|
+
cols: client.cols,
|
|
783
|
+
rows: client.rows,
|
|
818
784
|
hostname: host.hostname,
|
|
819
785
|
terminalPalette: host.terminalPalette || null,
|
|
820
|
-
}));
|
|
821
|
-
}
|
|
822
|
-
}
|
|
823
|
-
|
|
824
|
-
/**
|
|
825
|
-
* Attach `client` to the workstation's shared terminal, starting it if this
|
|
826
|
-
* is the first browser through the door.
|
|
827
|
-
*
|
|
828
|
-
* A later arrival does not get its own PTY: it is handed the stream already
|
|
829
|
-
* running, the output that has been printed so far, and — once the geometry
|
|
830
|
-
* has settled — a repaint, so it lands on the same screen everyone else is
|
|
831
|
-
* looking at.
|
|
832
|
-
*/
|
|
833
|
-
attachSession(host, client) {
|
|
834
|
-
if (!host.session) {
|
|
835
|
-
this.startSession(host);
|
|
836
|
-
return;
|
|
837
|
-
}
|
|
838
|
-
|
|
839
|
-
const session = host.session;
|
|
840
|
-
if (session.ready) jsonSend(client.ws, { type: 'session_ready', clientId: client.id });
|
|
841
|
-
for (const chunk of session.replay) {
|
|
842
|
-
if (!isOpen(client.ws)) break;
|
|
843
|
-
this.sendClientBinary(host, client, chunk);
|
|
844
|
-
}
|
|
845
|
-
// Geometry may now be smaller than it was; the resize doubles as the
|
|
846
|
-
// repaint that puts the newcomer on the same screen as everyone else.
|
|
847
|
-
this.syncDimensions(host, { force: true });
|
|
848
|
-
}
|
|
849
|
-
|
|
850
|
-
/**
|
|
851
|
-
* Push the shared grid to the workstation.
|
|
852
|
-
*
|
|
853
|
-
* `force` asks for a repaint even when the numbers did not move: a browser
|
|
854
|
-
* that just joined needs the program to draw itself again, and a resize is
|
|
855
|
-
* the only signal a PTY has for "paint everything".
|
|
856
|
-
*/
|
|
857
|
-
syncDimensions(host, { force = false } = {}) {
|
|
858
|
-
const session = host.session;
|
|
859
|
-
if (!session) return;
|
|
860
|
-
const dims = this.sharedDimensions(host);
|
|
861
|
-
if (!dims) return;
|
|
862
|
-
const changed = dims.cols !== session.cols || dims.rows !== session.rows;
|
|
863
|
-
session.cols = dims.cols;
|
|
864
|
-
session.rows = dims.rows;
|
|
865
|
-
if (!changed && !force) return;
|
|
866
|
-
// Every window has to be told what the shared grid became, not just the
|
|
867
|
-
// workstation. A browser that keeps rendering at its own width would wrap
|
|
868
|
-
// a stream written for a narrower terminal, which is the one thing a
|
|
869
|
-
// shared session must not do: the same bytes have to look the same in
|
|
870
|
-
// every window.
|
|
871
|
-
this.broadcastToClients(host, () => ({
|
|
872
|
-
type: 'shared_resize',
|
|
873
|
-
cols: dims.cols,
|
|
874
|
-
rows: dims.rows,
|
|
875
|
-
}));
|
|
876
|
-
if (!changed && force) {
|
|
877
|
-
// A no-op resize is ignored by the PTY, so bounce one row and come back.
|
|
878
|
-
jsonSend(host.ws, {
|
|
879
|
-
type: 'resize',
|
|
880
|
-
clientId: session.streamId,
|
|
881
|
-
streamId: session.streamId,
|
|
882
|
-
cols: dims.cols,
|
|
883
|
-
rows: Math.max(MIN_SHARED_ROWS, dims.rows - 1),
|
|
884
786
|
});
|
|
885
787
|
}
|
|
886
|
-
jsonSend(host.ws, {
|
|
887
|
-
type: 'resize',
|
|
888
|
-
clientId: session.streamId,
|
|
889
|
-
streamId: session.streamId,
|
|
890
|
-
cols: dims.cols,
|
|
891
|
-
rows: dims.rows,
|
|
892
|
-
});
|
|
893
788
|
}
|
|
894
789
|
|
|
895
790
|
handleClientConnection(ws, req) {
|
|
@@ -915,12 +810,9 @@ class RelayServer {
|
|
|
915
810
|
const host = this.hosts.get(device.hostId);
|
|
916
811
|
if (!host || host.reconnecting || !isOpen(host.ws)) return this.rejectHandshake(ws, 'paired Herdr host is offline', host?.reconnecting ? 'host_reconnecting' : 'host_offline');
|
|
917
812
|
|
|
918
|
-
// Two tabs of one browser are
|
|
919
|
-
// rivals. Nothing is retired here:
|
|
920
|
-
//
|
|
921
|
-
// evict each other in a loop that never converged — each eviction
|
|
922
|
-
// triggered the other tab's auto-reconnect, which evicted this one
|
|
923
|
-
// back, forever.
|
|
813
|
+
// Two tabs of one browser are separate windows with their own PTY sessions,
|
|
814
|
+
// not rivals. Nothing is retired here: each connection gets its own stream
|
|
815
|
+
// without evicting existing clients.
|
|
924
816
|
if (host.clients.size >= this.config.relay.maxClientsPerHost) return this.rejectHandshake(ws, 'host client limit reached', 'too_many_clients');
|
|
925
817
|
clearTimeout(deadline);
|
|
926
818
|
this.finishHandshake(ws);
|
|
@@ -934,9 +826,10 @@ class RelayServer {
|
|
|
934
826
|
// same browser rather than a genuinely separate viewer.
|
|
935
827
|
browserClientId: typeof message.clientId === 'string' ? message.clientId.slice(0, 128) : null,
|
|
936
828
|
handoffCapable: Array.isArray(message.capabilities) && message.capabilities.includes('host_handoff'),
|
|
937
|
-
|
|
938
|
-
//
|
|
939
|
-
//
|
|
829
|
+
session: null,
|
|
830
|
+
// Every paired window gets its own interactive PTY session. Pairing
|
|
831
|
+
// is the permission boundary; once a device is through it, every
|
|
832
|
+
// window can type into its own terminal.
|
|
940
833
|
role: 'controller',
|
|
941
834
|
controllerId: null,
|
|
942
835
|
connectedAt: new Date().toISOString(),
|
|
@@ -985,7 +878,7 @@ class RelayServer {
|
|
|
985
878
|
terminalPalette: host.terminalPalette || null,
|
|
986
879
|
clientCount: host.clients.size,
|
|
987
880
|
});
|
|
988
|
-
this.
|
|
881
|
+
this.startSession(host, client);
|
|
989
882
|
this.broadcastControlState(host);
|
|
990
883
|
return;
|
|
991
884
|
}
|
|
@@ -1004,11 +897,10 @@ class RelayServer {
|
|
|
1004
897
|
if (!host) return this.detachClient(client, { notify: true, reason: 'host_offline' });
|
|
1005
898
|
if (isBinary) {
|
|
1006
899
|
if (raw.length > this.config.relay.maxPayloadBytes) return;
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
//
|
|
1010
|
-
|
|
1011
|
-
const frame = packStreamFrame('input', session.streamId, raw);
|
|
900
|
+
if (!client.session) return;
|
|
901
|
+
// Each window owns its own PTY session, so input is stamped with the
|
|
902
|
+
// client's dedicated stream id.
|
|
903
|
+
const frame = packStreamFrame('input', client.session.streamId, raw);
|
|
1012
904
|
if (isOpen(host.ws)) {
|
|
1013
905
|
try {
|
|
1014
906
|
host.ws.send(frame);
|
|
@@ -1029,11 +921,23 @@ class RelayServer {
|
|
|
1029
921
|
client.lastPingAt = new Date().toISOString();
|
|
1030
922
|
jsonSend(client.ws, { type: 'pong' });
|
|
1031
923
|
} else if (message.type === 'resize') {
|
|
1032
|
-
//
|
|
1033
|
-
//
|
|
924
|
+
// Each client owns its own PTY session, so resizing directly forwards
|
|
925
|
+
// the new geometry for this client's stream to the host.
|
|
1034
926
|
client.cols = clampDimension(message.cols, client.cols);
|
|
1035
927
|
client.rows = clampDimension(message.rows, client.rows);
|
|
1036
|
-
|
|
928
|
+
if (client.session) {
|
|
929
|
+
client.session.cols = client.cols;
|
|
930
|
+
client.session.rows = client.rows;
|
|
931
|
+
if (isOpen(host.ws)) {
|
|
932
|
+
jsonSend(host.ws, {
|
|
933
|
+
type: 'resize',
|
|
934
|
+
clientId: client.session.streamId,
|
|
935
|
+
streamId: client.session.streamId,
|
|
936
|
+
cols: Math.max(MIN_SESSION_COLS, client.cols),
|
|
937
|
+
rows: Math.max(MIN_SESSION_ROWS, client.rows),
|
|
938
|
+
});
|
|
939
|
+
}
|
|
940
|
+
}
|
|
1037
941
|
} else if (message.type === 'claim_control') {
|
|
1038
942
|
// Control is no longer a lease. Answering the old request keeps clients
|
|
1039
943
|
// built against the previous protocol working.
|
|
@@ -1089,26 +993,22 @@ class RelayServer {
|
|
|
1089
993
|
if (!client || !this.clients.has(client.id)) return;
|
|
1090
994
|
this.clients.delete(client.id);
|
|
1091
995
|
const host = this.hosts.get(client.hostId);
|
|
996
|
+
// Each window has its own PTY session. Tearing down the client immediately
|
|
997
|
+
// stops its backing session on the host and cleans up its stream mapping.
|
|
998
|
+
if (client.session) {
|
|
999
|
+
const streamId = client.session.streamId;
|
|
1000
|
+
this.streams.delete(streamId);
|
|
1001
|
+
if (host && isOpen(host.ws)) {
|
|
1002
|
+
jsonSend(host.ws, { type: 'session_stop', clientId: streamId, streamId });
|
|
1003
|
+
}
|
|
1004
|
+
client.session = null;
|
|
1005
|
+
}
|
|
1092
1006
|
if (host) {
|
|
1093
1007
|
host.clients.delete(client.id);
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
// other tabs are still watching.
|
|
1008
|
+
this.notifyHostClientCount(host);
|
|
1009
|
+
this.broadcastControlState(host);
|
|
1097
1010
|
if (host.clients.size === 0) {
|
|
1098
|
-
if (host.session) {
|
|
1099
|
-
if (isOpen(host.ws)) {
|
|
1100
|
-
jsonSend(host.ws, { type: 'session_stop', clientId: host.session.streamId, streamId: host.session.streamId });
|
|
1101
|
-
}
|
|
1102
|
-
host.session = null;
|
|
1103
|
-
}
|
|
1104
1011
|
host.controllerId = null;
|
|
1105
|
-
} else {
|
|
1106
|
-
this.notifyHostClientCount(host);
|
|
1107
|
-
this.syncDimensions(host);
|
|
1108
|
-
this.broadcastControlState(host);
|
|
1109
|
-
}
|
|
1110
|
-
if (host.clients.size === 0) {
|
|
1111
|
-
this.notifyHostClientCount(host);
|
|
1112
1012
|
if (host.reconnecting) this.detachHost(host, { notify: false, reason: 'no_clients' });
|
|
1113
1013
|
}
|
|
1114
1014
|
}
|
|
@@ -1126,6 +1026,10 @@ class RelayServer {
|
|
|
1126
1026
|
const client = this.clients.get(clientId);
|
|
1127
1027
|
if (!client) continue;
|
|
1128
1028
|
this.clients.delete(client.id);
|
|
1029
|
+
if (client.session) {
|
|
1030
|
+
this.streams.delete(client.session.streamId);
|
|
1031
|
+
client.session = null;
|
|
1032
|
+
}
|
|
1129
1033
|
if (notify) jsonSend(client.ws, { type: 'error', code: reason, message: 'Herdr host disconnected' });
|
|
1130
1034
|
closeSocket(client.ws, 1012, reason);
|
|
1131
1035
|
}
|
|
@@ -1203,6 +1107,15 @@ class RelayServer {
|
|
|
1203
1107
|
bytesSent: client.bytesSent,
|
|
1204
1108
|
ip: client.ip,
|
|
1205
1109
|
}));
|
|
1110
|
+
// The roster is read once and used twice: the operator response carries it
|
|
1111
|
+
// whole, and every response carries the per-host tally derived from it. A
|
|
1112
|
+
// public caller learns how many devices a workstation has paired, never
|
|
1113
|
+
// which ones.
|
|
1114
|
+
const pairedDevices = this.auth.listDevices();
|
|
1115
|
+
const pairedPerHost = new Map();
|
|
1116
|
+
for (const device of pairedDevices) {
|
|
1117
|
+
pairedPerHost.set(device.hostId, (pairedPerHost.get(device.hostId) || 0) + 1);
|
|
1118
|
+
}
|
|
1206
1119
|
const hosts = scopedHosts.map((host) => ({
|
|
1207
1120
|
id: host.id,
|
|
1208
1121
|
hostname: host.hostname,
|
|
@@ -1211,6 +1124,12 @@ class RelayServer {
|
|
|
1211
1124
|
status: host.reconnecting ? 'reconnecting' : host.clients.size ? 'busy' : 'online',
|
|
1212
1125
|
connectedAt: host.connectedAt,
|
|
1213
1126
|
activePtyCount: host.ptys.length,
|
|
1127
|
+
// Distinct devices attached to this workstation, not open sockets: a
|
|
1128
|
+
// phone with two tabs open is one device on the operator's board.
|
|
1129
|
+
connectedDeviceCount: countActiveUsers(
|
|
1130
|
+
[...host.clients].map((id) => this.clients.get(id)).filter(Boolean)
|
|
1131
|
+
),
|
|
1132
|
+
pairedDeviceCount: pairedPerHost.get(host.id) || 0,
|
|
1214
1133
|
load: host.load,
|
|
1215
1134
|
}));
|
|
1216
1135
|
// The workstation counts one PTY per stream and cannot know how many
|
|
@@ -1225,9 +1144,19 @@ class RelayServer {
|
|
|
1225
1144
|
// The paired-device roster identifies people's hardware, so it is served to
|
|
1226
1145
|
// the relay operator only — never on /api/status, which any paired device
|
|
1227
1146
|
// may read.
|
|
1228
|
-
const devices = includeDevices ?
|
|
1147
|
+
const devices = includeDevices ? pairedDevices : undefined;
|
|
1229
1148
|
return {
|
|
1230
|
-
...this.metrics.snapshot({
|
|
1149
|
+
...this.metrics.snapshot({
|
|
1150
|
+
clients,
|
|
1151
|
+
hosts,
|
|
1152
|
+
ptys,
|
|
1153
|
+
sample,
|
|
1154
|
+
scopeHostId,
|
|
1155
|
+
// Counted from the unredacted records: the mapped rows above only carry
|
|
1156
|
+
// `deviceId` for the operator, and a public caller must still see the
|
|
1157
|
+
// same number of users the operator does.
|
|
1158
|
+
activeUserCount: countActiveUsers(scopedClients),
|
|
1159
|
+
}),
|
|
1231
1160
|
...(devices ? { devices } : {}),
|
|
1232
1161
|
relayMode: this.relayMode,
|
|
1233
1162
|
isRemoteRelay: this.relayMode === 'remote',
|