herdr-remote-relay 0.3.12 → 0.3.13
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/bin/herdr-remote-relay.js +20 -20
- package/dist/auth-store.d.ts +114 -0
- package/dist/auth-store.js +276 -0
- package/dist/metrics.d.ts +76 -0
- package/dist/metrics.js +249 -0
- package/dist/protocol/frames.d.ts +27 -0
- package/dist/protocol/frames.js +110 -0
- package/dist/protocol/http.d.ts +161 -0
- package/dist/protocol/http.js +3 -0
- package/dist/protocol/index.d.ts +5 -0
- package/dist/protocol/index.js +7 -0
- package/dist/protocol/messages.d.ts +321 -0
- package/dist/protocol/messages.js +18 -0
- package/dist/protocol/paste.d.ts +14 -0
- package/dist/protocol/paste.js +50 -0
- package/dist/protocol/terminal.d.ts +79 -0
- package/dist/protocol/terminal.js +150 -0
- package/dist/relay-config.d.ts +74 -0
- package/dist/relay-config.js +316 -0
- package/dist/relay-server.d.ts +64 -0
- package/dist/relay-server.js +194 -0
- package/dist/scroll-input.d.ts +17 -0
- package/dist/scroll-input.js +124 -0
- package/dist/server/client-channel.d.ts +3 -0
- package/dist/server/client-channel.js +365 -0
- package/dist/server/font-proxy.d.ts +19 -0
- package/dist/server/font-proxy.js +155 -0
- package/dist/server/host-broadcasts.d.ts +25 -0
- package/dist/server/host-broadcasts.js +79 -0
- package/dist/server/host-channel.d.ts +3 -0
- package/dist/server/host-channel.js +266 -0
- package/dist/server/http-api.d.ts +3 -0
- package/dist/server/http-api.js +214 -0
- package/dist/server/maintenance.d.ts +5 -0
- package/dist/server/maintenance.js +64 -0
- package/dist/server/requests.d.ts +34 -0
- package/dist/server/requests.js +142 -0
- package/dist/server/sessions.d.ts +36 -0
- package/dist/server/sessions.js +225 -0
- package/dist/server/sockets.d.ts +12 -0
- package/dist/server/sockets.js +64 -0
- package/dist/server/status.d.ts +11 -0
- package/dist/server/status.js +95 -0
- package/dist/server/transport.d.ts +16 -0
- package/dist/server/transport.js +110 -0
- package/dist/server/types.d.ts +123 -0
- package/dist/server/types.js +3 -0
- package/dist/server/version.d.ts +2 -0
- package/dist/server/version.js +5 -0
- package/dist/state.d.ts +6 -0
- package/dist/state.js +47 -0
- package/package.json +32 -8
- package/web/dist/assets/index-B-5zRmL_.js +61 -0
- package/web/dist/assets/{index-CPVUfBt4.css → index-DKt8KLog.css} +1 -1
- package/web/dist/assets/{vendor-xterm-Bbu8R_5E.js → vendor-xterm-BhCxil_4.js} +2 -2
- package/web/dist/index.html +3 -3
- package/src/auth-store.js +0 -256
- package/src/metrics.js +0 -241
- package/src/relay-config.js +0 -312
- package/src/relay-server.js +0 -1799
- package/src/scroll-input.js +0 -139
- package/src/state.js +0 -47
- package/src/stream-frame.js +0 -238
- package/web/dist/assets/index-6WucmdNP.js +0 -61
package/src/scroll-input.js
DELETED
|
@@ -1,139 +0,0 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Wheel-report recognition for read-only clients.
|
|
5
|
-
*
|
|
6
|
-
* A viewer holds no control lease and may not type into the terminal. It may
|
|
7
|
-
* still scroll, for the same reason `resize` does not require the lease: every
|
|
8
|
-
* client drives its *own* PTY stream (`session_start` is emitted per client
|
|
9
|
-
* with `streamId: client.id`), so a wheel report moves only that viewer's own
|
|
10
|
-
* screen and cannot disturb the controller.
|
|
11
|
-
*
|
|
12
|
-
* This module is the trust boundary for that allowance, so the match is exact
|
|
13
|
-
* rather than heuristic. A frame is forwarded only when it consists entirely
|
|
14
|
-
* of wheel reports in the encodings a terminal emits; a single stray byte —
|
|
15
|
-
* a keystroke, a click, a drag — rejects the whole frame.
|
|
16
|
-
*/
|
|
17
|
-
|
|
18
|
-
const ESC = 0x1b;
|
|
19
|
-
const LEFT_BRACKET = 0x5b; // [
|
|
20
|
-
const LESS_THAN = 0x3c; // <
|
|
21
|
-
const SEMICOLON = 0x3b; // ;
|
|
22
|
-
const UPPER_M = 0x4d; // M
|
|
23
|
-
const ZERO = 0x30;
|
|
24
|
-
const NINE = 0x39;
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* The wheel sets bit 64 of the event code; bit 1 is up/down and bits 4/8/16
|
|
28
|
-
* are shift/alt/ctrl. Every other bit belongs to something that is not a
|
|
29
|
-
* wheel: bit 32 marks motion, bits 2 and 128 select other buttons.
|
|
30
|
-
*/
|
|
31
|
-
const WHEEL_BASE = 64;
|
|
32
|
-
const WHEEL_CODE_MASK = ~(1 | 4 | 8 | 16);
|
|
33
|
-
|
|
34
|
-
/** Single-byte (X10) parameters are biased by 32 and cannot exceed one byte. */
|
|
35
|
-
const X10_PARAM_BIAS = 32;
|
|
36
|
-
const X10_PARAM_MAX = 255;
|
|
37
|
-
|
|
38
|
-
function isWheelCode(code) {
|
|
39
|
-
return Number.isInteger(code) && (code & WHEEL_CODE_MASK) === WHEEL_BASE;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
function isDigit(byte) {
|
|
43
|
-
return byte >= ZERO && byte <= NINE;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
* Reads one decimal parameter.
|
|
48
|
-
* @returns {{ value: number, next: number } | null}
|
|
49
|
-
*/
|
|
50
|
-
function readNumber(bytes, start) {
|
|
51
|
-
let index = start;
|
|
52
|
-
let value = 0;
|
|
53
|
-
while (index < bytes.length && isDigit(bytes[index])) {
|
|
54
|
-
value = value * 10 + (bytes[index] - ZERO);
|
|
55
|
-
// A parameter long enough to overflow is not something a terminal emits.
|
|
56
|
-
if (value > 0xffff) return null;
|
|
57
|
-
index += 1;
|
|
58
|
-
}
|
|
59
|
-
if (index === start) return null;
|
|
60
|
-
return { value, next: index };
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
/**
|
|
64
|
-
* Matches `ESC [ < Pb ; Px ; Py M`, the SGR and SGR-pixels encodings.
|
|
65
|
-
* @returns {number} offset after the report, or -1
|
|
66
|
-
*/
|
|
67
|
-
function matchSgrWheel(bytes, start) {
|
|
68
|
-
if (bytes[start] !== ESC || bytes[start + 1] !== LEFT_BRACKET || bytes[start + 2] !== LESS_THAN) {
|
|
69
|
-
return -1;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
const code = readNumber(bytes, start + 3);
|
|
73
|
-
if (!code || !isWheelCode(code.value) || bytes[code.next] !== SEMICOLON) return -1;
|
|
74
|
-
|
|
75
|
-
const first = readNumber(bytes, code.next + 1);
|
|
76
|
-
if (!first || bytes[first.next] !== SEMICOLON) return -1;
|
|
77
|
-
|
|
78
|
-
const second = readNumber(bytes, first.next + 1);
|
|
79
|
-
if (!second) return -1;
|
|
80
|
-
|
|
81
|
-
// A wheel is never released, so the terminator is always `M`. Accepting `m`
|
|
82
|
-
// here would admit button releases, which are not scrolling.
|
|
83
|
-
if (bytes[second.next] !== UPPER_M) return -1;
|
|
84
|
-
return second.next + 1;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
/**
|
|
88
|
-
* Matches `ESC [ M Pb Px Py`, the default single-byte encoding.
|
|
89
|
-
* @returns {number} offset after the report, or -1
|
|
90
|
-
*/
|
|
91
|
-
function matchX10Wheel(bytes, start) {
|
|
92
|
-
if (bytes[start] !== ESC || bytes[start + 1] !== LEFT_BRACKET || bytes[start + 2] !== UPPER_M) {
|
|
93
|
-
return -1;
|
|
94
|
-
}
|
|
95
|
-
if (start + 6 > bytes.length) return -1;
|
|
96
|
-
|
|
97
|
-
const code = bytes[start + 3] - X10_PARAM_BIAS;
|
|
98
|
-
const column = bytes[start + 4];
|
|
99
|
-
const row = bytes[start + 5];
|
|
100
|
-
if (!isWheelCode(code)) return -1;
|
|
101
|
-
if (column < X10_PARAM_BIAS || column > X10_PARAM_MAX) return -1;
|
|
102
|
-
if (row < X10_PARAM_BIAS || row > X10_PARAM_MAX) return -1;
|
|
103
|
-
return start + 6;
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
/**
|
|
107
|
-
* Whether every byte of `payload` belongs to a wheel report.
|
|
108
|
-
*
|
|
109
|
-
* @param {Buffer | Uint8Array | ArrayBuffer} payload raw client input frame
|
|
110
|
-
* @returns {boolean}
|
|
111
|
-
*/
|
|
112
|
-
function isWheelOnlyInput(payload) {
|
|
113
|
-
if (!payload) return false;
|
|
114
|
-
const bytes =
|
|
115
|
-
payload instanceof Uint8Array
|
|
116
|
-
? payload
|
|
117
|
-
: payload instanceof ArrayBuffer
|
|
118
|
-
? new Uint8Array(payload)
|
|
119
|
-
: null;
|
|
120
|
-
if (!bytes || bytes.length === 0) return false;
|
|
121
|
-
|
|
122
|
-
let offset = 0;
|
|
123
|
-
while (offset < bytes.length) {
|
|
124
|
-
const sgr = matchSgrWheel(bytes, offset);
|
|
125
|
-
if (sgr > offset) {
|
|
126
|
-
offset = sgr;
|
|
127
|
-
continue;
|
|
128
|
-
}
|
|
129
|
-
const x10 = matchX10Wheel(bytes, offset);
|
|
130
|
-
if (x10 > offset) {
|
|
131
|
-
offset = x10;
|
|
132
|
-
continue;
|
|
133
|
-
}
|
|
134
|
-
return false;
|
|
135
|
-
}
|
|
136
|
-
return true;
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
module.exports = { isWheelOnlyInput };
|
package/src/state.js
DELETED
|
@@ -1,47 +0,0 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
const crypto = require('node:crypto');
|
|
4
|
-
const fs = require('node:fs');
|
|
5
|
-
const path = require('node:path');
|
|
6
|
-
|
|
7
|
-
function ensureDir(dirPath) {
|
|
8
|
-
fs.mkdirSync(dirPath, { recursive: true, mode: 0o700 });
|
|
9
|
-
try {
|
|
10
|
-
fs.chmodSync(dirPath, 0o700);
|
|
11
|
-
} catch {}
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
function writeJsonAtomic(filePath, value) {
|
|
15
|
-
ensureDir(path.dirname(filePath));
|
|
16
|
-
const tempPath = `${filePath}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`;
|
|
17
|
-
fs.writeFileSync(tempPath, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
|
|
18
|
-
try {
|
|
19
|
-
fs.chmodSync(tempPath, 0o600);
|
|
20
|
-
} catch {}
|
|
21
|
-
fs.renameSync(tempPath, filePath);
|
|
22
|
-
try {
|
|
23
|
-
fs.chmodSync(filePath, 0o600);
|
|
24
|
-
} catch {}
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
function readJson(filePath, fallback) {
|
|
28
|
-
try {
|
|
29
|
-
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
30
|
-
} catch (error) {
|
|
31
|
-
if (error.code !== 'ENOENT') {
|
|
32
|
-
process.stderr.write(`herdr-remote: invalid state at ${filePath}: ${error.message}\n`);
|
|
33
|
-
}
|
|
34
|
-
return fallback;
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
function randomToken(bytes = 32) {
|
|
39
|
-
return crypto.randomBytes(bytes).toString('base64url');
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
module.exports = {
|
|
43
|
-
ensureDir,
|
|
44
|
-
writeJsonAtomic,
|
|
45
|
-
readJson,
|
|
46
|
-
randomToken,
|
|
47
|
-
};
|
package/src/stream-frame.js
DELETED
|
@@ -1,238 +0,0 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
// Wire protocol shared by the relay and the herdr-remote host connector. This
|
|
4
|
-
// module is the single source of truth for both sides: the CLI package imports
|
|
5
|
-
// it as `herdr-remote-relay/protocol` rather than keeping its own copy, so the
|
|
6
|
-
// framing and the version can never drift apart.
|
|
7
|
-
|
|
8
|
-
const PROTOCOL_VERSION = 1;
|
|
9
|
-
const MAX_HEADER_BYTES = 8 * 1024;
|
|
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
|
-
|
|
20
|
-
/**
|
|
21
|
-
* The sixteen ANSI slots a host may report, in index order. A palette is
|
|
22
|
-
* all-or-nothing: half the host's colors mixed with half the browser's would
|
|
23
|
-
* look worse than either set on its own.
|
|
24
|
-
*/
|
|
25
|
-
const ANSI_PALETTE_KEYS = [
|
|
26
|
-
'black',
|
|
27
|
-
'red',
|
|
28
|
-
'green',
|
|
29
|
-
'yellow',
|
|
30
|
-
'blue',
|
|
31
|
-
'magenta',
|
|
32
|
-
'cyan',
|
|
33
|
-
'white',
|
|
34
|
-
'brightBlack',
|
|
35
|
-
'brightRed',
|
|
36
|
-
'brightGreen',
|
|
37
|
-
'brightYellow',
|
|
38
|
-
'brightBlue',
|
|
39
|
-
'brightMagenta',
|
|
40
|
-
'brightCyan',
|
|
41
|
-
'brightWhite',
|
|
42
|
-
];
|
|
43
|
-
|
|
44
|
-
const HEX_COLOR = /^#[0-9a-f]{6}$/i;
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
* Validates a terminal palette crossing the wire.
|
|
48
|
-
*
|
|
49
|
-
* The host reports what its own terminal answered to the OSC color queries,
|
|
50
|
-
* and the browser paints with it. Anything that is not a plain `#rrggbb`
|
|
51
|
-
* string is dropped here, so a compromised or buggy host cannot push arbitrary
|
|
52
|
-
* data into a browser's renderer options.
|
|
53
|
-
*/
|
|
54
|
-
function sanitizeTerminalPalette(value) {
|
|
55
|
-
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
|
56
|
-
const palette = {};
|
|
57
|
-
for (const key of ['background', 'foreground', 'cursor']) {
|
|
58
|
-
const color = value[key];
|
|
59
|
-
if (typeof color === 'string' && HEX_COLOR.test(color)) palette[key] = color.toLowerCase();
|
|
60
|
-
}
|
|
61
|
-
const ansi = value.ansi;
|
|
62
|
-
if (ansi && typeof ansi === 'object' && !Array.isArray(ansi)) {
|
|
63
|
-
const collected = {};
|
|
64
|
-
for (const key of ANSI_PALETTE_KEYS) {
|
|
65
|
-
const color = ansi[key];
|
|
66
|
-
if (typeof color === 'string' && HEX_COLOR.test(color)) collected[key] = color.toLowerCase();
|
|
67
|
-
}
|
|
68
|
-
if (Object.keys(collected).length === ANSI_PALETTE_KEYS.length) palette.ansi = collected;
|
|
69
|
-
}
|
|
70
|
-
return Object.keys(palette).length > 0 ? palette : null;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
/** The faces a host may offer, in the order a browser registers them. */
|
|
74
|
-
const TERMINAL_FONT_STYLES = ['regular', 'bold', 'italic', 'boldItalic'];
|
|
75
|
-
const TERMINAL_FONT_FORMATS = ['truetype', 'opentype'];
|
|
76
|
-
/** `cjk`: the face Hanzi fall back to; `all`: the family itself, too big to send whole. */
|
|
77
|
-
const TERMINAL_FONT_SUBSET_SCOPES = ['cjk', 'all'];
|
|
78
|
-
/** One font file; a CJK face is larger than this and is left to the browser. */
|
|
79
|
-
const MAX_TERMINAL_FONT_BYTES = 16 * 1024 * 1024;
|
|
80
|
-
/** Raw bytes per `host_font_chunk`; base64 keeps it far below any payload cap. */
|
|
81
|
-
const TERMINAL_FONT_CHUNK_BYTES = 256 * 1024;
|
|
82
|
-
/**
|
|
83
|
-
* A family name ends up inside a CSS `font-family` list in the browser. Quotes,
|
|
84
|
-
* separators and escapes are what would let a name break out of its slot, and
|
|
85
|
-
* no real family needs them.
|
|
86
|
-
*/
|
|
87
|
-
const FONT_FAMILY_FORBIDDEN = /["'\\;,{}<>@\u0000-\u001f\u007f]/;
|
|
88
|
-
const SHA256_HEX = /^[0-9a-f]{64}$/;
|
|
89
|
-
|
|
90
|
-
/**
|
|
91
|
-
* Validates the terminal font a host reports.
|
|
92
|
-
*
|
|
93
|
-
* The browser draws the session in the family the workstation's terminal
|
|
94
|
-
* uses, and may fetch the font files by their hash. Every field is optional
|
|
95
|
-
* except the family; anything malformed is dropped rather than repaired.
|
|
96
|
-
*/
|
|
97
|
-
function sanitizeTerminalFont(value) {
|
|
98
|
-
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
|
99
|
-
const family = typeof value.family === 'string' ? value.family.trim() : '';
|
|
100
|
-
if (!family || family.length > 128 || FONT_FAMILY_FORBIDDEN.test(family)) return null;
|
|
101
|
-
|
|
102
|
-
const font = { family };
|
|
103
|
-
const size = Number(value.sizePx);
|
|
104
|
-
if (Number.isFinite(size) && size >= 4 && size <= 96) font.sizePx = Math.round(size * 10) / 10;
|
|
105
|
-
if (typeof value.source === 'string' && /^[a-z0-9-]{1,32}$/.test(value.source)) font.source = value.source;
|
|
106
|
-
|
|
107
|
-
const faces = [];
|
|
108
|
-
const seen = new Set();
|
|
109
|
-
for (const face of Array.isArray(value.faces) ? value.faces.slice(0, 16) : []) {
|
|
110
|
-
if (!face || typeof face !== 'object') continue;
|
|
111
|
-
if (!TERMINAL_FONT_STYLES.includes(face.style) || seen.has(face.style)) continue;
|
|
112
|
-
if (!TERMINAL_FONT_FORMATS.includes(face.format)) continue;
|
|
113
|
-
if (!Number.isInteger(face.bytes) || face.bytes <= 0 || face.bytes > MAX_TERMINAL_FONT_BYTES) continue;
|
|
114
|
-
if (typeof face.sha256 !== 'string' || !SHA256_HEX.test(face.sha256)) continue;
|
|
115
|
-
seen.add(face.style);
|
|
116
|
-
faces.push({ style: face.style, format: face.format, bytes: face.bytes, sha256: face.sha256 });
|
|
117
|
-
}
|
|
118
|
-
font.faces = faces;
|
|
119
|
-
|
|
120
|
-
// Large fonts (CJK above all) are offered a few characters at a time.
|
|
121
|
-
const subsets = [];
|
|
122
|
-
for (const source of Array.isArray(value.subsets) ? value.subsets.slice(0, 16) : []) {
|
|
123
|
-
if (!source || typeof source !== 'object') continue;
|
|
124
|
-
const name = typeof source.family === 'string' ? source.family.trim() : '';
|
|
125
|
-
if (!name || name.length > 128 || FONT_FAMILY_FORBIDDEN.test(name)) continue;
|
|
126
|
-
if (source.style !== 'regular' || !TERMINAL_FONT_SUBSET_SCOPES.includes(source.scope)) continue;
|
|
127
|
-
if (typeof source.sha256 !== 'string' || !SHA256_HEX.test(source.sha256)) continue;
|
|
128
|
-
if (subsets.some((known) => known.scope === source.scope)) continue;
|
|
129
|
-
subsets.push({ family: name, style: 'regular', scope: source.scope, sha256: source.sha256 });
|
|
130
|
-
}
|
|
131
|
-
font.subsets = subsets;
|
|
132
|
-
return font;
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
function packStreamFrame(type, streamId, payload = Buffer.alloc(0)) {
|
|
136
|
-
if (typeof type !== 'string' || !type || typeof streamId !== 'string' || !streamId) {
|
|
137
|
-
throw new TypeError('type and streamId must be non-empty strings');
|
|
138
|
-
}
|
|
139
|
-
const body = Buffer.isBuffer(payload) ? payload : Buffer.from(payload);
|
|
140
|
-
const header = Buffer.from(JSON.stringify({ type, streamId }), 'utf8');
|
|
141
|
-
if (header.length > MAX_HEADER_BYTES) throw new RangeError('stream frame header is too large');
|
|
142
|
-
const length = Buffer.allocUnsafe(4);
|
|
143
|
-
length.writeUInt32BE(header.length, 0);
|
|
144
|
-
return Buffer.concat([length, header, body]);
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
function unpackStreamFrameV1(value) {
|
|
148
|
-
const frame = Buffer.isBuffer(value) ? value : Buffer.from(value);
|
|
149
|
-
if (frame.length < 4) throw new Error('stream frame is truncated');
|
|
150
|
-
const headerLength = frame.readUInt32BE(0);
|
|
151
|
-
if (headerLength === 0 || headerLength > MAX_HEADER_BYTES || frame.length < 4 + headerLength) {
|
|
152
|
-
throw new Error('stream frame header is invalid');
|
|
153
|
-
}
|
|
154
|
-
let header;
|
|
155
|
-
try {
|
|
156
|
-
header = JSON.parse(frame.subarray(4, 4 + headerLength).toString('utf8'));
|
|
157
|
-
} catch (error) {
|
|
158
|
-
throw new Error(`stream frame header is not JSON: ${error.message}`);
|
|
159
|
-
}
|
|
160
|
-
if (!header || typeof header.type !== 'string' || typeof header.streamId !== 'string') {
|
|
161
|
-
throw new Error('stream frame header is missing routing fields');
|
|
162
|
-
}
|
|
163
|
-
return {
|
|
164
|
-
version: 1,
|
|
165
|
-
type: header.type,
|
|
166
|
-
streamId: header.streamId,
|
|
167
|
-
payload: frame.subarray(4 + headerLength),
|
|
168
|
-
};
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
function packStreamFrameV2(type, streamIndex, payload = Buffer.alloc(0)) {
|
|
172
|
-
let typeCode = type;
|
|
173
|
-
if (type === 'output') typeCode = FRAME_TYPE_OUTPUT;
|
|
174
|
-
else if (type === 'input') typeCode = FRAME_TYPE_INPUT;
|
|
175
|
-
if (typeCode !== FRAME_TYPE_OUTPUT && typeCode !== FRAME_TYPE_INPUT) {
|
|
176
|
-
throw new TypeError('type must be FRAME_TYPE_OUTPUT (0) or FRAME_TYPE_INPUT (1)');
|
|
177
|
-
}
|
|
178
|
-
if (!Number.isInteger(streamIndex) || streamIndex < 0 || streamIndex > 0xffff) {
|
|
179
|
-
throw new RangeError('streamIndex must be an unsigned 16-bit integer (0-65535)');
|
|
180
|
-
}
|
|
181
|
-
const body = Buffer.isBuffer(payload) ? payload : Buffer.from(payload);
|
|
182
|
-
const frame = Buffer.allocUnsafe(4 + body.length);
|
|
183
|
-
frame.writeUInt8(FRAME_V2_MAGIC, 0);
|
|
184
|
-
frame.writeUInt8(typeCode, 1);
|
|
185
|
-
frame.writeUInt16BE(streamIndex, 2);
|
|
186
|
-
body.copy(frame, 4);
|
|
187
|
-
return frame;
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
function unpackStreamFrameV2(buffer) {
|
|
191
|
-
const frame = Buffer.isBuffer(buffer) ? buffer : Buffer.from(buffer);
|
|
192
|
-
if (frame.length < 4) throw new Error('v2 stream frame is truncated');
|
|
193
|
-
const magic = frame.readUInt8(0);
|
|
194
|
-
if (magic !== FRAME_V2_MAGIC) {
|
|
195
|
-
throw new Error('invalid v2 stream frame magic byte');
|
|
196
|
-
}
|
|
197
|
-
const typeCode = frame.readUInt8(1);
|
|
198
|
-
if (typeCode !== FRAME_TYPE_OUTPUT && typeCode !== FRAME_TYPE_INPUT) {
|
|
199
|
-
throw new Error(`unknown v2 stream frame type: ${typeCode}`);
|
|
200
|
-
}
|
|
201
|
-
const streamIndex = frame.readUInt16BE(2);
|
|
202
|
-
return {
|
|
203
|
-
version: 2,
|
|
204
|
-
type: typeCode === FRAME_TYPE_OUTPUT ? 'output' : 'input',
|
|
205
|
-
typeCode,
|
|
206
|
-
streamIndex,
|
|
207
|
-
payload: frame.subarray(4),
|
|
208
|
-
};
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
function unpackStreamFrame(value) {
|
|
212
|
-
const frame = Buffer.isBuffer(value) ? value : Buffer.from(value);
|
|
213
|
-
if (frame.length < 4) throw new Error('stream frame is truncated');
|
|
214
|
-
// v1's headerLength is a 32-bit big-endian integer bounded by MAX_HEADER_BYTES (8KB),
|
|
215
|
-
// so its first byte is always 0x00. A first byte of 0xFF unambiguously identifies v2 framing.
|
|
216
|
-
if (frame[0] === FRAME_V2_MAGIC) {
|
|
217
|
-
return unpackStreamFrameV2(frame);
|
|
218
|
-
}
|
|
219
|
-
return unpackStreamFrameV1(frame);
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
module.exports = {
|
|
223
|
-
PROTOCOL_VERSION,
|
|
224
|
-
MAX_HEADER_BYTES,
|
|
225
|
-
FRAME_V2_MAGIC,
|
|
226
|
-
FRAME_TYPE_OUTPUT,
|
|
227
|
-
FRAME_TYPE_INPUT,
|
|
228
|
-
ANSI_PALETTE_KEYS,
|
|
229
|
-
TERMINAL_FONT_STYLES,
|
|
230
|
-
MAX_TERMINAL_FONT_BYTES,
|
|
231
|
-
TERMINAL_FONT_CHUNK_BYTES,
|
|
232
|
-
packStreamFrame,
|
|
233
|
-
unpackStreamFrame,
|
|
234
|
-
packStreamFrameV2,
|
|
235
|
-
unpackStreamFrameV2,
|
|
236
|
-
sanitizeTerminalPalette,
|
|
237
|
-
sanitizeTerminalFont,
|
|
238
|
-
};
|