@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.
- package/LICENSE +692 -0
- package/README.md +97 -0
- package/jsr.json +18 -0
- package/package.json +42 -0
- package/src/connection.ts +584 -0
- package/src/index.ts +10 -0
- package/src/keyboard.ts +630 -0
- package/src/modes.ts +95 -0
- package/src/mouse.ts +203 -0
- package/src/reconnect.ts +64 -0
- package/src/render.ts +823 -0
- package/src/scroll.ts +103 -0
- package/src/store.ts +389 -0
- package/src/types.ts +165 -0
- package/src/wire-binary.ts +238 -0
- package/src/wire.ts +36 -0
- package/src/wsurl.ts +19 -0
package/src/modes.ts
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// Current DEC private mode state, as last announced by the server via
|
|
2
|
+
// a wireMsgModes frame. Used by:
|
|
3
|
+
// - keyboard.ts arrow-key encoder: emits SS3 (ESC O letter) when
|
|
4
|
+
// applicationCursor is true, CSI (ESC [ letter) otherwise.
|
|
5
|
+
// - paste handling: wraps in \e[200~..\e[201~ only when
|
|
6
|
+
// bracketedPaste is on; otherwise sends raw text.
|
|
7
|
+
// - mouse.ts: encodes mouse events only when mouseMode > 0.
|
|
8
|
+
// - focus reporting: sends ESC[I / ESC[O when focusReporting is on.
|
|
9
|
+
//
|
|
10
|
+
// Defaults: bracketedPaste starts true because most modern shells
|
|
11
|
+
// (bash 4.4+, zsh, fish) enable it immediately on startup via
|
|
12
|
+
// CSI ?2004h. Starting false would cause the first paste before the
|
|
13
|
+
// server's modes frame arrives to be sent un-bracketed, which shells
|
|
14
|
+
// interpret as typed input (potentially executing pasted commands
|
|
15
|
+
// character-by-character). applicationCursor starts false (normal
|
|
16
|
+
// mode) because that's the VT100 power-on default.
|
|
17
|
+
|
|
18
|
+
let bracketedPaste = true;
|
|
19
|
+
let applicationCursor = false;
|
|
20
|
+
let applicationKeypad = false;
|
|
21
|
+
let mouseMode = 0; // 0=off, 1000=normal, 1002=button-event, 1003=any-event
|
|
22
|
+
let mouseSGR = false;
|
|
23
|
+
let focusReporting = false;
|
|
24
|
+
let reverseVideo = false;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Update the cached mode state. Called by the consumer whenever the server
|
|
28
|
+
* sends a `ModesMessage`. Pass-through arguments are optional so older
|
|
29
|
+
* server builds that don't include all fields don't reset them to defaults.
|
|
30
|
+
*/
|
|
31
|
+
export function setModes(
|
|
32
|
+
bracketed: boolean,
|
|
33
|
+
appCursor: boolean,
|
|
34
|
+
mSGR?: boolean,
|
|
35
|
+
focus?: boolean,
|
|
36
|
+
mMode?: number,
|
|
37
|
+
appKeypad?: boolean,
|
|
38
|
+
revVideo?: boolean,
|
|
39
|
+
): void {
|
|
40
|
+
bracketedPaste = bracketed;
|
|
41
|
+
applicationCursor = appCursor;
|
|
42
|
+
if (mSGR !== undefined) {
|
|
43
|
+
mouseSGR = mSGR;
|
|
44
|
+
}
|
|
45
|
+
if (focus !== undefined) {
|
|
46
|
+
focusReporting = focus;
|
|
47
|
+
}
|
|
48
|
+
if (mMode !== undefined) {
|
|
49
|
+
mouseMode = mMode;
|
|
50
|
+
}
|
|
51
|
+
if (appKeypad !== undefined) {
|
|
52
|
+
applicationKeypad = appKeypad;
|
|
53
|
+
}
|
|
54
|
+
if (revVideo !== undefined) {
|
|
55
|
+
reverseVideo = revVideo;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** True when the server has DEC 2004 (bracketed paste) enabled. */
|
|
60
|
+
export function isBracketedPaste(): boolean {
|
|
61
|
+
return bracketedPaste;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** True when the server has DECCKM (application cursor keys) enabled. */
|
|
65
|
+
export function isApplicationCursor(): boolean {
|
|
66
|
+
return applicationCursor;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Active mouse tracking mode (xterm DECSET): 0 = off, 1000 = normal,
|
|
71
|
+
* 1002 = button-event, 1003 = any-event.
|
|
72
|
+
*/
|
|
73
|
+
export function getMouseMode(): number {
|
|
74
|
+
return mouseMode;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** True when the server has DEC 1006 (SGR mouse encoding) enabled. */
|
|
78
|
+
export function isMouseSGR(): boolean {
|
|
79
|
+
return mouseSGR;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** True when the server has DEC 1004 (focus event reporting) enabled. */
|
|
83
|
+
export function isFocusReporting(): boolean {
|
|
84
|
+
return focusReporting;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** True when the server has DECKPAM (application keypad) enabled. */
|
|
88
|
+
export function isApplicationKeypad(): boolean {
|
|
89
|
+
return applicationKeypad;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** True when the server has DEC 5 (reverse video / DECSCNM) enabled. */
|
|
93
|
+
export function isReverseVideo(): boolean {
|
|
94
|
+
return reverseVideo;
|
|
95
|
+
}
|
package/src/mouse.ts
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
// Mouse event encoding for terminal input (SGR 1006 protocol).
|
|
2
|
+
//
|
|
3
|
+
// Encodes mouse events as SGR sequences: ESC[<code;col;rowM (press/move)
|
|
4
|
+
// or ESC[<code;col;rowm (release). Coordinates are 1-based.
|
|
5
|
+
//
|
|
6
|
+
// Button encoding (matches xterm/xterm.js):
|
|
7
|
+
// 0=left, 1=middle, 2=right, 64=wheel-up, 65=wheel-down
|
|
8
|
+
// +4=shift, +8=alt, +16=ctrl, +32=motion
|
|
9
|
+
//
|
|
10
|
+
// Focus events: ESC[I (focus in), ESC[O (focus out).
|
|
11
|
+
//
|
|
12
|
+
// Out-of-scope (consumed but not implemented):
|
|
13
|
+
// - X10 mouse mode (mode 9): not supported.
|
|
14
|
+
// - urxvt encoding (mode 1015): not supported.
|
|
15
|
+
// - DEFAULT encoding (raw bytes): not supported; only SGR 1006.
|
|
16
|
+
|
|
17
|
+
import { getMouseMode, isMouseSGR, isFocusReporting } from "./modes.js";
|
|
18
|
+
|
|
19
|
+
const ESC = "\x1b";
|
|
20
|
+
|
|
21
|
+
/** Encode a mouse button code for SGR protocol. */
|
|
22
|
+
function buttonCode(
|
|
23
|
+
button: number,
|
|
24
|
+
motion: boolean,
|
|
25
|
+
shift: boolean,
|
|
26
|
+
alt: boolean,
|
|
27
|
+
ctrl: boolean,
|
|
28
|
+
): number {
|
|
29
|
+
let code = button;
|
|
30
|
+
if (shift) {
|
|
31
|
+
code |= 4;
|
|
32
|
+
}
|
|
33
|
+
if (alt) {
|
|
34
|
+
code |= 8;
|
|
35
|
+
}
|
|
36
|
+
if (ctrl) {
|
|
37
|
+
code |= 16;
|
|
38
|
+
}
|
|
39
|
+
if (motion) {
|
|
40
|
+
code |= 32;
|
|
41
|
+
}
|
|
42
|
+
return code;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Encode a mouse event into an SGR 1006 escape sequence ready to send to the
|
|
47
|
+
* terminal as input. The returned string starts with ESC[< and ends with `M`
|
|
48
|
+
* for press/move or `m` for release.
|
|
49
|
+
*
|
|
50
|
+
* @param code Button code returned from `buttonCode` (button + modifier bits).
|
|
51
|
+
* @param col 1-based column.
|
|
52
|
+
* @param row 1-based row.
|
|
53
|
+
* @param release `true` for release events (terminator `m`), `false` otherwise.
|
|
54
|
+
*/
|
|
55
|
+
export function encodeSGR(code: number, col: number, row: number, release: boolean): string {
|
|
56
|
+
const final = release ? "m" : "M";
|
|
57
|
+
return `${ESC}[<${code};${col};${row}${final}`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Map DOM button index to xterm button number. */
|
|
61
|
+
function domButtonToXterm(button: number): number {
|
|
62
|
+
// DOM: 0=left, 1=middle, 2=right
|
|
63
|
+
// xterm: 0=left, 1=middle, 2=right
|
|
64
|
+
if (button <= 2) {
|
|
65
|
+
return button;
|
|
66
|
+
}
|
|
67
|
+
return 0; // fallback
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Adapter the consumer wires into `init` so the mouse module can: send encoded
|
|
72
|
+
* input bytes to the server, query current cell pixel size for hit-testing,
|
|
73
|
+
* and access the terminal DOM element to attach listeners to.
|
|
74
|
+
*/
|
|
75
|
+
export interface MouseInputHandler {
|
|
76
|
+
/** Sends raw input bytes (or escape sequences) to the server. */
|
|
77
|
+
send: (data: string) => void;
|
|
78
|
+
/** Returns the current cell pixel size for hit-testing pointer coordinates. */
|
|
79
|
+
cellSize: () => { width: number; height: number };
|
|
80
|
+
/** Returns the terminal DOM element to attach mouse listeners to. */
|
|
81
|
+
termElement: () => HTMLElement;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
let handler: MouseInputHandler | null = null;
|
|
85
|
+
let lastButton = -1;
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Initialize the mouse module by attaching pointer/wheel/focus listeners to
|
|
89
|
+
* the terminal element. Listeners gate on the active mouse mode + SGR 1006
|
|
90
|
+
* encoding; they are no-ops when the server hasn't enabled mouse tracking.
|
|
91
|
+
*/
|
|
92
|
+
export function init(h: MouseInputHandler): void {
|
|
93
|
+
handler = h;
|
|
94
|
+
const el = h.termElement();
|
|
95
|
+
el.addEventListener("mousedown", onMouseDown);
|
|
96
|
+
el.addEventListener("mouseup", onMouseUp);
|
|
97
|
+
el.addEventListener("mousemove", onMouseMove);
|
|
98
|
+
el.addEventListener("wheel", onWheel, { passive: false });
|
|
99
|
+
el.addEventListener("focusin", onFocusIn);
|
|
100
|
+
el.addEventListener("focusout", onFocusOut);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function pixelToCell(e: MouseEvent): { col: number; row: number } | null {
|
|
104
|
+
if (!handler) {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
const el = handler.termElement();
|
|
108
|
+
const rect = el.getBoundingClientRect();
|
|
109
|
+
const { width, height } = handler.cellSize();
|
|
110
|
+
if (width <= 0 || height <= 0) {
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
const col = Math.floor((e.clientX - rect.left) / width) + 1; // 1-based
|
|
114
|
+
const row = Math.floor((e.clientY - rect.top) / height) + 1; // 1-based
|
|
115
|
+
if (col < 1 || row < 1) {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
return { col, row };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function onMouseDown(e: MouseEvent): void {
|
|
122
|
+
const mode = getMouseMode();
|
|
123
|
+
if (mode === 0 || !isMouseSGR() || !handler) {
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
const pos = pixelToCell(e);
|
|
127
|
+
if (!pos) {
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
lastButton = domButtonToXterm(e.button);
|
|
131
|
+
const code = buttonCode(lastButton, false, e.shiftKey, e.altKey, e.ctrlKey);
|
|
132
|
+
handler.send(encodeSGR(code, pos.col, pos.row, false));
|
|
133
|
+
e.preventDefault();
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function onMouseUp(e: MouseEvent): void {
|
|
137
|
+
const mode = getMouseMode();
|
|
138
|
+
if (mode === 0 || !isMouseSGR() || !handler) {
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
const pos = pixelToCell(e);
|
|
142
|
+
if (!pos) {
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
const btn = domButtonToXterm(e.button);
|
|
146
|
+
const code = buttonCode(btn, false, e.shiftKey, e.altKey, e.ctrlKey);
|
|
147
|
+
handler.send(encodeSGR(code, pos.col, pos.row, true));
|
|
148
|
+
lastButton = -1;
|
|
149
|
+
e.preventDefault();
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function onMouseMove(e: MouseEvent): void {
|
|
153
|
+
const mode = getMouseMode();
|
|
154
|
+
if (mode === 0 || !isMouseSGR() || !handler) {
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
// mode 1000: no motion events
|
|
158
|
+
// mode 1002: motion only while button held (drag)
|
|
159
|
+
// mode 1003: all motion events
|
|
160
|
+
if (mode === 1000) {
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
if (mode === 1002 && !e.buttons) {
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
const pos = pixelToCell(e);
|
|
167
|
+
if (!pos) {
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
const btn = e.buttons ? (e.buttons & 1 ? 0 : e.buttons & 4 ? 1 : e.buttons & 2 ? 2 : 0) : 0;
|
|
171
|
+
const code = buttonCode(btn, true, e.shiftKey, e.altKey, e.ctrlKey);
|
|
172
|
+
handler.send(encodeSGR(code, pos.col, pos.row, false));
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function onWheel(e: WheelEvent): void {
|
|
176
|
+
const mode = getMouseMode();
|
|
177
|
+
if (mode === 0 || !isMouseSGR() || !handler) {
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
const pos = pixelToCell(e);
|
|
181
|
+
if (!pos) {
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
// Wheel up = button 64, wheel down = button 65
|
|
185
|
+
const btn = e.deltaY < 0 ? 64 : 65;
|
|
186
|
+
const code = buttonCode(btn, false, e.shiftKey, e.altKey, e.ctrlKey);
|
|
187
|
+
handler.send(encodeSGR(code, pos.col, pos.row, false));
|
|
188
|
+
e.preventDefault();
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function onFocusIn(): void {
|
|
192
|
+
if (!isFocusReporting() || !handler) {
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
handler.send(`${ESC}[I`);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function onFocusOut(): void {
|
|
199
|
+
if (!isFocusReporting() || !handler) {
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
handler.send(`${ESC}[O`);
|
|
203
|
+
}
|
package/src/reconnect.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// Pure reconnect-delay scheduler for the terminal WebSocket.
|
|
2
|
+
//
|
|
3
|
+
// Reconnect strategy: exponential backoff capped at MAX_DELAY_MS, with
|
|
4
|
+
// uniform random jitter in [0, JITTER_MS) added to each scheduled
|
|
5
|
+
// attempt. The per-attempt jitter prevents thundering-herd reconnects
|
|
6
|
+
// when several browser tabs lose connectivity together; the cap keeps
|
|
7
|
+
// the worst-case wait bounded so a long network outage doesn't push
|
|
8
|
+
// the next retry an hour out.
|
|
9
|
+
//
|
|
10
|
+
// Pulled into a dedicated module so the formula is testable without
|
|
11
|
+
// real timers: callers inject `random()` (typically `Math.random`)
|
|
12
|
+
// and receive both the delay to wait this attempt and the new base
|
|
13
|
+
// delay for the next attempt. This separates the math from the
|
|
14
|
+
// timer-side-effect plumbing in the connection module.
|
|
15
|
+
|
|
16
|
+
/** Initial backoff delay in milliseconds. Used by the first retry after a clean connection. */
|
|
17
|
+
export const INITIAL_DELAY_MS = 500;
|
|
18
|
+
|
|
19
|
+
/** Maximum (capped) base delay in milliseconds. */
|
|
20
|
+
export const MAX_DELAY_MS = 8000;
|
|
21
|
+
|
|
22
|
+
/** Maximum jitter added per attempt (uniform random, exclusive upper bound). */
|
|
23
|
+
export const JITTER_MS = 250;
|
|
24
|
+
|
|
25
|
+
/** Result of computing the next backoff step. */
|
|
26
|
+
interface BackoffStep {
|
|
27
|
+
/** Milliseconds to wait before the next reconnect attempt. */
|
|
28
|
+
scheduledMs: number;
|
|
29
|
+
/** New base delay to feed into the subsequent call. */
|
|
30
|
+
nextBaseMs: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* nextBackoffDelay computes the wait time for this attempt and the
|
|
35
|
+
* new base delay for the next attempt. Pure given a deterministic
|
|
36
|
+
* `random` function; pass `Math.random` for production.
|
|
37
|
+
*
|
|
38
|
+
* Formula:
|
|
39
|
+
* scheduledMs = currentBaseMs + random() * JITTER_MS
|
|
40
|
+
* nextBaseMs = min(currentBaseMs * 2, MAX_DELAY_MS)
|
|
41
|
+
*
|
|
42
|
+
* `random` is expected to return a number in [0, 1). When undefined
|
|
43
|
+
* or out of range, the implementation clamps to that interval so
|
|
44
|
+
* malformed inputs don't yield NaN/negative delays.
|
|
45
|
+
*/
|
|
46
|
+
export function nextBackoffDelay(
|
|
47
|
+
currentBaseMs: number,
|
|
48
|
+
random: () => number = Math.random,
|
|
49
|
+
): BackoffStep {
|
|
50
|
+
const r = clamp01(random());
|
|
51
|
+
const scheduledMs = currentBaseMs + r * JITTER_MS;
|
|
52
|
+
const nextBaseMs = Math.min(currentBaseMs * 2, MAX_DELAY_MS);
|
|
53
|
+
return { scheduledMs, nextBaseMs };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function clamp01(x: number): number {
|
|
57
|
+
if (!Number.isFinite(x) || x < 0) {
|
|
58
|
+
return 0;
|
|
59
|
+
}
|
|
60
|
+
if (x >= 1) {
|
|
61
|
+
return 0.999_999_999;
|
|
62
|
+
}
|
|
63
|
+
return x;
|
|
64
|
+
}
|