@cplieger/web-terminal-engine 2.8.0 → 3.0.1

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/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 `sessionId` (UUID) for the page lifetime, an
10
- // `outbox` of input bytes sent but not yet acknowledged, and a
11
- // `bytesSent` counter.
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, WIRE_PROTOCOL_VERSION } from "./wire-binary.js";
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
- | { status: "connected"; sock: WebSocket; abort: AbortController }
36
- | { status: "reconnecting"; timer: ReturnType<typeof setTimeout>; delayMs: number };
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: both the routing id (?session=) and the resume 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 { id, bytesSent: 0, bytesAcked: 0, outbox: [], outboxBytes: 0, lastServerEpoch: null };
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 the
191
- * process-exited application close code (4001): the session's child
192
- * process has ended, so the close is DEFINITIVE, not a transient drop.
193
- * When this callback is wired, the module does NOT auto-reconnect that
194
- * socket — reconnecting a dead session just replays its final screen and
195
- * earns another 4001 (the endless "Reconnecting…" flash) — and does not
196
- * call onClose for it. The consumer decides what "ended" looks like
197
- * (banner, tab state) and may still reconnect explicitly (setSession /
198
- * reconnectNow) to re-view the final screen. When absent, every close
199
- * keeps the legacy transient treatment (onClose + backoff reconnect), so
200
- * existing consumers are unaffected until they opt in. */
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.send(copy.buffer.slice(copy.byteOffset, copy.byteOffset + copy.byteLength));
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
- function retransmitOutbox(sock: WebSocket, st: ResumeState): void {
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.send(
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
- connState = { status: "connected", sock, abort: connectAbort };
559
- reconnectDelay = INITIAL_DELAY_MS;
560
- lastSentCols = 0;
561
- lastSentRows = 0;
562
- cb?.onOpen();
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
- sessionId: st.id,
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
- if (typeof ev.data === "string") {
639
- try {
640
- handleDecoded(JSON.parse(ev.data) as ServerMessage);
641
- } catch {
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 path below resets
669
- // state, a fresh server's oldest=0 simply reads as "no trim".)
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
- // Server-doesn't-recognize-this-session safeguard: if the server
674
- // returns received=0 but the client already had bytesAcked > 0,
675
- // the server has forgotten our session (idle GC kicked in, or
676
- // sessionId persistence failed and a reload created a new one).
677
- // Replaying the outbox would deliver every queued chunk again,
678
- // causing the iOS tab-suspend duplicate-resend bug. Drop the
679
- // outbox and notify the UI as if the server restarted — input
680
- // since the last successful ack is irrecoverable but at least
681
- // not duplicated. Skip this branch when bytesSent = 0 (genuine
682
- // first-connect; received=0 is correct).
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(sock, st);
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
- modes.setModes(
693
- msg.bracketedPaste,
694
- msg.applicationCursor,
695
- msg.mouseSGR,
696
- msg.focusReporting,
697
- msg.mouseMode,
698
- msg.applicationKeypad,
699
- msg.reverseVideo,
700
- msg.mousePixels,
701
- msg.keyboardFlags,
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. Route it to onProcessExit (no reconnect, no
736
- // onClose) when the consumer wired it; without the callback, keep the
737
- // legacy transient treatment so existing consumers see no change.
738
- if (ev.code === PROCESS_EXITED_CLOSE_CODE && cb?.onProcessExit) {
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,11 +3,19 @@ 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 { WireCompatibility, 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";