@cplieger/web-terminal-engine 1.2.0

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.
@@ -0,0 +1,238 @@
1
+ // Binary wire decoder. Mirrors wire_binary.go on the server.
2
+ //
3
+ // All multi-byte integers are little-endian. See the Go terminal package's
4
+ // wire_binary.go for the exact frame layout.
5
+
6
+ import type {
7
+ WireRun,
8
+ ScreenMessage,
9
+ ScrollMessage,
10
+ ResumeAckMessage,
11
+ ModesMessage,
12
+ TitleMessage,
13
+ ServerMessage,
14
+ } from "./types.js";
15
+
16
+ // Wire message type tags (mirrored from Go wire_binary.go constants).
17
+ const MSG_SCREEN = 0;
18
+ const MSG_SCROLL = 1;
19
+ const MSG_RESUME_ACK = 2;
20
+ const MSG_MODES = 3;
21
+ const MSG_TITLE = 4;
22
+ const MSG_PONG = 5;
23
+
24
+ /**
25
+ * Wire protocol version. Sent by the client in the `resume` control message so
26
+ * the server can detect a client built against a different protocol revision
27
+ * (e.g. a stale cached bundle after a breaking change) and warn rather than
28
+ * silently mis-decode. Bump on any breaking change to the binary frame layout
29
+ * or control-message shape. Mirrors `wireProtocolVersion` in wire_binary.go.
30
+ */
31
+ export const WIRE_PROTOCOL_VERSION = 2;
32
+ const MODE_FLAG_BRACKETED_PASTE = 1;
33
+ const MODE_FLAG_APP_CURSOR_KEYS = 2;
34
+ const MODE_FLAG_MOUSE_SGR = 4;
35
+ const MODE_FLAG_FOCUS_REPORTING = 8;
36
+ const MODE_FLAG_APP_KEYPAD = 16;
37
+ const MODE_FLAG_REVERSE_VIDEO = 32;
38
+
39
+ class Cursor {
40
+ view: DataView;
41
+ bytes: Uint8Array;
42
+ off = 0;
43
+ constructor(buf: ArrayBuffer) {
44
+ this.view = new DataView(buf);
45
+ this.bytes = new Uint8Array(buf);
46
+ }
47
+ u8(): number {
48
+ const v = this.view.getUint8(this.off);
49
+ this.off += 1;
50
+ return v;
51
+ }
52
+ u16(): number {
53
+ const v = this.view.getUint16(this.off, true);
54
+ this.off += 2;
55
+ return v;
56
+ }
57
+ i32(): number {
58
+ const v = this.view.getInt32(this.off, true);
59
+ this.off += 4;
60
+ return v;
61
+ }
62
+ u64(): number {
63
+ // Number(v) is safe here: all values this protocol encodes in u64
64
+ // fields (bytesReceived, serverEpoch) fit within JavaScript's 53-bit
65
+ // integer precision. If the protocol ever needs true 64-bit values,
66
+ // this must return BigInt instead.
67
+ const v = this.view.getBigUint64(this.off, true);
68
+ this.off += 8;
69
+ return Number(v);
70
+ }
71
+ utf8(len: number): string {
72
+ const slice = this.bytes.subarray(this.off, this.off + len);
73
+ this.off += len;
74
+ return new TextDecoder().decode(slice);
75
+ }
76
+ }
77
+
78
+ function readRowRuns(c: Cursor): WireRun[] {
79
+ const numRuns = c.u16();
80
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- pre-allocated array filled immediately below
81
+ const runs: WireRun[] = new Array(numRuns);
82
+ for (let i = 0; i < numRuns; i++) {
83
+ const tlen = c.u16();
84
+ const t = c.utf8(tlen);
85
+ const f = c.i32();
86
+ const b = c.i32();
87
+ const a = c.u16();
88
+ const uc = c.i32();
89
+ const ulen = c.u16();
90
+ const u = ulen > 0 ? c.utf8(ulen) : undefined;
91
+ runs[i] = u ? { t, f, b, a, uc, u } : { t, f, b, a, uc };
92
+ }
93
+ return runs;
94
+ }
95
+
96
+ /**
97
+ * Decode a binary frame from the server's WebSocket into a `ServerMessage`.
98
+ * Returns `null` for frames smaller than the minimum header (9 bytes) or for
99
+ * truncated/invalid frames; the caller should drop such frames silently.
100
+ *
101
+ * Frame format mirrors the Go `vt` package's wire encoding:
102
+ * - 1 byte type tag
103
+ * - 8 bytes inputAck (u64 little-endian)
104
+ * - type-specific body (rows of styled runs for screen/scroll, etc.)
105
+ *
106
+ * @param buf Raw binary frame received over WebSocket.
107
+ * @returns Decoded message, or `null` if the frame couldn't be parsed.
108
+ */
109
+ export function decodeWireBinary(buf: ArrayBuffer): ServerMessage | null {
110
+ if (buf.byteLength < 9) {
111
+ return null;
112
+ }
113
+ try {
114
+ return decodeWireBinaryInner(buf);
115
+ } catch (err) {
116
+ // Graceful frame drop: a RangeError means the frame was truncated
117
+ // or malformed (e.g. network split mid-frame). Returning null lets
118
+ // the caller skip this frame rather than crashing the decode loop.
119
+ // The next flush will carry a complete snapshot anyway.
120
+ if (err instanceof RangeError) {
121
+ return null;
122
+ }
123
+ throw err;
124
+ }
125
+ }
126
+
127
+ function decodeWireBinaryInner(buf: ArrayBuffer): ServerMessage | null {
128
+ const c = new Cursor(buf);
129
+ const msgType = c.u8();
130
+ const inputAck = c.u64();
131
+
132
+ if (msgType === MSG_RESUME_ACK) {
133
+ // Optional tails, length-gated for back-compat with older servers:
134
+ // >= 17 bytes: + serverEpoch (restart detection)
135
+ // >= 33 bytes: + committed + oldestIndex (resume gap detection)
136
+ let serverEpoch: number | undefined;
137
+ let committed: number | undefined;
138
+ let oldestIndex: number | undefined;
139
+ if (buf.byteLength >= 17) {
140
+ serverEpoch = c.u64();
141
+ }
142
+ if (buf.byteLength >= 33) {
143
+ committed = c.u64();
144
+ oldestIndex = c.u64();
145
+ }
146
+ const msg: ResumeAckMessage = { type: "resumeAck", received: inputAck };
147
+ if (serverEpoch !== undefined) {
148
+ msg.serverEpoch = serverEpoch;
149
+ }
150
+ if (committed !== undefined) {
151
+ msg.committed = committed;
152
+ }
153
+ if (oldestIndex !== undefined) {
154
+ msg.oldestIndex = oldestIndex;
155
+ }
156
+ return msg;
157
+ }
158
+ if (msgType === MSG_SCREEN) {
159
+ // Sparse row array: the frame carries only changed rows (indexed by
160
+ // row_idx). The client allocates a full screenHeight-sized array but
161
+ // only the indices listed in `changed` are populated. Absolute index
162
+ // of a changed row `y` is `base + y`.
163
+ const base = c.u64();
164
+ const cursorRow = c.u16();
165
+ const cursorCol = c.u16();
166
+ const screenHeight = c.u16();
167
+ const numChanged = c.u16();
168
+ const cursorStyle = c.u8();
169
+ const cursorFlags = c.u8();
170
+ const cursorHidden = (cursorFlags & 1) !== 0;
171
+ const bell = (cursorFlags & 2) !== 0;
172
+ const cursorBlink = (cursorFlags & 4) !== 0;
173
+ const altActive = (cursorFlags & 8) !== 0;
174
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- pre-allocated array filled below
175
+ const rows: WireRun[][] = new Array(screenHeight);
176
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- pre-allocated array filled below
177
+ const changed: number[] = new Array(numChanged);
178
+ for (let i = 0; i < numChanged; i++) {
179
+ const idx = c.u16();
180
+ changed[i] = idx;
181
+ rows[idx] = readRowRuns(c);
182
+ }
183
+ const msg: ScreenMessage = {
184
+ type: "screen",
185
+ rows,
186
+ base,
187
+ cursor: [cursorRow, cursorCol],
188
+ changed,
189
+ altActive,
190
+ cursorStyle,
191
+ cursorHidden,
192
+ cursorBlink,
193
+ bell,
194
+ inputAck,
195
+ };
196
+ return msg;
197
+ }
198
+ if (msgType === MSG_SCROLL) {
199
+ const firstIndex = c.u64();
200
+ const numLines = c.u16();
201
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- pre-allocated array filled below
202
+ const lines: WireRun[][] = new Array(numLines);
203
+ for (let i = 0; i < numLines; i++) {
204
+ lines[i] = readRowRuns(c);
205
+ }
206
+ const msg: ScrollMessage = { type: "scroll", firstIndex, lines, inputAck };
207
+ return msg;
208
+ }
209
+ if (msgType === MSG_MODES) {
210
+ const flags = c.u8();
211
+ const mouseMode = c.u16();
212
+ const msg: ModesMessage = {
213
+ type: "modes",
214
+ bracketedPaste: (flags & MODE_FLAG_BRACKETED_PASTE) !== 0,
215
+ applicationCursor: (flags & MODE_FLAG_APP_CURSOR_KEYS) !== 0,
216
+ applicationKeypad: (flags & MODE_FLAG_APP_KEYPAD) !== 0,
217
+ mouseSGR: (flags & MODE_FLAG_MOUSE_SGR) !== 0,
218
+ focusReporting: (flags & MODE_FLAG_FOCUS_REPORTING) !== 0,
219
+ reverseVideo: (flags & MODE_FLAG_REVERSE_VIDEO) !== 0,
220
+ mouseMode,
221
+ inputAck,
222
+ };
223
+ return msg;
224
+ }
225
+ if (msgType === MSG_TITLE) {
226
+ const titleLen = c.u16();
227
+ const title = c.utf8(titleLen);
228
+ const msg: TitleMessage = { type: "title", title, inputAck };
229
+ return msg;
230
+ }
231
+ if (msgType === MSG_PONG) {
232
+ // Liveness pong: no payload to deliver. The message listener already
233
+ // refreshed the activity clock on arrival, so there is nothing to do
234
+ // here — returning null keeps it out of the onMessage path.
235
+ return null;
236
+ }
237
+ return null;
238
+ }
package/src/wire.ts ADDED
@@ -0,0 +1,36 @@
1
+ // Pure wire-format helpers for the client → server WebSocket protocol.
2
+ //
3
+ // Protocol: every WebSocket message the client sends to the server is a
4
+ // binary frame whose first byte tags the message type:
5
+ //
6
+ // 0x00 control message (JSON-encoded {type, ...} payload)
7
+ // any raw terminal input bytes
8
+ //
9
+ // The 0x00 prefix is the only framing convention; raw input flows
10
+ // through the rest of the byte space, because terminal input never
11
+ // starts a write with NUL. See the Go terminal package's frame parser
12
+ // for the receiving end.
13
+ //
14
+ // Pulled into a dedicated module so unit tests can exercise the framing
15
+ // without spinning up a WebSocket. Pure: no DOM, no WebSocket, no
16
+ // module-level state.
17
+
18
+ /** Type tag prefix for control messages. */
19
+ export const CONTROL_FRAME_PREFIX = 0x00;
20
+
21
+ /**
22
+ * controlFrame builds the 0x00-prefixed binary frame containing the
23
+ * JSON encoding of msg. The result is the exact bytes to pass to
24
+ * `WebSocket.send(...)`.
25
+ *
26
+ * Returns a Uint8Array backed by a fresh ArrayBuffer (not a
27
+ * SharedArrayBuffer slice) so it satisfies WebSocket.send's
28
+ * BufferSource parameter.
29
+ */
30
+ export function controlFrame(msg: unknown): Uint8Array<ArrayBuffer> {
31
+ const body = new TextEncoder().encode(JSON.stringify(msg));
32
+ const frame = new Uint8Array(new ArrayBuffer(body.length + 1));
33
+ frame[0] = CONTROL_FRAME_PREFIX;
34
+ frame.set(body, 1);
35
+ return frame;
36
+ }
package/src/wsurl.ts ADDED
@@ -0,0 +1,19 @@
1
+ // Pure URL helper, no DOM / terminal / WebSocket dependencies.
2
+ // Kept in a dedicated module so it can be property-tested without
3
+ // touching `location`. The caller threads the live `location.*` through.
4
+
5
+ /**
6
+ * Build the WebSocket URL for a terminal session from a page
7
+ * protocol/host pair and a path. `https:` selects `wss:`; any other
8
+ * page protocol selects `ws:`.
9
+ *
10
+ * @param pageProtocol `location.protocol` (e.g. `"https:"`).
11
+ * @param pageHost `location.host` (host[:port]).
12
+ * @param path WebSocket endpoint path; defaults to `"/ws"`.
13
+ * @returns A parseable `ws://`/`wss://` URL.
14
+ */
15
+ export function wsURL(pageProtocol: string, pageHost: string, path = "/ws"): string {
16
+ const wsProto = pageProtocol === "https:" ? "wss:" : "ws:";
17
+ const normalizedPath = path.startsWith("/") ? path : `/${path}`;
18
+ return `${wsProto}//${pageHost}${normalizedPath}`;
19
+ }