@cplieger/web-terminal-engine 1.2.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 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`, `getScrollbackRowCount`, `updateReverseVideo`.
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`, `scrollToBottom`, `suppressScroll`, `isUserScrolledUp`, `isInUserScroll`.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cplieger/web-terminal-engine",
3
- "version": "1.2.0",
3
+ "version": "1.3.2",
4
4
  "exports": {
5
5
  ".": "./src/index.ts"
6
6
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cplieger/web-terminal-engine",
3
- "version": "1.2.0",
3
+ "version": "1.3.2",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "typecheck": "tsgo -p tsconfig.json",
@@ -10,10 +10,10 @@
10
10
  "devDependencies": {
11
11
  "@eslint/js": "10.0.1",
12
12
  "@vitest/coverage-v8": "4.1.9",
13
- "eslint": "10.5.0",
13
+ "eslint": "10.6.0",
14
14
  "fast-check": "4.8.0",
15
15
  "happy-dom": "20.10.6",
16
- "prettier": "3.8.4",
16
+ "prettier": "3.8.5",
17
17
  "typescript": "6.0.3",
18
18
  "typescript-eslint": "8.62.0",
19
19
  "vitest": "4.1.9"
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/keyboard.ts CHANGED
@@ -470,7 +470,7 @@ export interface MobileToolbarController {
470
470
  * would garble it.
471
471
  */
472
472
  applyStickyCtrl(text: string): string;
473
- /** Programmatically arm/disarm Ctrl. Updates the toolbar button visuals + fires `onCtrlChange`. */
473
+ /** Programmatically arm/disarm Ctrl. Updates the toolbar button visuals (`.armed` class + `aria-pressed`) + fires `onCtrlChange`. */
474
474
  setCtrlArmed(on: boolean): void;
475
475
  /** Whether sticky-Ctrl is currently armed. */
476
476
  isCtrlArmed(): boolean;
@@ -513,20 +513,28 @@ export function bindMobileToolbar(opts: BindMobileToolbarOptions): MobileToolbar
513
513
  const enterBtn = findBtn(ids.enter);
514
514
  const escBtn = findBtn(ids.esc);
515
515
 
516
+ function paintCtrlBtn(on: boolean): void {
517
+ if (!ctrlBtn) {
518
+ return;
519
+ }
520
+ ctrlBtn.classList.toggle("armed", on);
521
+ // Keep the ARIA toggle state in sync with the visual `.armed` class so
522
+ // assistive tech announces the sticky-Ctrl button as pressed/unpressed.
523
+ // The scaffold ships kb-ctrl as `aria-pressed="false"`; consumers used to
524
+ // own this in their own setCtrlArmed before delegating the toolbar here.
525
+ ctrlBtn.setAttribute("aria-pressed", on ? "true" : "false");
526
+ }
527
+
516
528
  function setCtrlArmed(on: boolean): void {
517
529
  if (armed === on) {
518
530
  // Still update visuals defensively (e.g. after dispose was called
519
531
  // and someone re-armed via setCtrlArmed) — but skip the change
520
532
  // notification to keep onCtrlChange edge-triggered.
521
- if (ctrlBtn) {
522
- ctrlBtn.classList.toggle("armed", on);
523
- }
533
+ paintCtrlBtn(on);
524
534
  return;
525
535
  }
526
536
  armed = on;
527
- if (ctrlBtn) {
528
- ctrlBtn.classList.toggle("armed", on);
529
- }
537
+ paintCtrlBtn(on);
530
538
  opts.onCtrlChange?.(on);
531
539
  }
532
540
 
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,
@@ -178,6 +181,12 @@ export function init(opts: {
178
181
  rowEls.clear();
179
182
  renderQueue.clear();
180
183
  trimMarkerEl = null;
184
+ // Drop the predicted-cursor overlay (re-created lazily against the new
185
+ // termWrap on the next setPredictedCursor call) so re-init starts clean.
186
+ if (predCursorEl) {
187
+ predCursorEl.remove();
188
+ predCursorEl = null;
189
+ }
181
190
  output.replaceChildren();
182
191
  cursorAbs = -1;
183
192
  if (pendingFrame !== undefined) {
@@ -457,7 +466,7 @@ function buildRowSpans(runs: WireRun[], cursorAt: number): (HTMLSpanElement | HT
457
466
  }
458
467
  if (out.length === 0) {
459
468
  const span = document.createElement("span");
460
- span.innerHTML = " ";
469
+ span.textContent = "\u00a0";
461
470
  out.push(span);
462
471
  }
463
472
  return linkifySpans(out);
@@ -555,15 +564,28 @@ function flushRenderInner(): void {
555
564
  renderQueue.add(abs);
556
565
  }
557
566
 
558
- // The cursor rows are built every frame regardless of the budget so the
559
- // inline cursor span is always current (it moves off the old row and onto
560
- // the new one); a huge backlog must never make the caret lag.
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.
561
573
  if (prevCursorAbs !== newCursorAbs && prevCursorAbs >= 0) {
562
574
  upsertRow(prevCursorAbs);
563
575
  renderQueue.delete(prevCursorAbs);
564
576
  }
565
- upsertRow(newCursorAbs);
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
+ }
566
585
  renderQueue.delete(newCursorAbs);
586
+ prevCursorCol = cursorCol;
587
+ prevCursorHidden = cursorHidden;
588
+ prevCursorStyleVal = cursorStyleVal;
567
589
 
568
590
  // Drain up to MAX_ROWS_PER_FRAME queued rows this frame; the rest carry over
569
591
  // to the next frame (scheduled below) so one big burst never blocks paint.
@@ -785,16 +807,28 @@ export function getCursorPx(): { left: number; top: number; cellH: number } {
785
807
 
786
808
  let predCursorEl: HTMLElement | null = null;
787
809
 
810
+ // Create the predicted-cursor overlay the renderer owns. Appended to termWrap
811
+ // (the positioned scroll container) so its absolute left/top math matches the
812
+ // row offsets. Styled by the `.pred-cursor` class from the UI CSS bundle. The
813
+ // renderer owning this means the engine never depends on a host-provided
814
+ // `#pred-cursor` scaffold element.
815
+ function createPredCursorEl(): HTMLElement {
816
+ const el = document.createElement("div");
817
+ el.className = "pred-cursor";
818
+ el.setAttribute("aria-hidden", "true");
819
+ termWrap.appendChild(el);
820
+ return el;
821
+ }
822
+
788
823
  /**
789
824
  * Show or hide a "predicted" cursor overlay at window-relative (row, col).
790
825
  * Useful for client-side echo of typed characters before the server
791
- * acknowledges them, over high-latency connections.
826
+ * acknowledges them, over high-latency connections. The overlay element is
827
+ * created lazily on first use (a consumer that never predicts never creates
828
+ * it).
792
829
  */
793
830
  export function setPredictedCursor(row: number, col: number, active: boolean): void {
794
- const el = predCursorEl ?? (predCursorEl = document.getElementById("pred-cursor"));
795
- if (!el) {
796
- return;
797
- }
831
+ const el = predCursorEl ?? (predCursorEl = createPredCursorEl());
798
832
  const win = store.getWindow();
799
833
  const predAbs = win.base + row;
800
834
  if (!active || (predAbs === cursorAbs && col === cursorCol)) {
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
- export const MAX_LINES = 5000;
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 past the evicted line and any hole.
309
- let next = victim + 1;
310
- while (next <= this.highest && !this.lines.has(next)) {
311
- next++;
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;
@@ -60,10 +60,16 @@ class Cursor {
60
60
  return v;
61
61
  }
62
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.
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);