@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 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.3.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.3.0",
3
+ "version": "1.3.2",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "typecheck": "tsgo -p tsconfig.json",
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.innerHTML = " ";
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 every frame regardless of the budget so the
565
- // inline cursor span is always current (it moves off the old row and onto
566
- // 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.
567
573
  if (prevCursorAbs !== newCursorAbs && prevCursorAbs >= 0) {
568
574
  upsertRow(prevCursorAbs);
569
575
  renderQueue.delete(prevCursorAbs);
570
576
  }
571
- 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
+ }
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
- 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);