@cplieger/web-terminal-engine 5.2.0 → 5.3.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/jsr.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cplieger/web-terminal-engine",
3
- "version": "5.2.0",
3
+ "version": "5.3.0",
4
4
  "license": "MPL-2.0",
5
5
  "exports": {
6
6
  ".": "./src/index.ts"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cplieger/web-terminal-engine",
3
- "version": "5.2.0",
3
+ "version": "5.3.0",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "typecheck": "tsc -p tsconfig.json",
@@ -17,18 +17,18 @@
17
17
  "@stryker-mutator/vitest-runner": "10.0.0",
18
18
  "@types/pngjs": "6.0.5",
19
19
  "@typescript/native": "npm:typescript@7.0.2",
20
- "@vitest/browser": "5.0.0",
21
- "@vitest/browser-playwright": "5.0.0",
22
- "@vitest/coverage-v8": "5.0.0",
20
+ "@vitest/browser": "5.0.1",
21
+ "@vitest/browser-playwright": "5.0.1",
22
+ "@vitest/coverage-v8": "5.0.1",
23
23
  "esbuild": "0.28.2",
24
24
  "eslint": "10.10.0",
25
- "fast-check": "4.9.0",
25
+ "fast-check": "4.10.1",
26
26
  "playwright": "1.63.0",
27
27
  "pngjs": "7.0.0",
28
- "prettier": "3.9.6",
28
+ "prettier": "3.9.7",
29
29
  "typescript": "npm:@typescript/typescript6@6.0.2",
30
30
  "typescript-eslint": "8.70.0",
31
- "vitest": "5.0.0"
31
+ "vitest": "5.0.1"
32
32
  },
33
33
  "overrides": {
34
34
  "qs": "^6.15.2"
package/src/connection.ts CHANGED
@@ -416,8 +416,18 @@ function loadOrCreateSessionId(): string {
416
416
  }
417
417
  }
418
418
 
