herdr-remote-relay 0.2.12 → 0.2.14

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "herdr-remote-relay",
3
- "version": "0.2.12",
3
+ "version": "0.2.14",
4
4
  "description": "Standalone relay server and web terminal for Herdr Remote",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -10,7 +10,13 @@ const { WebSocketServer, WebSocket } = require('ws');
10
10
  const { loadRelayConfig, defaultStateDir, PACKAGE_ROOT } = require('./relay-config');
11
11
  const { AuthStore } = require('./auth-store');
12
12
  const { RelayMetrics, countActiveUsers } = require('./metrics');
13
- const { unpackStreamFrame, packStreamFrame, sanitizeTerminalPalette } = require('./stream-frame');
13
+ const {
14
+ unpackStreamFrame,
15
+ packStreamFrame,
16
+ packStreamFrameV2,
17
+ FRAME_TYPE_INPUT,
18
+ sanitizeTerminalPalette,
19
+ } = require('./stream-frame');
14
20
  const { ensureDir } = require('./state');
15
21
 
16
22
  const VERSION = require('../package.json').version;
@@ -149,6 +155,15 @@ class RelayServer {
149
155
  this.password = options.password ?? config.auth?.password ?? null;
150
156
  this.adminToken = options.adminToken ?? config.auth?.adminToken ?? null;
151
157
  this.trustProxy = Boolean(options.trustProxy ?? config.relay.trustProxy);
158
+ // Development-only artificial latency switch for local responsiveness profiling.
159
+ // When unset or 0, this incurs zero overhead and avoids entering the delayed path.
160
+ // RELAY_DEV_LATENCY_MS specifies round-trip delay, so each one-way leg
161
+ // (host -> browser and browser -> host) is delayed by half.
162
+ const devLatencyRaw = options.devLatencyMs ?? config.relay?.devLatencyMs ?? process.env.RELAY_DEV_LATENCY_MS;
163
+ this.devLatencyMs = devLatencyRaw ? Math.max(0, parseInt(devLatencyRaw, 10) || 0) : 0;
164
+ this.devDelayMs = this.devLatencyMs > 0 ? Math.round(this.devLatencyMs / 2) : 0;
165
+ this.sendQueues = new WeakMap();
166
+ this.activeDelayTimers = new Set();
152
167
  this.auth = options.auth || new AuthStore({
153
168
  stateFile: this.stateFile,
154
169
  pairingTtlMs: config.auth.pairingTtlMs,
@@ -161,9 +176,18 @@ class RelayServer {
161
176
  noServer: true,
162
177
  clientTracking: false,
163
178
  maxPayload: config.relay.maxPayloadBytes,
164
- // Terminal data is already compact and latency-sensitive. Compression
165
- // adds CPU and buffering without helping the usual ANSI payloads.
166
- perMessageDeflate: false,
179
+ // Frames below 1024 bytes bypass compression completely, ensuring single
180
+ // keystrokes and small echoes incur zero CPU and zero buffering delay.
181
+ // Keeping context across messages (NoContextTakeover=false) maximizes
182
+ // compression ratios on highly repetitive full-screen ratatui/ANSI redraws;
183
+ // level 3 provides low CPU cost and low latency over peak compression.
184
+ perMessageDeflate: {
185
+ threshold: 1024,
186
+ zlibDeflateOptions: { level: 3 },
187
+ serverNoContextTakeover: false,
188
+ clientNoContextTakeover: false,
189
+ concurrencyLimit: 10,
190
+ },
167
191
  });
168
192
  this.heartbeatTimer = null;
169
193
  this.cleanupTimer = null;
@@ -200,6 +224,10 @@ class RelayServer {
200
224
  if (this.cleanupTimer) clearInterval(this.cleanupTimer);
201
225
  this.heartbeatTimer = null;
202
226
  this.cleanupTimer = null;
227
+ if (this.activeDelayTimers) {
228
+ for (const timer of this.activeDelayTimers) clearTimeout(timer);
229
+ this.activeDelayTimers.clear();
230
+ }
203
231
  for (const client of [...this.clients.values()]) this.detachClient(client, { notify: false });
204
232
  for (const host of [...this.hosts.values()]) this.detachHost(host, { notify: false });
205
233
  this.streams.clear();
@@ -569,10 +597,26 @@ class RelayServer {
569
597
  reconnectTimer: null,
570
598
  connectionGeneration: randomId('host-connection'),
571
599
  handoffCapable: capabilities.includes('host_handoff'),
600
+ binaryFrameV2: capabilities.includes('binary_frame_v2'),
601
+ streamIndices: new Map(),
602
+ nextStreamIndex: 0,
572
603
  shutdownRequested: false,
573
604
  };
574
605
  }
575
606
 
607
+ allocateStreamIndex(host) {
608
+ if (!host) return null;
609
+ const totalPossible = 65536;
610
+ for (let i = 0; i < totalPossible; i++) {
611
+ const candidate = host.nextStreamIndex;
612
+ host.nextStreamIndex = (host.nextStreamIndex + 1) & 0xffff;
613
+ if (!host.streamIndices.has(candidate)) {
614
+ return candidate;
615
+ }
616
+ }
617
+ return null;
618
+ }
619
+
576
620
  canHandoffHost(host) {
577
621
  if (!host?.handoffCapable || host.clients.size === 0) return false;
578
622
  for (const clientId of host.clients) {
@@ -597,6 +641,9 @@ class RelayServer {
597
641
  for (const clientId of host.clients) {
598
642
  const client = this.clients.get(clientId);
599
643
  if (client?.session) {
644
+ if (client.session.streamIndex !== null && client.session.streamIndex !== undefined) {
645
+ host.streamIndices.delete(client.session.streamIndex);
646
+ }
600
647
  this.streams.delete(client.session.streamId);
601
648
  client.session = null;
602
649
  }
@@ -653,6 +700,9 @@ class RelayServer {
653
700
  streamId: client.session.streamId,
654
701
  });
655
702
  }
703
+ if (client.session.streamIndex !== null && client.session.streamIndex !== undefined) {
704
+ oldHost.streamIndices.delete(client.session.streamIndex);
705
+ }
656
706
  this.streams.delete(client.session.streamId);
657
707
  client.session = null;
658
708
  }
@@ -720,9 +770,16 @@ class RelayServer {
720
770
  closeSocket(host.ws, 1003, error.message);
721
771
  return;
722
772
  }
773
+ // Postel's law: be conservative in what you send, liberal in what you accept.
774
+ // We strictly send v2 only to hosts that negotiated binary_frame_v2, but we accept
775
+ // both v1 and v2 on receipt: a v2-capable host falls back to v1 if stream indices
776
+ // were exhausted for a session, and an unnegotiated host sending v2 simply finds
777
+ // no routed client instead of having its entire connection severed.
723
778
  // Output is routed to the single client owning the stream rather than broadcast.
724
779
  if (frame.type !== 'output') return;
725
- const clientId = this.streams.get(frame.streamId);
780
+ const clientId = frame.version === 2
781
+ ? host.streamIndices.get(frame.streamIndex)
782
+ : this.streams.get(frame.streamId);
726
783
  if (!clientId) return;
727
784
  const client = this.clients.get(clientId);
728
785
  if (!client || !isOpen(client.ws)) return;
@@ -754,6 +811,9 @@ class RelayServer {
754
811
  const code = Number.isInteger(message.code) ? message.code : null;
755
812
  jsonSend(client.ws, { type: 'exit', code });
756
813
  if (client.session) {
814
+ if (client.session.streamIndex !== null && client.session.streamIndex !== undefined) {
815
+ host.streamIndices.delete(client.session.streamIndex);
816
+ }
757
817
  this.streams.delete(client.session.streamId);
758
818
  client.session = null;
759
819
  }
@@ -791,14 +851,72 @@ class RelayServer {
791
851
  this.detachClient(client, { notify: true, reason: 'slow_client', closeCode: 1013 });
792
852
  return false;
793
853
  }
794
- try {
795
- client.ws.send(payload);
796
- client.bytesSent += payload.length;
797
- this.metrics.recordOut(payload.length, host.id);
798
- return true;
799
- } catch {
800
- this.detachClient(client, { notify: false, reason: 'client_send_failed', closeCode: 1011 });
801
- return false;
854
+ let sent = false;
855
+ const doSend = () => {
856
+ if (!isOpen(client.ws)) return;
857
+ try {
858
+ client.ws.send(payload);
859
+ client.bytesSent += payload.length;
860
+ this.metrics.recordOut(payload.length, host.id);
861
+ sent = true;
862
+ } catch {
863
+ this.detachClient(client, { notify: false, reason: 'client_send_failed', closeCode: 1011 });
864
+ }
865
+ };
866
+ if (this.devLatencyMs > 0) this.enqueueDelayedSend(client.ws, doSend);
867
+ else doSend();
868
+ return this.devLatencyMs > 0 ? true : sent;
869
+ }
870
+
871
+ /**
872
+ * Queue artificial delay per-socket rather than using naked setTimeout calls.
873
+ * Concurrent timers experience event loop jitter that can deliver frames out of order;
874
+ * a single FIFO queue per socket guarantees strict in-order delivery of terminal frames.
875
+ */
876
+ enqueueDelayedSend(socket, task) {
877
+ let queue = this.sendQueues.get(socket);
878
+ if (!queue) {
879
+ queue = { items: [], timer: null };
880
+ this.sendQueues.set(socket, queue);
881
+ socket.once('close', () => {
882
+ if (queue.timer) {
883
+ clearTimeout(queue.timer);
884
+ this.activeDelayTimers.delete(queue.timer);
885
+ queue.timer = null;
886
+ }
887
+ queue.items = [];
888
+ });
889
+ }
890
+ const sendAt = Date.now() + this.devDelayMs;
891
+ queue.items.push({ sendAt, task });
892
+ if (!queue.timer) {
893
+ const timer = setTimeout(() => this.flushSendQueue(socket, queue), this.devDelayMs);
894
+ queue.timer = timer;
895
+ this.activeDelayTimers.add(timer);
896
+ }
897
+ }
898
+
899
+ /**
900
+ * Drain ready frames in FIFO order up to the current timestamp, then schedule the
901
+ * single next timer if items remain. Ensures only one timer runs per socket at a time.
902
+ */
903
+ flushSendQueue(socket, queue) {
904
+ if (queue.timer) {
905
+ this.activeDelayTimers.delete(queue.timer);
906
+ queue.timer = null;
907
+ }
908
+ const now = Date.now();
909
+ while (queue.items.length > 0 && queue.items[0].sendAt <= now) {
910
+ const item = queue.items.shift();
911
+ try {
912
+ item.task();
913
+ } catch {}
914
+ }
915
+ if (queue.items.length > 0 && !queue.timer) {
916
+ const nextDelay = Math.max(0, queue.items[0].sendAt - Date.now());
917
+ const timer = setTimeout(() => this.flushSendQueue(socket, queue), nextDelay);
918
+ queue.timer = timer;
919
+ this.activeDelayTimers.add(timer);
802
920
  }
803
921
  }
804
922
 
@@ -816,8 +934,16 @@ class RelayServer {
816
934
  startSession(host, client, { restarted = false } = {}) {
817
935
  if (!client || !isOpen(host.ws)) return;
818
936
  const streamId = randomId('session');
937
+ let streamIndex = null;
938
+ if (host.binaryFrameV2) {
939
+ streamIndex = this.allocateStreamIndex(host);
940
+ if (streamIndex !== null) {
941
+ host.streamIndices.set(streamIndex, client.id);
942
+ }
943
+ }
819
944
  client.session = {
820
945
  streamId,
946
+ streamIndex,
821
947
  cols: client.cols,
822
948
  rows: client.rows,
823
949
  ready: false,
@@ -830,6 +956,7 @@ class RelayServer {
830
956
  cols: Math.max(MIN_SESSION_COLS, client.cols),
831
957
  rows: Math.max(MIN_SESSION_ROWS, client.rows),
832
958
  role: 'controller',
959
+ ...(streamIndex !== null ? { streamIndex } : {}),
833
960
  });
834
961
  if (restarted) {
835
962
  jsonSend(client.ws, {
@@ -955,16 +1082,23 @@ class RelayServer {
955
1082
  if (raw.length > this.config.relay.maxPayloadBytes) return;
956
1083
  if (!client.session) return;
957
1084
  // Each window owns its own PTY session, so input is stamped with the
958
- // client's dedicated stream id.
959
- const frame = packStreamFrame('input', client.session.streamId, raw);
1085
+ // client's dedicated stream id or streamIndex.
1086
+ const frame = host.binaryFrameV2 && typeof client.session.streamIndex === 'number'
1087
+ ? packStreamFrameV2(FRAME_TYPE_INPUT, client.session.streamIndex, raw)
1088
+ : packStreamFrame('input', client.session.streamId, raw);
960
1089
  if (isOpen(host.ws)) {
961
- try {
962
- host.ws.send(frame);
963
- client.bytesReceived += raw.length;
964
- this.metrics.recordIn(raw.length, host.id);
965
- } catch {
966
- this.beginHostReconnect(host, 'host_send_failed');
967
- }
1090
+ const doSend = () => {
1091
+ if (!isOpen(host.ws)) return;
1092
+ try {
1093
+ host.ws.send(frame);
1094
+ client.bytesReceived += raw.length;
1095
+ this.metrics.recordIn(raw.length, host.id);
1096
+ } catch {
1097
+ this.beginHostReconnect(host, 'host_send_failed');
1098
+ }
1099
+ };
1100
+ if (this.devLatencyMs > 0) this.enqueueDelayedSend(host.ws, doSend);
1101
+ else doSend();
968
1102
  }
969
1103
  return;
970
1104
  }
@@ -1129,6 +1263,9 @@ class RelayServer {
1129
1263
  // stops its backing session on the host and cleans up its stream mapping.
1130
1264
  if (client.session) {
1131
1265
  const streamId = client.session.streamId;
1266
+ if (client.session.streamIndex !== null && client.session.streamIndex !== undefined && host) {
1267
+ host.streamIndices.delete(client.session.streamIndex);
1268
+ }
1132
1269
  this.streams.delete(streamId);
1133
1270
  if (host && isOpen(host.ws)) {
1134
1271
  jsonSend(host.ws, { type: 'session_stop', clientId: streamId, streamId });
@@ -1159,6 +1296,9 @@ class RelayServer {
1159
1296
  if (!client) continue;
1160
1297
  this.clients.delete(client.id);
1161
1298
  if (client.session) {
1299
+ if (client.session.streamIndex !== null && client.session.streamIndex !== undefined) {
1300
+ host.streamIndices.delete(client.session.streamIndex);
1301
+ }
1162
1302
  this.streams.delete(client.session.streamId);
1163
1303
  client.session = null;
1164
1304
  }
@@ -8,6 +8,15 @@
8
8
  const PROTOCOL_VERSION = 1;
9
9
  const MAX_HEADER_BYTES = 8 * 1024;
10
10
 
11
+ // Framing constants for compact binary frame protocol v2.
12
+ // With permessage-deflate already enabled on the WebSocket connection, the repeated
13
+ // JSON routing header in v1 frames was already compressed down to near-zero network overhead.
14
+ // The actual motivation for v2 framing is reducing CPU consumption: eliminating the per-frame
15
+ // JSON.stringify / JSON.parse serialization and string decoding overhead under heavy terminal output.
16
+ const FRAME_V2_MAGIC = 0xff;
17
+ const FRAME_TYPE_OUTPUT = 0;
18
+ const FRAME_TYPE_INPUT = 1;
19
+
11
20
  /**
12
21
  * The sixteen ANSI slots a host may report, in index order. A palette is
13
22
  * all-or-nothing: half the host's colors mixed with half the browser's would
@@ -73,7 +82,7 @@ function packStreamFrame(type, streamId, payload = Buffer.alloc(0)) {
73
82
  return Buffer.concat([length, header, body]);
74
83
  }
75
84
 
76
- function unpackStreamFrame(value) {
85
+ function unpackStreamFrameV1(value) {
77
86
  const frame = Buffer.isBuffer(value) ? value : Buffer.from(value);
78
87
  if (frame.length < 4) throw new Error('stream frame is truncated');
79
88
  const headerLength = frame.readUInt32BE(0);
@@ -90,17 +99,74 @@ function unpackStreamFrame(value) {
90
99
  throw new Error('stream frame header is missing routing fields');
91
100
  }
92
101
  return {
102
+ version: 1,
93
103
  type: header.type,
94
104
  streamId: header.streamId,
95
105
  payload: frame.subarray(4 + headerLength),
96
106
  };
97
107
  }
98
108
 
109
+ function packStreamFrameV2(type, streamIndex, payload = Buffer.alloc(0)) {
110
+ let typeCode = type;
111
+ if (type === 'output') typeCode = FRAME_TYPE_OUTPUT;
112
+ else if (type === 'input') typeCode = FRAME_TYPE_INPUT;
113
+ if (typeCode !== FRAME_TYPE_OUTPUT && typeCode !== FRAME_TYPE_INPUT) {
114
+ throw new TypeError('type must be FRAME_TYPE_OUTPUT (0) or FRAME_TYPE_INPUT (1)');
115
+ }
116
+ if (!Number.isInteger(streamIndex) || streamIndex < 0 || streamIndex > 0xffff) {
117
+ throw new RangeError('streamIndex must be an unsigned 16-bit integer (0-65535)');
118
+ }
119
+ const body = Buffer.isBuffer(payload) ? payload : Buffer.from(payload);
120
+ const frame = Buffer.allocUnsafe(4 + body.length);
121
+ frame.writeUInt8(FRAME_V2_MAGIC, 0);
122
+ frame.writeUInt8(typeCode, 1);
123
+ frame.writeUInt16BE(streamIndex, 2);
124
+ body.copy(frame, 4);
125
+ return frame;
126
+ }
127
+
128
+ function unpackStreamFrameV2(buffer) {
129
+ const frame = Buffer.isBuffer(buffer) ? buffer : Buffer.from(buffer);
130
+ if (frame.length < 4) throw new Error('v2 stream frame is truncated');
131
+ const magic = frame.readUInt8(0);
132
+ if (magic !== FRAME_V2_MAGIC) {
133
+ throw new Error('invalid v2 stream frame magic byte');
134
+ }
135
+ const typeCode = frame.readUInt8(1);
136
+ if (typeCode !== FRAME_TYPE_OUTPUT && typeCode !== FRAME_TYPE_INPUT) {
137
+ throw new Error(`unknown v2 stream frame type: ${typeCode}`);
138
+ }
139
+ const streamIndex = frame.readUInt16BE(2);
140
+ return {
141
+ version: 2,
142
+ type: typeCode === FRAME_TYPE_OUTPUT ? 'output' : 'input',
143
+ typeCode,
144
+ streamIndex,
145
+ payload: frame.subarray(4),
146
+ };
147
+ }
148
+
149
+ function unpackStreamFrame(value) {
150
+ const frame = Buffer.isBuffer(value) ? value : Buffer.from(value);
151
+ if (frame.length < 4) throw new Error('stream frame is truncated');
152
+ // v1's headerLength is a 32-bit big-endian integer bounded by MAX_HEADER_BYTES (8KB),
153
+ // so its first byte is always 0x00. A first byte of 0xFF unambiguously identifies v2 framing.
154
+ if (frame[0] === FRAME_V2_MAGIC) {
155
+ return unpackStreamFrameV2(frame);
156
+ }
157
+ return unpackStreamFrameV1(frame);
158
+ }
159
+
99
160
  module.exports = {
100
161
  PROTOCOL_VERSION,
101
162
  MAX_HEADER_BYTES,
163
+ FRAME_V2_MAGIC,
164
+ FRAME_TYPE_OUTPUT,
165
+ FRAME_TYPE_INPUT,
102
166
  ANSI_PALETTE_KEYS,
103
167
  packStreamFrame,
104
168
  unpackStreamFrame,
169
+ packStreamFrameV2,
170
+ unpackStreamFrameV2,
105
171
  sanitizeTerminalPalette,
106
172
  };