@cplieger/web-terminal-engine 2.7.0 → 3.0.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/README.md +6 -4
- package/jsr.json +1 -1
- package/package.json +2 -1
- package/src/connection.ts +347 -72
- package/src/index.ts +9 -0
- package/src/keyboard.ts +187 -270
- package/src/modes.ts +76 -2
- package/src/mouse.ts +51 -6
- package/src/render.ts +275 -95
- package/src/scroll.ts +25 -0
- package/src/status-stream.ts +13 -5
- package/src/store.ts +11 -3
- package/src/toolbar.ts +270 -0
- package/src/types.ts +49 -2
- package/src/wire-binary.ts +20 -12
- package/src/wire-compatibility.ts +29 -0
- package/src/wire.ts +14 -6
package/src/connection.ts
CHANGED
|
@@ -6,9 +6,12 @@
|
|
|
6
6
|
// backoff, and the resume/inputAck reliability layer.
|
|
7
7
|
//
|
|
8
8
|
// Protocol (resume / inputAck):
|
|
9
|
-
// - Client maintains a
|
|
10
|
-
//
|
|
11
|
-
//
|
|
9
|
+
// - Client maintains a resume key for the page lifetime, an `outbox` of
|
|
10
|
+
// input bytes sent but not yet acknowledged, and a `bytesSent` counter.
|
|
11
|
+
// Unmanaged mode: a per-tab UUID. Managed mode: `<serverSessionId>#<per-
|
|
12
|
+
// client instance id>`, so every device/tab attached to one session owns
|
|
13
|
+
// its OWN server-side input ledger (see resumeKey — shared ledgers acked
|
|
14
|
+
// other devices' bytes to this one and corrupted its outbox).
|
|
12
15
|
// - On WS open, client sends control: {type:"resume", sessionId, sentBytes}.
|
|
13
16
|
// - Server replies with {type:"resumeAck", received:M}; subsequent
|
|
14
17
|
// screen/scroll messages also carry inputAck = bytesReceived. Client
|
|
@@ -24,16 +27,39 @@
|
|
|
24
27
|
import { wsURL } from "./wsurl.js";
|
|
25
28
|
import { WS_PATH } from "./routes.js";
|
|
26
29
|
import { controlFrame } from "./wire.js";
|
|
27
|
-
import { decodeWireBinary
|
|
30
|
+
import { decodeWireBinary } from "./wire-binary.js";
|
|
31
|
+
import {
|
|
32
|
+
MIN_SUPPORTED_SERVER_WIRE_VERSION,
|
|
33
|
+
WIRE_INCOMPATIBLE_CLOSE_CODE,
|
|
34
|
+
WIRE_PROTOCOL_VERSION,
|
|
35
|
+
type WireIncompatibility,
|
|
36
|
+
} from "./wire-compatibility.js";
|
|
28
37
|
import * as modes from "./modes.js";
|
|
29
38
|
import type { ControlMessage, ServerMessage } from "./types.js";
|
|
30
39
|
import { INITIAL_DELAY_MS, nextBackoffDelay } from "./reconnect.js";
|
|
31
40
|
|
|
41
|
+
/**
|
|
42
|
+
* First wire revision with typed client→server framing (text = control,
|
|
43
|
+
* binary = full-alphabet input). A resumeAck whose serverWireVersion is at
|
|
44
|
+
* least this triggers the per-socket upgrade (design §4 phase 3). Mirrors
|
|
45
|
+
* typedFramingMinVersion in the Go terminal package.
|
|
46
|
+
*/
|
|
47
|
+
const TYPED_FRAMING_MIN_VERSION = 4;
|
|
48
|
+
|
|
32
49
|
type ConnState =
|
|
33
50
|
| { status: "disconnected" }
|
|
34
51
|
| { status: "connecting"; sock: WebSocket; abort: AbortController }
|
|
35
|
-
|
|
36
|
-
|
|
52
|
+
/**
|
|
53
|
+
* `upgraded` is the v4 typed-framing latch for THIS socket (per-socket by
|
|
54
|
+
* construction: the state object is replaced on every transition, so it can
|
|
55
|
+
* never leak across a reconnect). false = v3 mode: controls go as
|
|
56
|
+
* 0x00-sentinel binary frames and input is leading-NUL-split. true = the
|
|
57
|
+
* resumeAck proved a v4 server and the text `upgrade` transition was sent:
|
|
58
|
+
* controls go as text frames and input is raw full-alphabet binary.
|
|
59
|
+
*/
|
|
60
|
+
| { status: "connected"; sock: WebSocket; abort: AbortController; upgraded: boolean }
|
|
61
|
+
| { status: "reconnecting"; timer: ReturnType<typeof setTimeout>; delayMs: number }
|
|
62
|
+
| { status: "incompatible" };
|
|
37
63
|
|
|
38
64
|
// The server's application close code for "the session's child process has
|
|
39
65
|
// exited" (terminal/terminal.go statusProcessExited). It marks a close as
|
|
@@ -42,6 +68,15 @@ type ConnState =
|
|
|
42
68
|
// application range (4000-4999) per RFC 6455.
|
|
43
69
|
const PROCESS_EXITED_CLOSE_CODE = 4001;
|
|
44
70
|
|
|
71
|
+
// The server's application close code for "the manager does not know this
|
|
72
|
+
// session id" (terminal/terminal.go statusUnknownSession): reaped, closed
|
|
73
|
+
// elsewhere, or a restarted server. The server ACCEPTS the upgrade and closes
|
|
74
|
+
// with this code precisely so the client can read it (a pre-upgrade 404 is an
|
|
75
|
+
// opaque 1006 in browser JS). Like 4001 it is definitive — the session will
|
|
76
|
+
// never produce output — so it routes to the same no-reconnect ended path
|
|
77
|
+
// instead of an endless "Reconnecting…" flap.
|
|
78
|
+
const SESSION_UNKNOWN_CLOSE_CODE = 4004;
|
|
79
|
+
|
|
45
80
|
let connState: ConnState = { status: "disconnected" };
|
|
46
81
|
let reconnectDelay = INITIAL_DELAY_MS;
|
|
47
82
|
let lastSentCols = 0;
|
|
@@ -57,12 +92,18 @@ let wsPath: string = WS_PATH;
|
|
|
57
92
|
// bytes onto it and without firing a false server-restart reset, because each
|
|
58
93
|
// session's epoch is compared only against its own bootEpoch (design section 8).
|
|
59
94
|
interface ResumeState {
|
|
60
|
-
id: string; // server session id:
|
|
95
|
+
id: string; // server session id: the routing id (?session=); resumeKey derives the ledger key
|
|
61
96
|
bytesSent: number; // total bytes ever passed to sendBinary for this session
|
|
62
97
|
bytesAcked: number; // confirmed by server inputAck/resumeAck
|
|
63
98
|
outbox: Uint8Array[]; // unacked chunks (sum of lengths = bytesSent - bytesAcked)
|
|
64
99
|
outboxBytes: number; // running sum of outbox chunk lengths; keeps applyAck O(n) not O(n²)
|
|
65
100
|
lastServerEpoch: number | null; // process-start nanos last seen for this session
|
|
101
|
+
/** The session's last-announced DEC-mode state (P3: per-session mode
|
|
102
|
+
* mirror). Written on every inbound modes frame; restored synchronously
|
|
103
|
+
* into the modes singleton by setSession, so a keystroke in the switch
|
|
104
|
+
* window encodes under THIS session's modes, never the previous tab's.
|
|
105
|
+
* Power-on defaults until the session announces modes. */
|
|
106
|
+
modes: modes.ModeSnapshot;
|
|
66
107
|
}
|
|
67
108
|
|
|
68
109
|
const sessions = new Map<string, ResumeState>();
|
|
@@ -81,8 +122,44 @@ let managed = false;
|
|
|
81
122
|
|
|
82
123
|
const SESSION_ID_KEY = "vterm-session-id";
|
|
83
124
|
|
|
125
|
+
// --- Per-client-instance resume key (P1: per-sender input-ack scoping) ---
|
|
126
|
+
//
|
|
127
|
+
// In managed mode the routing id (?session=) is SHARED by every device/tab
|
|
128
|
+
// attached to that session, but the server-side input ledger (bytesReceived,
|
|
129
|
+
// keyed by the resume frame's sessionId) must be PER SENDER: with a shared
|
|
130
|
+
// key, device B's input advances the one ledger and the server acks that
|
|
131
|
+
// total to device A, whose applyAck then trims bytes the server never
|
|
132
|
+
// received from A — silent input loss on A's next resume (the cross-device
|
|
133
|
+
// outbox corruption class). So the resume frame carries
|
|
134
|
+
// `<serverSessionId>#<clientInstanceId>` — the URL keeps the bare routing id
|
|
135
|
+
// (the server routes on it), while the registry keys A's and B's ledgers
|
|
136
|
+
// separately. The unmanaged path already has per-sender semantics (its
|
|
137
|
+
// sessionStorage id is per tab) and is unchanged.
|
|
138
|
+
//
|
|
139
|
+
// The instance id is crypto-random and page-lifetime (NOT persisted): a
|
|
140
|
+
// reload is a fresh sender whose ledger starts at zero, which matches its
|
|
141
|
+
// empty outbox. Lazy so a cryptoless environment throws on first CONNECT
|
|
142
|
+
// (as before), not at module import.
|
|
143
|
+
let clientInstanceId: string | null = null;
|
|
144
|
+
|
|
145
|
+
function resumeKey(st: ResumeState): string {
|
|
146
|
+
if (!managed) {
|
|
147
|
+
return st.id;
|
|
148
|
+
}
|
|
149
|
+
clientInstanceId ??= generateSessionId();
|
|
150
|
+
return `${st.id}#${clientInstanceId}`;
|
|
151
|
+
}
|
|
152
|
+
|
|
84
153
|
function newResumeState(id: string): ResumeState {
|
|
85
|
-
return {
|
|
154
|
+
return {
|
|
155
|
+
id,
|
|
156
|
+
bytesSent: 0,
|
|
157
|
+
bytesAcked: 0,
|
|
158
|
+
outbox: [],
|
|
159
|
+
outboxBytes: 0,
|
|
160
|
+
lastServerEpoch: null,
|
|
161
|
+
modes: { ...modes.POWER_ON_MODES },
|
|
162
|
+
};
|
|
86
163
|
}
|
|
87
164
|
|
|
88
165
|
function ensureState(id: string): ResumeState {
|
|
@@ -187,17 +264,19 @@ export interface Callbacks {
|
|
|
187
264
|
onClose(): void;
|
|
188
265
|
onConnecting?(): void;
|
|
189
266
|
onOutboxFull?(): void;
|
|
190
|
-
/** Fired instead of onClose when the server closes with
|
|
191
|
-
*
|
|
192
|
-
* process has ended
|
|
193
|
-
*
|
|
194
|
-
*
|
|
195
|
-
*
|
|
196
|
-
*
|
|
197
|
-
*
|
|
198
|
-
*
|
|
199
|
-
*
|
|
200
|
-
*
|
|
267
|
+
/** Fired instead of onClose when the server closes with a DEFINITIVE
|
|
268
|
+
* application close code — process-exited (4001: the session's child
|
|
269
|
+
* process has ended) or unknown-session (4004: the server does not know
|
|
270
|
+
* the id at all — reaped, closed elsewhere, or a restarted server). In
|
|
271
|
+
* both cases the session will never produce output again, so a transient
|
|
272
|
+
* treatment could only earn the same close code forever (the endless
|
|
273
|
+
* "Reconnecting…" flash). When this callback is wired, the module does
|
|
274
|
+
* NOT auto-reconnect that socket and does not call onClose for it. The
|
|
275
|
+
* consumer decides what "ended" looks like (banner, tab state) and may
|
|
276
|
+
* still reconnect explicitly (setSession / reconnectNow) to re-view a
|
|
277
|
+
* dead session's final screen. When absent, every close keeps the legacy
|
|
278
|
+
* transient treatment (onClose + backoff reconnect), so existing
|
|
279
|
+
* consumers are unaffected until they opt in. */
|
|
201
280
|
onProcessExit?(): void;
|
|
202
281
|
/** Fired when the server's boot-epoch in resumeAck differs from the
|
|
203
282
|
* one observed on a previous connection — i.e. the server has
|
|
@@ -206,6 +285,19 @@ export interface Callbacks {
|
|
|
206
285
|
* starts from zero. UI should clear scrollback and surface a
|
|
207
286
|
* banner so the user knows old input may have been lost. */
|
|
208
287
|
onServerRestart?(): void;
|
|
288
|
+
/** Fired when a resumeAck carries an explicit server revision outside
|
|
289
|
+
* [MIN_SUPPORTED_SERVER_WIRE_VERSION, WIRE_PROTOCOL_VERSION]. A newer
|
|
290
|
+
* server warns but continues because it may retain this client's baseline.
|
|
291
|
+
* A below-floor server also fires onWireIncompatible and stops the socket.
|
|
292
|
+
* Version-silent servers never fire either callback. */
|
|
293
|
+
onWireVersionMismatch?(server: number, client: number): void;
|
|
294
|
+
/** Fired when the module definitively stops a socket for an incompatible
|
|
295
|
+
* declared wire revision, either from resumeAck metadata or close code
|
|
296
|
+
* 4002. Automatic and wake-triggered reconnects remain blocked until an
|
|
297
|
+
* explicit disconnect clears the terminal state (normally a page reload).
|
|
298
|
+
* The callback is optional; refusal is enforced even when no UI consumes
|
|
299
|
+
* it. */
|
|
300
|
+
onWireIncompatible?(details: WireIncompatibility): void;
|
|
209
301
|
computeSize(): { cols: number; rows: number };
|
|
210
302
|
/** Returns the highest absolute line index the client currently holds, or
|
|
211
303
|
* -1 if it holds nothing. Sent as `haveThrough` on resume so the server
|
|
@@ -237,6 +329,13 @@ export function init(callbacks: Callbacks): void {
|
|
|
237
329
|
* if the outbox is full (caller should surface a UI signal that input
|
|
238
330
|
* was dropped). Always copies the input to defend against caller-side
|
|
239
331
|
* buffer reuse.
|
|
332
|
+
*
|
|
333
|
+
* Input alphabet: any byte sequence is deliverable, including leading NULs.
|
|
334
|
+
* On a v4-upgraded socket the bytes go out verbatim in one binary message;
|
|
335
|
+
* on a v3-mode socket each leading 0x00 is emitted as its own solitary
|
|
336
|
+
* 1-byte frame (see sendInputFrames) so nothing this module sends can be
|
|
337
|
+
* misread as a control frame — byte order and count are preserved either
|
|
338
|
+
* way, and servers with the parse-fallback deliver every byte to the PTY.
|
|
240
339
|
*/
|
|
241
340
|
export function sendBinary(data: Uint8Array): boolean {
|
|
242
341
|
const st = activeState();
|
|
@@ -252,15 +351,54 @@ export function sendBinary(data: Uint8Array): boolean {
|
|
|
252
351
|
st.outboxBytes += copy.length;
|
|
253
352
|
st.bytesSent += copy.length;
|
|
254
353
|
if (connState.status === "connected") {
|
|
255
|
-
connState.sock
|
|
354
|
+
sendInputFrames(connState.sock, connState.upgraded, copy);
|
|
256
355
|
}
|
|
257
356
|
return true;
|
|
258
357
|
}
|
|
259
358
|
|
|
359
|
+
/**
|
|
360
|
+
* sendInputFrames is the ONE encoder for PTY input bytes — both the live
|
|
361
|
+
* sendBinary path and retransmitOutbox route through it so the two can never
|
|
362
|
+
* disagree on framing (design §6). On a v4-upgraded socket the bytes go out
|
|
363
|
+
* verbatim as a single binary message (full alphabet — the server never
|
|
364
|
+
* inspects a sentinel after the latch). On a v3-mode socket (old server, or
|
|
365
|
+
* the pre-upgrade window of any socket), each leading 0x00 byte is emitted as
|
|
366
|
+
* its own solitary 1-byte message so no frame the client sends can ever be
|
|
367
|
+
* misread as a control frame; servers with the parse-fallback deliver the
|
|
368
|
+
* solitary NUL to the PTY, and byte accounting is unchanged either way
|
|
369
|
+
* (splitting alters message count, never byte count or order).
|
|
370
|
+
*/
|
|
371
|
+
function sendInputFrames(sock: WebSocket, upgraded: boolean, chunk: Uint8Array): void {
|
|
372
|
+
let rest = chunk;
|
|
373
|
+
if (!upgraded) {
|
|
374
|
+
while (rest.length > 0 && rest[0] === 0x00) {
|
|
375
|
+
sock.send(new Uint8Array([0x00]).buffer);
|
|
376
|
+
rest = rest.subarray(1);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
if (rest.length > 0) {
|
|
380
|
+
sock.send(rest.buffer.slice(rest.byteOffset, rest.byteOffset + rest.byteLength) as ArrayBuffer);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* textControl encodes a control message for a v4-upgraded socket: bare JSON in
|
|
386
|
+
* a WebSocket TEXT frame (the transport's message type is the discriminator;
|
|
387
|
+
* no sentinel byte). The binary 0x00-sentinel form (controlFrame) remains the
|
|
388
|
+
* bootstrap/fallback encoding for v3-mode sockets.
|
|
389
|
+
*/
|
|
390
|
+
function textControl(msg: ControlMessage): string {
|
|
391
|
+
return JSON.stringify(msg);
|
|
392
|
+
}
|
|
393
|
+
|
|
260
394
|
function sendControl(msg: ControlMessage): void {
|
|
261
395
|
if (connState.status !== "connected") {
|
|
262
396
|
return;
|
|
263
397
|
}
|
|
398
|
+
if (connState.upgraded) {
|
|
399
|
+
connState.sock.send(textControl(msg));
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
264
402
|
connState.sock.send(controlFrame(msg));
|
|
265
403
|
}
|
|
266
404
|
|
|
@@ -316,12 +454,12 @@ function applyAck(st: ResumeState, received: number): void {
|
|
|
316
454
|
|
|
317
455
|
// On reconnect, after sending the resume control message and getting resumeAck,
|
|
318
456
|
// replay anything still in the session's outbox over its (now open) socket. The
|
|
319
|
-
// server has adjusted bytesAcked already — only unacked bytes remain.
|
|
320
|
-
|
|
457
|
+
// server has adjusted bytesAcked already — only unacked bytes remain. Routed
|
|
458
|
+
// through sendInputFrames so replay framing always matches live-send framing
|
|
459
|
+
// (v3-mode leading-NUL split vs v4 verbatim — design §6).
|
|
460
|
+
function retransmitOutbox(sock: WebSocket, upgraded: boolean, st: ResumeState): void {
|
|
321
461
|
for (const chunk of st.outbox) {
|
|
322
|
-
sock
|
|
323
|
-
chunk.buffer.slice(chunk.byteOffset, chunk.byteOffset + chunk.byteLength) as ArrayBuffer,
|
|
324
|
-
);
|
|
462
|
+
sendInputFrames(sock, upgraded, chunk);
|
|
325
463
|
}
|
|
326
464
|
}
|
|
327
465
|
|
|
@@ -420,6 +558,9 @@ function teardown(): void {
|
|
|
420
558
|
}
|
|
421
559
|
|
|
422
560
|
export function reconnectNow(): void {
|
|
561
|
+
if (connState.status === "incompatible") {
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
423
564
|
// Unconditional teardown. On iOS wake (visibilitychange + pageshow), the
|
|
424
565
|
// socket frequently reads OPEN/"connected" for a while but is actually a
|
|
425
566
|
// zombie — the OS froze it during sleep and frames printed meanwhile never
|
|
@@ -445,11 +586,19 @@ export function reconnectNow(): void {
|
|
|
445
586
|
*/
|
|
446
587
|
export function setSession(id: string): void {
|
|
447
588
|
managed = true;
|
|
448
|
-
ensureState(id);
|
|
589
|
+
const target = ensureState(id);
|
|
449
590
|
if (id === activeId && (connState.status === "connected" || connState.status === "connecting")) {
|
|
450
591
|
return; // already serving this session
|
|
451
592
|
}
|
|
452
593
|
activeId = id;
|
|
594
|
+
// Restore the target session's DEC-mode mirror SYNCHRONOUSLY (P3): a
|
|
595
|
+
// keystroke fired in the switch window — after this call returns, before
|
|
596
|
+
// the new session's modes frame arrives — must encode under the target's
|
|
597
|
+
// last-known modes (power-on defaults for a session never seen), never
|
|
598
|
+
// under the previous tab's. The kernel already disarms every other latched
|
|
599
|
+
// input class on switch (composition, sticky-Ctrl); this was the one it
|
|
600
|
+
// could not reach.
|
|
601
|
+
modes.applySnapshot(target.modes);
|
|
453
602
|
reconnectNow();
|
|
454
603
|
}
|
|
455
604
|
|
|
@@ -476,6 +625,9 @@ export function disconnect(): void {
|
|
|
476
625
|
}
|
|
477
626
|
|
|
478
627
|
export function connect(): void {
|
|
628
|
+
if (connState.status === "incompatible") {
|
|
629
|
+
return;
|
|
630
|
+
}
|
|
479
631
|
// Guard against double-call: a stray invocation while a previous
|
|
480
632
|
// socket is still CONNECTING/OPEN would orphan it (its handlers
|
|
481
633
|
// remain bound but the new sock assignment makes it unreachable).
|
|
@@ -555,19 +707,18 @@ export function connect(): void {
|
|
|
555
707
|
"open",
|
|
556
708
|
() => {
|
|
557
709
|
clearTimeout(timeoutId);
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
// Send resume immediately so server can respond with its current
|
|
565
|
-
// bytesReceived for this session — we trim/retransmit the outbox
|
|
566
|
-
// when that resumeAck arrives (handled in the message listener).
|
|
710
|
+
// Bootstrap resume FIRST, on the captured socket, before consumer
|
|
711
|
+
// callbacks can run: it must be message one on every socket (the v4
|
|
712
|
+
// negotiation bootstrap, design §4 — always binary-sentinel encoded,
|
|
713
|
+
// understood by every server revision). An onOpen callback that calls
|
|
714
|
+
// sendResize()/sendBinary() therefore always queues AFTER it.
|
|
567
715
|
sock.send(
|
|
568
716
|
controlFrame({
|
|
569
717
|
type: "resume",
|
|
570
|
-
|
|
718
|
+
// Managed mode: the per-sender resume key (routing id + "#" +
|
|
719
|
+
// instance id), so each device/tab owns its server-side input
|
|
720
|
+
// ledger. Unmanaged: the per-tab sessionStorage id as-is.
|
|
721
|
+
sessionId: resumeKey(st),
|
|
571
722
|
sentBytes: st.bytesSent,
|
|
572
723
|
// Highest absolute line index the client holds (-1 if none). The
|
|
573
724
|
// server replays everything after it, so lines printed while the
|
|
@@ -578,10 +729,18 @@ export function connect(): void {
|
|
|
578
729
|
haveThrough: cb?.getHaveThrough?.() ?? -1,
|
|
579
730
|
// Lets the server detect a client built against a different wire
|
|
580
731
|
// revision (e.g. a stale cached bundle) and warn rather than
|
|
581
|
-
// silently mis-decode
|
|
732
|
+
// silently mis-decode; >= 4 also ARMS the connection for the
|
|
733
|
+
// typed-framing upgrade (design §4 phase 1).
|
|
582
734
|
protocolVersion: WIRE_PROTOCOL_VERSION,
|
|
583
735
|
}),
|
|
584
736
|
);
|
|
737
|
+
// Every socket starts in v3 mode; the resumeAck's serverWireVersion
|
|
738
|
+
// decides whether the typed-framing upgrade happens (design §4).
|
|
739
|
+
connState = { status: "connected", sock, abort: connectAbort, upgraded: false };
|
|
740
|
+
reconnectDelay = INITIAL_DELAY_MS;
|
|
741
|
+
lastSentCols = 0;
|
|
742
|
+
lastSentRows = 0;
|
|
743
|
+
cb?.onOpen();
|
|
585
744
|
|
|
586
745
|
// Begin client-side liveness probing for this socket. Idempotent
|
|
587
746
|
// (clears any prior timer) and resets the activity clock to now.
|
|
@@ -621,6 +780,19 @@ export function connect(): void {
|
|
|
621
780
|
blobChain = blobChain
|
|
622
781
|
.then(() => blob.arrayBuffer())
|
|
623
782
|
.then((ab) => {
|
|
783
|
+
// Stale-socket guard (design §4, review F2): the async
|
|
784
|
+
// blob.arrayBuffer() hop can outlive this socket — teardown
|
|
785
|
+
// aborts the listeners, but a conversion already queued still
|
|
786
|
+
// resolves. A frame from a superseded socket must not reach
|
|
787
|
+
// handleDecoded, where its resumeAck could upgrade/reset/
|
|
788
|
+
// retransmit against the REPLACEMENT socket's server.
|
|
789
|
+
if (
|
|
790
|
+
connectAbort.signal.aborted ||
|
|
791
|
+
connState.status !== "connected" ||
|
|
792
|
+
connState.sock !== sock
|
|
793
|
+
) {
|
|
794
|
+
return;
|
|
795
|
+
}
|
|
624
796
|
handleDecoded(decodeWireBinary(ab));
|
|
625
797
|
})
|
|
626
798
|
.catch((err: unknown) => {
|
|
@@ -635,13 +807,10 @@ export function connect(): void {
|
|
|
635
807
|
});
|
|
636
808
|
return;
|
|
637
809
|
}
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
// ignore malformed text frames
|
|
643
|
-
}
|
|
644
|
-
}
|
|
810
|
+
// Text frames from the server are undefined in the protocol (the server
|
|
811
|
+
// sends only binary frames; wire v4 made text a CLIENT->server control
|
|
812
|
+
// channel). The old dormant JSON.parse branch that accepted them as
|
|
813
|
+
// unvalidated ServerMessages was removed 2026-07 (judgement finding).
|
|
645
814
|
},
|
|
646
815
|
{ signal: connectAbort.signal },
|
|
647
816
|
);
|
|
@@ -651,6 +820,63 @@ export function connect(): void {
|
|
|
651
820
|
return;
|
|
652
821
|
}
|
|
653
822
|
if (msg.type === "resumeAck") {
|
|
823
|
+
// An explicit below-floor revision is definitive: this decoder cannot
|
|
824
|
+
// safely consume that server's frames. Stop the socket before invoking
|
|
825
|
+
// consumer callbacks, latch the no-reconnect state, and require an
|
|
826
|
+
// explicit disconnect/page reload before another attempt. A missing
|
|
827
|
+
// tail remains the version-silent compatibility path.
|
|
828
|
+
if (
|
|
829
|
+
msg.serverWireVersion !== undefined &&
|
|
830
|
+
msg.serverWireVersion < MIN_SUPPORTED_SERVER_WIRE_VERSION
|
|
831
|
+
) {
|
|
832
|
+
const reason = `server wire protocol ${msg.serverWireVersion} is below client minimum ${MIN_SUPPORTED_SERVER_WIRE_VERSION}; upgrade the server`;
|
|
833
|
+
console.warn("vterm: refusing incompatible server wire protocol", reason);
|
|
834
|
+
stopHeartbeat();
|
|
835
|
+
connState = { status: "incompatible" };
|
|
836
|
+
try {
|
|
837
|
+
sock.close(WIRE_INCOMPATIBLE_CLOSE_CODE, reason);
|
|
838
|
+
} finally {
|
|
839
|
+
connectAbort.abort();
|
|
840
|
+
}
|
|
841
|
+
cb?.onWireVersionMismatch?.(msg.serverWireVersion, WIRE_PROTOCOL_VERSION);
|
|
842
|
+
cb?.onWireIncompatible?.({
|
|
843
|
+
source: "server-version",
|
|
844
|
+
serverVersion: msg.serverWireVersion,
|
|
845
|
+
clientVersion: WIRE_PROTOCOL_VERSION,
|
|
846
|
+
minimumServerVersion: MIN_SUPPORTED_SERVER_WIRE_VERSION,
|
|
847
|
+
reason,
|
|
848
|
+
});
|
|
849
|
+
return;
|
|
850
|
+
}
|
|
851
|
+
// Higher revisions may retain this client's compatible baseline. Keep
|
|
852
|
+
// the socket running and surface the skew as a warning only.
|
|
853
|
+
if (msg.serverWireVersion !== undefined && msg.serverWireVersion > WIRE_PROTOCOL_VERSION) {
|
|
854
|
+
console.warn(
|
|
855
|
+
"vterm: server wire-protocol version is newer than client",
|
|
856
|
+
"server",
|
|
857
|
+
msg.serverWireVersion,
|
|
858
|
+
"client",
|
|
859
|
+
WIRE_PROTOCOL_VERSION,
|
|
860
|
+
"- upgrade the client if terminal behavior is incorrect",
|
|
861
|
+
);
|
|
862
|
+
cb?.onWireVersionMismatch?.(msg.serverWireVersion, WIRE_PROTOCOL_VERSION);
|
|
863
|
+
}
|
|
864
|
+
// Typed-framing upgrade (design §4 phase 3): on proof of a v4+ server,
|
|
865
|
+
// send the text transition FIRST — WebSocket ordering then guarantees
|
|
866
|
+
// the server latches before any unsplit binary input that follows —
|
|
867
|
+
// and only then flip the socket's mode. This must precede the
|
|
868
|
+
// ledger-lost/ack/retransmit handling below so the retransmit already
|
|
869
|
+
// uses the upgraded framing.
|
|
870
|
+
if (
|
|
871
|
+
msg.serverWireVersion !== undefined &&
|
|
872
|
+
msg.serverWireVersion >= TYPED_FRAMING_MIN_VERSION &&
|
|
873
|
+
connState.status === "connected" &&
|
|
874
|
+
connState.sock === sock &&
|
|
875
|
+
!connState.upgraded
|
|
876
|
+
) {
|
|
877
|
+
sock.send(textControl({ type: "upgrade" }));
|
|
878
|
+
connState.upgraded = true;
|
|
879
|
+
}
|
|
654
880
|
// Server-restart detection. The first resumeAck we see records
|
|
655
881
|
// the epoch; subsequent ones compare to it. A mismatch means the
|
|
656
882
|
// server's process has restarted, which invalidates our local
|
|
@@ -665,41 +891,71 @@ export function connect(): void {
|
|
|
665
891
|
}
|
|
666
892
|
// Resync guard 8.2.2: hand the server's retained-history bounds to the
|
|
667
893
|
// consumer so it can surface a trim marker when history the client was
|
|
668
|
-
// missing is gone for good. (If the session-forgotten
|
|
669
|
-
// state, a fresh server's oldest=0 simply reads as
|
|
894
|
+
// missing is gone for good. (If the ledger-lost / session-forgotten
|
|
895
|
+
// paths below reset state, a fresh server's oldest=0 simply reads as
|
|
896
|
+
// "no trim".)
|
|
670
897
|
if (typeof msg.committed === "number" && typeof msg.oldestIndex === "number") {
|
|
671
898
|
cb?.onResumeBounds?.(msg.committed, msg.oldestIndex);
|
|
672
899
|
}
|
|
673
|
-
//
|
|
674
|
-
//
|
|
675
|
-
//
|
|
676
|
-
//
|
|
677
|
-
//
|
|
678
|
-
//
|
|
679
|
-
//
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
900
|
+
// Explicit ledger-loss signal (servers with the >= 35-byte resumeAck
|
|
901
|
+
// tail): the resume key missed the server's registry while we claimed
|
|
902
|
+
// sent bytes — our ledger was reclaimed (idle GC / cap eviction). The
|
|
903
|
+
// server cannot vouch for ANY previously sent input, so replaying the
|
|
904
|
+
// outbox risks duplicate execution. Deterministic drop-and-notify,
|
|
905
|
+
// covering the bytesAcked === 0 case the heuristic below cannot see
|
|
906
|
+
// (acks that never reached us before the disconnect).
|
|
907
|
+
if (msg.ledgerLost) {
|
|
908
|
+
resetSessionAfterRestart(st);
|
|
909
|
+
return;
|
|
910
|
+
}
|
|
911
|
+
// Server-doesn't-recognize-this-session safeguard (old-server
|
|
912
|
+
// fallback, pre-ledgerLost tail): if the server returns received=0
|
|
913
|
+
// but the client already had bytesAcked > 0, the server has forgotten
|
|
914
|
+
// our session (idle GC kicked in, or sessionId persistence failed and
|
|
915
|
+
// a reload created a new one). Replaying the outbox would deliver
|
|
916
|
+
// every queued chunk again, causing the iOS tab-suspend
|
|
917
|
+
// duplicate-resend bug. Drop the outbox and notify the UI as if the
|
|
918
|
+
// server restarted — input since the last successful ack is
|
|
919
|
+
// irrecoverable but at least not duplicated. Skip this branch when
|
|
920
|
+
// bytesSent = 0 (genuine first-connect; received=0 is correct).
|
|
683
921
|
if (msg.received === 0 && st.bytesAcked > 0) {
|
|
684
922
|
resetSessionAfterRestart(st);
|
|
685
923
|
return;
|
|
686
924
|
}
|
|
687
925
|
applyAck(st, msg.received);
|
|
688
|
-
retransmitOutbox(
|
|
926
|
+
retransmitOutbox(
|
|
927
|
+
sock,
|
|
928
|
+
connState.status === "connected" && connState.sock === sock && connState.upgraded,
|
|
929
|
+
st,
|
|
930
|
+
);
|
|
931
|
+
return;
|
|
932
|
+
}
|
|
933
|
+
if (msg.type === "ackOnly") {
|
|
934
|
+
// Bare ack from the flush tick's sweep: input was applied but no
|
|
935
|
+
// content frame carried the new count (silent app — e.g. `read -s`).
|
|
936
|
+
// Trim the outbox and stop; transport-internal, never forwarded to
|
|
937
|
+
// onMessage.
|
|
938
|
+
applyAck(st, msg.inputAck);
|
|
689
939
|
return;
|
|
690
940
|
}
|
|
691
941
|
if (msg.type === "modes") {
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
msg.
|
|
698
|
-
msg.
|
|
699
|
-
msg.
|
|
700
|
-
msg.
|
|
701
|
-
msg.
|
|
702
|
-
|
|
942
|
+
// Single mirror writer (P3): cache the snapshot on the session this
|
|
943
|
+
// socket serves AND apply it to the active-session singleton. The two
|
|
944
|
+
// targets are the same session by construction — a superseded socket's
|
|
945
|
+
// listeners are aborted, so only the active session's socket delivers.
|
|
946
|
+
const snap: modes.ModeSnapshot = {
|
|
947
|
+
bracketedPaste: msg.bracketedPaste,
|
|
948
|
+
applicationCursor: msg.applicationCursor,
|
|
949
|
+
mouseSGR: msg.mouseSGR,
|
|
950
|
+
focusReporting: msg.focusReporting,
|
|
951
|
+
mouseMode: msg.mouseMode,
|
|
952
|
+
applicationKeypad: msg.applicationKeypad,
|
|
953
|
+
reverseVideo: msg.reverseVideo,
|
|
954
|
+
mousePixels: msg.mousePixels,
|
|
955
|
+
keyboardFlags: msg.keyboardFlags,
|
|
956
|
+
};
|
|
957
|
+
st.modes = snap;
|
|
958
|
+
modes.applySnapshot(snap);
|
|
703
959
|
if (typeof msg.inputAck === "number") {
|
|
704
960
|
applyAck(st, msg.inputAck);
|
|
705
961
|
}
|
|
@@ -728,14 +984,33 @@ export function connect(): void {
|
|
|
728
984
|
return;
|
|
729
985
|
}
|
|
730
986
|
stopHeartbeat();
|
|
987
|
+
if (ev.code === WIRE_INCOMPATIBLE_CLOSE_CODE) {
|
|
988
|
+
const reason =
|
|
989
|
+
ev.reason ||
|
|
990
|
+
"server rejected this client wire protocol; reload or upgrade the client/server";
|
|
991
|
+
connState = { status: "incompatible" };
|
|
992
|
+
cb?.onWireIncompatible?.({
|
|
993
|
+
source: "server-close",
|
|
994
|
+
clientVersion: WIRE_PROTOCOL_VERSION,
|
|
995
|
+
minimumServerVersion: MIN_SUPPORTED_SERVER_WIRE_VERSION,
|
|
996
|
+
reason,
|
|
997
|
+
});
|
|
998
|
+
return;
|
|
999
|
+
}
|
|
731
1000
|
connState = { status: "disconnected" };
|
|
732
1001
|
// A process-exited close (4001) is definitive: the child is gone, so a
|
|
733
1002
|
// backoff reconnect can only replay the final screen and collect another
|
|
734
1003
|
// 4001 — an endless, pointless churn that reads as a flapping
|
|
735
|
-
// "Reconnecting…" banner.
|
|
736
|
-
//
|
|
737
|
-
//
|
|
738
|
-
|
|
1004
|
+
// "Reconnecting…" banner. An unknown-session close (4004) is equally
|
|
1005
|
+
// definitive: the server does not know the id at all (reaped, closed
|
|
1006
|
+
// elsewhere, restarted server), so reconnecting can only collect another
|
|
1007
|
+
// 4004. Route both to onProcessExit (no reconnect, no onClose) when the
|
|
1008
|
+
// consumer wired it; without the callback, keep the legacy transient
|
|
1009
|
+
// treatment so existing consumers see no change.
|
|
1010
|
+
if (
|
|
1011
|
+
(ev.code === PROCESS_EXITED_CLOSE_CODE || ev.code === SESSION_UNKNOWN_CLOSE_CODE) &&
|
|
1012
|
+
cb?.onProcessExit
|
|
1013
|
+
) {
|
|
739
1014
|
cb.onProcessExit();
|
|
740
1015
|
return;
|
|
741
1016
|
}
|
package/src/index.ts
CHANGED
|
@@ -3,16 +3,25 @@ export { LineStore } from "./store.js";
|
|
|
3
3
|
export type { WindowState, StoreChanges } from "./store.js";
|
|
4
4
|
export * as render from "./render.js";
|
|
5
5
|
export * as keyboard from "./keyboard.js";
|
|
6
|
+
export * as toolbar from "./toolbar.js";
|
|
6
7
|
export * as scroll from "./scroll.js";
|
|
7
8
|
export * as modes from "./modes.js";
|
|
8
9
|
export * as mouse from "./mouse.js";
|
|
9
10
|
export * as connection from "./connection.js";
|
|
10
11
|
export { decodeWireBinary } from "./wire-binary.js";
|
|
12
|
+
export {
|
|
13
|
+
MIN_SUPPORTED_SERVER_WIRE_VERSION,
|
|
14
|
+
WIRE_COMPATIBILITY,
|
|
15
|
+
WIRE_INCOMPATIBLE_CLOSE_CODE,
|
|
16
|
+
WIRE_PROTOCOL_VERSION,
|
|
17
|
+
} from "./wire-compatibility.js";
|
|
18
|
+
export type { WireIncompatibility } from "./wire-compatibility.js";
|
|
11
19
|
export { controlFrame, CONTROL_FRAME_PREFIX } from "./wire.js";
|
|
12
20
|
export { wsURL } from "./wsurl.js";
|
|
13
21
|
export { WS_PATH, SESSIONS_PATH, SESSION_EVENTS_PATH } from "./routes.js";
|
|
14
22
|
export { connectStatusStream } from "./status-stream.js";
|
|
15
23
|
export type {
|
|
24
|
+
SessionInfo,
|
|
16
25
|
SessionStatus,
|
|
17
26
|
StatusStream,
|
|
18
27
|
StatusStreamCallbacks,
|