@cplieger/web-terminal-engine 1.3.0 → 1.3.2
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/README.md +2 -2
- package/jsr.json +1 -1
- package/package.json +1 -1
- package/src/connection.ts +8 -0
- package/src/render.ts +21 -5
- package/src/store.ts +19 -6
- package/src/wire-binary.ts +10 -4
package/README.md
CHANGED
|
@@ -77,10 +77,10 @@ document.addEventListener("keydown", (ev) => {
|
|
|
77
77
|
|
|
78
78
|
## API
|
|
79
79
|
|
|
80
|
-
- **`render`** — DOM renderer driven by `ScreenMessage` / `ScrollMessage` frames. `init`, `handleScreen`, `handleScroll`, `updateFontMetrics`, `computeSize`, `getCursorPx`, `setPredictedCursor`, `resetScreen`, `resetScrollback`, `
|
|
80
|
+
- **`render`** — DOM renderer driven by `ScreenMessage` / `ScrollMessage` frames. `init`, `handleScreen`, `handleScroll`, `updateFontMetrics`, `computeSize`, `getCursorPx`, `setPredictedCursor`, `resetScreen`, `resetScrollback`, `getHighestIndex`, `noteResumeBounds`, `updateReverseVideo`.
|
|
81
81
|
- **`keyboard`** — Translates `KeyboardEvent` to terminal byte sequences. `mapKeyboardEvent`, `bracketTextForPaste`, `prepareTextForTerminal`, `ctrlByteFor`. Honors `applicationCursor`, `applicationKeypad`, `bracketedPaste`. For touch / mobile UIs, `bindMobileToolbar({toolbar, send, ids?})` wires `pointerdown` handlers for an on-screen Ctrl/arrows/Tab/Enter/Esc toolbar (with sticky-Ctrl semantics and DECCKM-aware arrows), returning a `MobileToolbarController` exposing `applyStickyCtrl`, `setCtrlArmed`, `isCtrlArmed`, and `dispose`.
|
|
82
82
|
- **`mouse`** — SGR 1006 mouse + focus reporting encoder. `init`, `encodeSGR`, `MouseInputHandler`. Auto-gates on `mouseMode > 0`.
|
|
83
|
-
- **`scroll`** — Auto-follow tracker for the scroll container. `init`, `
|
|
83
|
+
- **`scroll`** — Auto-follow tracker for the scroll container. `init`, `stickToBottom`, `scrollToBottom`, `isUserScrolledUp`.
|
|
84
84
|
- **`modes`** — DEC private mode state (synced from server's `ModesMessage`). `setModes`, `isBracketedPaste`, `isApplicationCursor`, `getMouseMode`, `isMouseSGR`, `isFocusReporting`, `isApplicationKeypad`, `isReverseVideo`.
|
|
85
85
|
- **`decodeWireBinary(buf)`** — Top-level decoder for the binary WebSocket frames. Returns a `ServerMessage` or `null` for invalid/truncated frames.
|
|
86
86
|
- **`connection`** — Client → server WebSocket lifecycle: owns the socket, exponential-backoff reconnect, and the resume/inputAck reliability layer (outbox + server-restart detection). `init(callbacks)`, `connect`, `sendBinary(bytes)`, `sendResize`, `reconnectNow`. The callbacks expose `onMessage(ServerMessage)`, `onOpen`/`onClose`/`onConnecting`/`onOutboxFull`/`onServerRestart`, a `computeSize()` provider, and an optional `wsPath` (defaults to `"/ws"`). It decodes frames internally and applies `modes.setModes` for you, so a consumer only needs to dispatch screen/scroll to `render`. Prefer this over wiring `WebSocket` + `decodeWireBinary` by hand unless you need full control.
|
package/jsr.json
CHANGED
package/package.json
CHANGED
package/src/connection.ts
CHANGED
|
@@ -373,6 +373,14 @@ export function connect(): void {
|
|
|
373
373
|
/* ignore */
|
|
374
374
|
}
|
|
375
375
|
}
|
|
376
|
+
// Re-entry while a backoff reconnect is pending (e.g. a consumer calling
|
|
377
|
+
// connect() to restore a panel during the 500ms-8s backoff window): clear the
|
|
378
|
+
// scheduled timer so it cannot fire later and spawn a SECOND socket alongside
|
|
379
|
+
// the one created below. The orphaned timer resets connState to disconnected
|
|
380
|
+
// and calls connect() again, while the existing socket's listeners stay bound
|
|
381
|
+
// (its abort never fired) -> a duplicate server connection + double delivery.
|
|
382
|
+
// cancelScheduledReconnect is a no-op in any non-reconnecting state.
|
|
383
|
+
cancelScheduledReconnect();
|
|
376
384
|
|
|
377
385
|
cb?.onConnecting?.();
|
|
378
386
|
|
package/src/render.ts
CHANGED
|
@@ -137,6 +137,9 @@ let cursorAbs = -1; // absolute index of the row the cursor is on
|
|
|
137
137
|
let cursorCol = 0;
|
|
138
138
|
let cursorHidden = false;
|
|
139
139
|
let cursorStyleVal = 0; // 0-6: DECSCUSR
|
|
140
|
+
let prevCursorCol = -1;
|
|
141
|
+
let prevCursorHidden = false;
|
|
142
|
+
let prevCursorStyleVal = -1;
|
|
140
143
|
|
|
141
144
|
function cursorClassName(): string {
|
|
142
145
|
// DECSCUSR: 0/1=blinking block, 2=steady block, 3=blinking underline,
|
|
@@ -463,7 +466,7 @@ function buildRowSpans(runs: WireRun[], cursorAt: number): (HTMLSpanElement | HT
|
|
|
463
466
|
}
|
|
464
467
|
if (out.length === 0) {
|
|
465
468
|
const span = document.createElement("span");
|
|
466
|
-
span.
|
|
469
|
+
span.textContent = "\u00a0";
|
|
467
470
|
out.push(span);
|
|
468
471
|
}
|
|
469
472
|
return linkifySpans(out);
|
|
@@ -561,15 +564,28 @@ function flushRenderInner(): void {
|
|
|
561
564
|
renderQueue.add(abs);
|
|
562
565
|
}
|
|
563
566
|
|
|
564
|
-
// The cursor rows are built
|
|
565
|
-
//
|
|
566
|
-
// the
|
|
567
|
+
// The cursor rows are built regardless of the budget so the inline cursor span
|
|
568
|
+
// is always current; a huge backlog must never make the caret lag. But skip
|
|
569
|
+
// rebuilding the current cursor row when neither the cursor's visual state nor
|
|
570
|
+
// the row content changed: an unconditional replaceChildren() would discard a
|
|
571
|
+
// text selection on that row, defeating the byte-identical selection-preserving
|
|
572
|
+
// guarantee the store idempotency gives every other row.
|
|
567
573
|
if (prevCursorAbs !== newCursorAbs && prevCursorAbs >= 0) {
|
|
568
574
|
upsertRow(prevCursorAbs);
|
|
569
575
|
renderQueue.delete(prevCursorAbs);
|
|
570
576
|
}
|
|
571
|
-
|
|
577
|
+
const cursorVisualChanged =
|
|
578
|
+
prevCursorAbs !== newCursorAbs ||
|
|
579
|
+
cursorCol !== prevCursorCol ||
|
|
580
|
+
cursorHidden !== prevCursorHidden ||
|
|
581
|
+
cursorStyleVal !== prevCursorStyleVal;
|
|
582
|
+
if (cursorVisualChanged || renderQueue.has(newCursorAbs)) {
|
|
583
|
+
upsertRow(newCursorAbs);
|
|
584
|
+
}
|
|
572
585
|
renderQueue.delete(newCursorAbs);
|
|
586
|
+
prevCursorCol = cursorCol;
|
|
587
|
+
prevCursorHidden = cursorHidden;
|
|
588
|
+
prevCursorStyleVal = cursorStyleVal;
|
|
573
589
|
|
|
574
590
|
// Drain up to MAX_ROWS_PER_FRAME queued rows this frame; the rest carry over
|
|
575
591
|
// to the next frame (scheduled below) so one big burst never blocks paint.
|
package/src/store.ts
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
import type { ScreenMessage, ScrollMessage, WireRun } from "./types.js";
|
|
16
16
|
|
|
17
17
|
/** Maximum lines retained client-side. Older lines are evicted from the top. */
|
|
18
|
-
|
|
18
|
+
const MAX_LINES = 5000;
|
|
19
19
|
|
|
20
20
|
/** The live screen window: a fixed `height`-row block at the tail of the buffer. */
|
|
21
21
|
export interface WindowState {
|
|
@@ -305,12 +305,25 @@ export class LineStore {
|
|
|
305
305
|
this.evicted.add(victim);
|
|
306
306
|
this.dirty.delete(victim);
|
|
307
307
|
}
|
|
308
|
-
// Advance oldest
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
308
|
+
// Advance oldest to the lowest remaining key. After evicting the current
|
|
309
|
+
// oldest, contiguous history (the common case: live scroll-off, resume
|
|
310
|
+
// replay, a cat-bigfile burst) leaves victim + 1 present -- an O(1) probe.
|
|
311
|
+
// Only a hole at the boundary falls back to a full key scan. That scan is
|
|
312
|
+
// bounded by map size (<= maxLines), NOT by the integer gap to the next
|
|
313
|
+
// retained line, so a malformed/compromised frame whose base jumps far
|
|
314
|
+
// ahead of a retained low index cannot make eviction walk billions of
|
|
315
|
+
// indices (an algorithmic-complexity DoS that freezes the tab).
|
|
316
|
+
if (this.lines.has(victim + 1)) {
|
|
317
|
+
this.oldest = victim + 1;
|
|
318
|
+
} else {
|
|
319
|
+
let min = -1;
|
|
320
|
+
for (const k of this.lines.keys()) {
|
|
321
|
+
if (min < 0 || k < min) {
|
|
322
|
+
min = k;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
this.oldest = min;
|
|
312
326
|
}
|
|
313
|
-
this.oldest = next > this.highest && !this.lines.has(this.highest) ? -1 : next;
|
|
314
327
|
if (this.lines.size === 0) {
|
|
315
328
|
this.oldest = -1;
|
|
316
329
|
this.highest = -1;
|
package/src/wire-binary.ts
CHANGED
|
@@ -60,10 +60,16 @@ class Cursor {
|
|
|
60
60
|
return v;
|
|
61
61
|
}
|
|
62
62
|
u64(): number {
|
|
63
|
-
// Number(v)
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
-
//
|
|
63
|
+
// Number(v) loses precision above 2^53. That is safe for every u64 this
|
|
64
|
+
// protocol decodes because of how each value is USED, not because they all fit:
|
|
65
|
+
// - base / firstIndex / committed / oldestIndex / inputAck (bytesReceived)
|
|
66
|
+
// stay well under 2^53 for any realistic session, so arithmetic is exact.
|
|
67
|
+
// - serverEpoch is the server's time.Now().UnixNano() (~1.77e18, far ABOVE
|
|
68
|
+
// 2^53), so it DOES drop low bits here. That is harmless: it is only ever
|
|
69
|
+
// equality-compared to detect a restart, and Number() is deterministic, so
|
|
70
|
+
// one epoch always maps to one double and two distinct boots differ by far
|
|
71
|
+
// more than the ~256ns rounding granularity at that magnitude.
|
|
72
|
+
// If a future u64 field needs exact values above 2^53, return BigInt instead.
|
|
67
73
|
const v = this.view.getBigUint64(this.off, true);
|
|
68
74
|
this.off += 8;
|
|
69
75
|
return Number(v);
|