@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 +1 -1
- package/package.json +7 -7
- package/src/connection.ts +91 -2
- package/src/index.ts +98 -0
- package/src/render.ts +13 -0
- package/src/status-stream.ts +5 -0
- package/src/store.ts +30 -0
package/jsr.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cplieger/web-terminal-engine",
|
|
3
|
-
"version": "5.
|
|
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.
|
|
21
|
-
"@vitest/browser-playwright": "5.0.
|
|
22
|
-
"@vitest/coverage-v8": "5.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.
|
|
25
|
+
"fast-check": "4.10.1",
|
|
26
26
|
"playwright": "1.63.0",
|
|
27
27
|
"pngjs": "7.0.0",
|
|
28
|
-
"prettier": "3.9.
|
|
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.
|
|
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
|
-
|
|
420
|
-
|
|
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;
|
package/src/status-stream.ts
CHANGED
|
@@ -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)
|