419
- // Exported for unit testing of the RNG fallback. Not part of the
420
- // stable client API surface; callers use loadOrCreateSessionId.
419
+ /**
420
+ * generateSessionId mints a fresh session id: a `crypto.randomUUID` where that
421
+ * exists, otherwise 16 CSPRNG bytes as hex (128 bits, the fallback for a
422
+ * non-secure-context origin). Throws when neither is available rather than
423
+ * falling back to a weaker source — the caller gets no id, not a guessable one.
424
+ *
425
+ * Never returns an id already in use: nothing here reads storage, so a caller
426
+ * that wants the tab's existing session must go through loadOrCreateSessionId.
427
+ *
428
+ * Exported for unit testing of the RNG fallback. Not part of the
429
+ * stable client API surface; callers use loadOrCreateSessionId.
430
+ */
421
431
  export function generateSessionId(): string {
422
432
  if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
423
433
  return crypto.randomUUID();
@@ -438,6 +448,30 @@ export function generateSessionId(): string {
438
448
  throw new Error("vterm: no cryptographically secure RNG available for session id");
439
449
  }
440
450
 
451
+ /**
452
+ * Callbacks is everything this module needs from its consumer, handed to init
453
+ * once. Four members are required because no other layer can answer them: where
454
+ * a decoded frame goes (onMessage), the two socket-lifecycle edges the UI has to
455
+ * reflect (onOpen, onClose), and the terminal's current geometry (computeSize,
456
+ * which must return integer cols/rows > 0 — the server FLOORS an out-of-range
457
+ * resize rather than dropping it).
458
+ *
459
+ * The optional members are two different kinds. The notifications
460
+ * (onConnecting, onOutboxFull, onProcessExit, onServerRestart, the two wire
461
+ * callbacks) each change what the module DOES when wired, and every one says how
462
+ * in its own comment; absence is the documented legacy treatment, never a
463
+ * failure. The resume and history members (getHaveThrough, getReplayMax,
464
+ * onResumeTransition, onResumeBounds, onHistoryReply, noteSolicited,
465
+ * clearSolicited, onHistoryRetry) are engine-owned — supply nothing and the
466
+ * renderer answers them itself (engineDefaults below). getReplayMax is the one
467
+ * override that is expected.
468
+ *
469
+ * Every member is called, never captured: the module reads the current one at
470
+ * each event, so an implementation may depend on state that changes between
471
+ * calls (a bound store, a viewport in flight). A consumer callback that throws
472
+ * propagates out of the module's handler, with the single exception of
473
+ * initialSize, which is isolated (readInitialSize) because it runs mid-open.
474
+ */
441
475
  export interface Callbacks {
442
476
  onMessage(msg: ServerMessage): void;
443
477
  onOpen(): void;
@@ -603,6 +637,17 @@ function engineDefaults(): Partial<Callbacks> {
603
637
  };
604
638
  }
605
639
 
640
+ /**
641
+ * init installs the consumer's callbacks and the WS path. It must run before
642
+ * connect/setSession, and before any sendBinary the consumer expects to reach a
643
+ * server — the module holds one set of callbacks for the whole page, so a second
644
+ * call REPLACES them rather than adding to them.
645
+ *
646
+ * `wsPath` is read here only, and only when present: a later init that omits it
647
+ * leaves the path an earlier one set, so an established socket's endpoint is not
648
+ * silently reinterpreted. Every other member is looked up per event, so a
649
+ * replacement takes effect on the next one without reconnecting.
650
+ */
606
651
  export function init(callbacks: Callbacks): void {
607
652
  // Consumer last: an explicit member wins, an absent one gets the engine's own.
608
653
  // The alternative, leaving them absent, is not neutral — the resume send reads
@@ -788,6 +833,19 @@ function readInitialSize(): { cols: number; rows: number } | null {
788
833
  return { cols: size.cols, rows: size.rows };
789
834
  }
790
835
 
836
+ /**
837
+ * sendResize measures the terminal through Callbacks.computeSize and announces
838
+ * it to the server. Call it whenever the geometry may have changed (a window
839
+ * resize, a font-metric update, the soft keyboard opening); it is cheap to call
840
+ * speculatively because a size equal to the last one SENT is dropped, and the
841
+ * consumer therefore needs no dedup of its own.
842
+ *
843
+ * A no-op when no socket is connected, and the size is not queued for later. A
844
+ * fresh socket clears the dedup baseline as it opens, so the consumer's next
845
+ * call after a reconnect always goes out; a consumer that can measure itself at
846
+ * that moment should wire Callbacks.initialSize instead, which is announced
847
+ * before the resume and therefore brings the snapshot back at this geometry.
848
+ */
791
849
  export function sendResize(): void {
792
850
  if (connState.status !== "connected" || !cb) {
793
851
  return;
@@ -988,6 +1046,21 @@ function teardown(): void {
988
1046
  connState = { status: "disconnected" };
989
1047
  }
990
1048
 
1049
+ /**
1050
+ * reconnectNow replaces whatever socket exists with a fresh one immediately,
1051
+ * skipping the backoff schedule. This is the call for an event that says the
1052
+ * transport may be stale even though it does not look it — a tab becoming
1053
+ * visible, a pageshow, a device waking — because a socket the OS froze during
1054
+ * sleep keeps reading OPEN while frames printed meanwhile never arrive.
1055
+ *
1056
+ * Safe to call in any state and at any rate: the resume protocol aligns by
1057
+ * absolute line index, so reconnecting over a healthy socket costs one handshake
1058
+ * and delivers no duplicate output. Per-session resume state (outbox, byte
1059
+ * counters) survives, so unacked input is retransmitted rather than dropped.
1060
+ *
1061
+ * Refused, silently, once the peer has been ruled wire-incompatible: that
1062
+ * verdict is terminal for the page and only an explicit disconnect clears it.
1063
+ */
991
1064
  export function reconnectNow(): void {
992
1065
  if (connState.status === "incompatible") {
993
1066
  return;
@@ -1320,6 +1393,22 @@ export function disconnect(): void {
1320
1393
  teardown();
1321
1394
  }
1322
1395
 
1396
+ /**
1397
+ * connect opens the WebSocket and starts the module: after init, this is the one
1398
+ * call a consumer needs, and everything that follows (backoff reconnects, the
1399
+ * heartbeat, resume) is driven from here. It returns as soon as the socket is
1400
+ * created — readiness is reported through Callbacks.onOpen, and a connect that
1401
+ * never opens is failed by a 10s timeout that reports onClose and schedules a
1402
+ * retry, so a caller never has to time it out itself.
1403
+ *
1404
+ * At most one socket exists per module, whatever the call pattern: an existing
1405
+ * connecting/connected socket is aborted and closed, and a backoff reconnect
1406
+ * already scheduled is cancelled, so a stray second call cannot leave two
1407
+ * sockets delivering the same session's output twice. Resume state is untouched,
1408
+ * so the new socket picks the session up where the old one left it.
1409
+ *
1410
+ * A no-op once the peer has been ruled wire-incompatible.
1411
+ */
1323
1412
  export function connect(): void {
1324
1413
  if (connState.status === "incompatible") {
1325
1414
  return;
package/src/index.ts CHANGED
@@ -1,13 +1,111 @@
1
1
  export * from "./types.js";
2
2
  export { LineStore } from "./store.js";
3
3
  export type { WindowState, StoreChanges, StoreSnapshot } from "./store.js";
4
+ /**
5
+ * The DOM renderer: it owns a `LineStore` of absolute-index lines and reflects
6
+ * it to one `div.term-row` per line inside a natively-scrolled container, so
7
+ * there is no live-zone/scrollback split to reconcile. Reach for it to paint
8
+ * decoded frames (`handleScreen`, `handleScroll`), to measure the grid
9
+ * (`updateFontMetrics`, `computeSize`, `getCursorPx`), and to swap stores on a
10
+ * tab switch (`bind`).
11
+ *
12
+ * `init` must run once against the two elements before any frame is applied,
13
+ * and it always installs a fresh implicit store — so a consumer that manages
14
+ * its own stores binds AFTER init, never before. `getHighestIndex` reports how
15
+ * far the store's content reaches and is NOT a resume claim; a transport asks
16
+ * `getReplayBoundary` for that, and wiring the former is the defect the latter
17
+ * exists to prevent.
18
+ */
4
19
  export * as render from "./render.js";
5
20
  export type { ViewMemory } from "./render.js";
21
+ /**
22
+ * Key encoding, and nothing else: `mapKeyboardEvent` turns a browser
23
+ * `KeyboardEvent` into either the bytes a PTY expects or a local action, and
24
+ * the paste helpers (`bracketTextForPaste`, `prepareTextForTerminal`) do the
25
+ * same for text. Reach for it when your own input surface — a hidden textarea,
26
+ * an IME composition, a paste handler — has an event and needs the wire form.
27
+ *
28
+ * It touches no DOM and sends nothing: the caller delivers the bytes, and the
29
+ * `scroll-up` / `scroll-down` results carry no bytes at all because local
30
+ * scrollback navigation is the consumer's to perform. Mode state arrives as an
31
+ * explicit `KeyboardModes` argument rather than being read from a global, so a
32
+ * tabbed shell encodes against the active session's modes.
33
+ */
6
34
  export * as keyboard from "./keyboard.js";
35
+ /**
36
+ * The on-screen mobile key toolbar — the one DOM widget in the engine.
37
+ * `bindMobileToolbar` wires an existing container's buttons (Ctrl, arrows, Tab,
38
+ * Enter, Esc, a collapse toggle) to a send sink and returns a controller owning
39
+ * sticky-Ctrl state and tear-down. Reach for it on a touch UI, where the keys a
40
+ * physical keyboard supplies have to be painted.
41
+ *
42
+ * The consumer supplies the markup; the module only looks buttons up by id
43
+ * within the container, so a missing button is skipped rather than fatal.
44
+ * Sticky Ctrl only takes effect on text routed through
45
+ * `controller.applyStickyCtrl` — the toolbar cannot see keystrokes it did not
46
+ * emit. Its arrow and Escape bytes come from `keyboard`'s exported logical-key
47
+ * encodings, so they cannot drift from the physical-key path.
48
+ */
7
49
  export * as toolbar from "./toolbar.js";
50
+ /**
51
+ * The scroll controller: the single owner of the scroll container's
52
+ * `scrollTop`, holding one piece of state — whether the viewport is following
53
+ * the tail or holding a reading position — derived from scroll events alone.
54
+ * Reach for it to `init` the container and for consumer-facing controls: a
55
+ * jump-to-bottom button (`scrollToBottom`), and `isUserScrolledUp` to decide
56
+ * whether new output deserves an affordance.
57
+ *
58
+ * Everything else here is the renderer's to call around its own DOM mutations
59
+ * (`stickToBottom`, `adjustForContentShift`, `noteContentShrink`). A consumer
60
+ * that writes `scrollTop` directly is competing with this module for the same
61
+ * value, which is how a held reading position gets lost.
62
+ */
8
63
  export * as scroll from "./scroll.js";
64
+ /**
65
+ * The DEC private-mode mirror for the ACTIVE session — bracketed paste,
66
+ * application cursor and keypad, mouse tracking and its encoding, focus
67
+ * reporting, reverse video, kitty keyboard flags. Reach for it to read what the
68
+ * application has turned on, most often to decide whether a paste needs
69
+ * bracketing or whether a mouse gesture belongs to the terminal at all.
70
+ *
71
+ * It is a module singleton with one writer: `connection` applies every inbound
72
+ * modes frame and, in a tabbed shell, restores the target session's snapshot
73
+ * inside `setSession`. A consumer that calls `setModes` is racing that writer.
74
+ * Values lag the server by the one frame in flight, and an unseen session
75
+ * starts at `POWER_ON_MODES`, where bracketed paste is already ON so the first
76
+ * paste cannot be delivered as typed input.
77
+ */
9
78
  export * as modes from "./modes.js";
79
+ /**
80
+ * Mouse input for the terminal: SGR 1006 encoding (`encodeSGR`) plus `init`,
81
+ * which attaches pointer and wheel listeners to an element and returns an
82
+ * idempotent disposer. Reach for it when the terminal element should forward
83
+ * clicks, drags and wheel ticks to the application rather than the page.
84
+ *
85
+ * The listeners gate on the mode state, so they are inert until the
86
+ * application enables tracking — which is also why selection and page
87
+ * scrolling keep working in a normal shell. Reports are best-effort: one
88
+ * describes a screen a resume may since have repainted, so a refused report is
89
+ * forgotten rather than retried. Focus is not mouse input and lives in
90
+ * `connection.setClientFocus`.
91
+ */
10
92
  export * as mouse from "./mouse.js";
93
+ /**
94
+ * The client-to-server transport. It owns the socket, the jittered
95
+ * exponential-backoff reconnect, and the resume/inputAck layer that makes input
96
+ * survive a reconnect: a bounded outbox retransmitted against the server's
97
+ * acked byte count, resume by absolute index, and server-restart detection via
98
+ * the boot epoch. Reach for it to `init` the callbacks, `connect`, send input
99
+ * (`sendBinary`), announce geometry (`sendResize`), and switch sessions
100
+ * (`setSession`).
101
+ *
102
+ * It decodes frames and applies mode changes itself, so a consumer's
103
+ * `onMessage` only has to route screen and scroll to the renderer. Supply as
104
+ * few of the resume and history callbacks as possible: an omitted one gets the
105
+ * renderer's own answer, read per resume rather than captured, and overriding
106
+ * one means deciding a local fact on the engine's behalf — `getReplayMax`, how
107
+ * much history the client keeps resident, is the one override that is expected.
108
+ */
11
109
  export * as connection from "./connection.js";
12
110
  export { decodeWireBinary } from "./wire-binary.js";
13
111
  export {
package/src/render.ts CHANGED
@@ -1662,6 +1662,19 @@ function flushRenderInner(): void {
1662
1662
  // scrollTop belongs to `.term`, so any ABSOLUTE pixel value would need the
1663
1663
  // rowTopInTermWrap conversion and would be one padding off without it. A
1664
1664
  // difference cancels the space out.
1665
+
1666
+ /**
1667
+ * A saved reading position, in the only terms that survive the content growing
1668
+ * underneath it: which line the reader had at the top of the viewport, where on
1669
+ * screen that row sat, and whether they were following the tail.
1670
+ *
1671
+ * Effectively opaque to the consumer — read one out of the renderer and hand the
1672
+ * same value back to restore it; the fields are documented because JSR publishes
1673
+ * them, not because a caller should compute one. A memory is only meaningful to
1674
+ * the store it was taken from, since `abs` names a line in THAT index space, and
1675
+ * a restore is re-asserted until the named row has been built rather than
1676
+ * applied once.
1677
+ */
1665
1678
  export interface ViewMemory {
1666
1679
  /** Absolute line index of the content row at the viewport top. */
1667
1680
  abs: number;
@@ -139,6 +139,11 @@ export interface EventSourceLike {
139
139
  /** EventSourceFactory builds an EventSourceLike for a URL. */
140
140
  export type EventSourceFactory = (url: string) => EventSourceLike;
141
141
 
142
+ /** StatusStream is the handle connectStatusStream returns: the consumer's only
143
+ * hold on a live stream, and close is the whole of it — everything else the
144
+ * stream does is reported through the callbacks. Closing is final and there is
145
+ * no reopen; it cancels a scheduled reconnect as well as the current stream, so
146
+ * a consumer that wants status back calls connectStatusStream again. */
142
147
  export interface StatusStream {
143
148
  /** Closes the stream and stops reconnection. */
144
149
  close: () => void;
package/src/store.ts CHANGED
@@ -309,6 +309,36 @@ function rowEqual(a: WireRun[], b: WireRun[]): boolean {
309
309
  return true;
310
310
  }
311
311
 
312
+ /**
313
+ * The client's model of one terminal: lines keyed by absolute index, the live
314
+ * screen window sliding along the tail, and the change set a renderer drains each
315
+ * frame. Holds no DOM and reads no globals, so a consumer may own several (one
316
+ * per tab) and bind whichever is visible.
317
+ *
318
+ * Applying is idempotent by absolute index, which is what makes re-delivery —
319
+ * resume replay, a doubled frame from a zombie socket — incapable of duplicating
320
+ * a row. The apply methods enforce the store's invariants themselves and refuse
321
+ * rather than throw: a frame that violates one is dropped, so a malformed or
322
+ * misordered stream degrades to missing content, never to an exception in the
323
+ * middle of a render. A drop is not reported either, so a caller that needs to
324
+ * know what the store holds asks it (oldestIndex, highestIndex, retainedRanges)
325
+ * instead of tracking what it sent.
326
+ *
327
+ * What the caller must hold up:
328
+ *
329
+ * - Feed the store every frame the server sends, in arrival order, and pass
330
+ * `viewportAbs`/`following` where a method asks for them. The store never
331
+ * infers where the reader is; the renderer is the only layer that knows.
332
+ * - Drain with drainChanges once per frame and apply the result. Nothing is
333
+ * re-reported, so a skipped drain loses the notification, not the content.
334
+ * - Treat every reader (getLine, forEachLine, getAltRows) as a view onto live
335
+ * state: the runs are the store's own, valid until the next apply, and are not
336
+ * to be mutated.
337
+ *
338
+ * Residency is a HISTORY budget floored at the live window, so a small cap keeps
339
+ * the full screen and simply retains no scrollback; the constructor documents the
340
+ * difference between supplying one and letting the engine choose.
341
+ */
312
342
  export class LineStore {
313
343
  private lines = new Map<number, WireRun[]>();
314
344
  private oldest = -1; // lowest retained absolute index (-1 = empty)