herdr-remote-relay 0.2.13 → 0.2.15
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/relay-server.js +162 -22
- package/src/stream-frame.js +67 -1
- package/web/dist/assets/index-C-5huwJC.js +43 -0
- package/web/dist/assets/{index-BWkuUhfd.css → index-fcdwS56H.css} +1 -1
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-BXe3zQtq.js +0 -41
package/package.json
CHANGED
package/src/relay-server.js
CHANGED
|
@@ -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 {
|
|
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
|
-
//
|
|
165
|
-
//
|
|
166
|
-
|
|
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 =
|
|
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
|
-
|
|
795
|
-
|
|
796
|
-
client.
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
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 =
|
|
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
|
-
|
|
962
|
-
host.ws
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
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
|
}
|
package/src/stream-frame.js
CHANGED
|
@@ -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
|
|
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
|
};
|