@cplieger/web-terminal-engine 1.2.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.
@@ -0,0 +1,584 @@
1
+ // WebSocket lifecycle with reliable input delivery across reconnects.
2
+ //
3
+ // This is the client → server half of the terminal protocol (the
4
+ // server → client half is the binary screen/scroll/modes wire format
5
+ // decoded by wire-binary.ts). It owns the socket, the reconnect
6
+ // backoff, and the resume/inputAck reliability layer.
7
+ //
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.
12
+ // - On WS open, client sends control: {type:"resume", sessionId, sentBytes}.
13
+ // - Server replies with {type:"resumeAck", received:M}; subsequent
14
+ // screen/scroll messages also carry inputAck = bytesReceived. Client
15
+ // trims the outbox by acked count and retransmits the remainder.
16
+ // - This handles the network-blip failure mode where ws.send() reports
17
+ // success but TCP couldn't deliver before the connection broke.
18
+ //
19
+ // Outbox is bounded at MAX_OUTBOX_BYTES; once full, sendBinary refuses
20
+ // new input and surfaces the failure via onOutboxFull. This prevents
21
+ // holding-down a key during a long disconnect from growing the outbox
22
+ // without bound.
23
+
24
+ import { wsURL } from "./wsurl.js";
25
+ import { controlFrame } from "./wire.js";
26
+ import { decodeWireBinary, WIRE_PROTOCOL_VERSION } from "./wire-binary.js";
27
+ import * as modes from "./modes.js";
28
+ import type { ControlMessage, ServerMessage } from "./types.js";
29
+ import { INITIAL_DELAY_MS, nextBackoffDelay } from "./reconnect.js";
30
+
31
+ type ConnState =
32
+ | { status: "disconnected" }
33
+ | { status: "connecting"; sock: WebSocket; abort: AbortController }
34
+ | { status: "connected"; sock: WebSocket; abort: AbortController }
35
+ | { status: "reconnecting"; timer: ReturnType<typeof setTimeout>; delayMs: number };
36
+
37
+ let connState: ConnState = { status: "disconnected" };
38
+ let reconnectDelay = INITIAL_DELAY_MS;
39
+ let lastSentCols = 0;
40
+ let lastSentRows = 0;
41
+ let wsPath = "/ws";
42
+
43
+ // Resume-protocol state. sessionId persists across iOS tab-suspend/reload
44
+ // via sessionStorage. Without this persistence, an iOS Safari tab unload
45
+ // (which Safari does aggressively when the user backgrounds the tab) and
46
+ // subsequent reconstruction from history triggers a fresh JS module load
47
+ // → new sessionId → server treats it as a new session → resumeAck.received
48
+ // returns 0 → applyAck(0) doesn't trim the outbox → retransmitOutbox sends
49
+ // every queued chunk again, causing the duplicate-message-resend bug.
50
+ const SESSION_ID_KEY = "vterm-session-id";
51
+ const sessionId = loadOrCreateSessionId();
52
+ let bytesSent = 0; // total bytes ever passed to sendBinary
53
+ let bytesAcked = 0; // confirmed by server inputAck/resumeAck
54
+ const outbox: Uint8Array[] = []; // chunks of unacked bytes (sum = bytesSent - bytesAcked)
55
+ let outboxBytes = 0; // running sum of outbox chunk lengths; keeps applyAck O(n) instead of O(n²)
56
+ let lastServerEpoch: number | null = null; // process-start nanos of the last connected server
57
+
58
+ // --- Client-side liveness (bug 2 defense-in-depth) ---
59
+ //
60
+ // On iOS wake, visibilitychange + pageshow fire and call reconnectNow(),
61
+ // which is the primary fix. But a socket can also go silently half-open
62
+ // without any wake event (a NAT/idle timeout on a backgrounded-then-
63
+ // foregrounded tab, a flaky network that drops the path without a close
64
+ // frame). The socket then reads OPEN forever and delivers nothing. The
65
+ // server's ping loop notices the dead client, but those are WS-protocol
66
+ // pings the browser answers without surfacing to JS, so the client can't
67
+ // see them. So the client runs its own probe: after a stretch of silence
68
+ // it sends an app-level ping; the server echoes a pong. Any inbound frame
69
+ // (the pong, or normal output) proves the socket is alive and clears the
70
+ // probe. If the probe goes unanswered, the socket is stale and we
71
+ // reconnectNow() — which resumes by absolute index, so nothing is lost or
72
+ // duplicated. The probe is what distinguishes "idle but alive" from
73
+ // "dead": without it, a quiet-but-healthy terminal would reconnect-flap.
74
+ let lastActivityAt = 0; // Date.now() of the last inbound frame (any kind)
75
+ let probeSentAt = 0; // Date.now() the outstanding probe ping was sent; 0 = none
76
+ let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
77
+
78
+ /** How often liveness is evaluated. */
79
+ const HEARTBEAT_INTERVAL_MS = 5_000;
80
+ /** Inbound silence that must elapse before we actively probe with a ping. */
81
+ const IDLE_BEFORE_PROBE_MS = 10_000;
82
+ /** How long an unanswered probe is tolerated before declaring the socket stale. */
83
+ const PONG_TIMEOUT_MS = 7_000;
84
+
85
+ /**
86
+ * Maximum bytes we keep in the outbox before refusing new input. 1
87
+ * MiB at typical typing rates is hours of held keys; fast enough to
88
+ * accept any normal disconnect, low enough that an offline tab can't
89
+ * silently grow memory unbounded.
90
+ */
91
+ export const MAX_OUTBOX_BYTES = 1 << 20;
92
+
93
+ function loadOrCreateSessionId(): string {
94
+ // sessionStorage is per-tab and survives most iOS lifecycle events
95
+ // (suspend/resume, BFCache restore, page reload). It does NOT survive
96
+ // a true tab close + reopen, which is the desired semantic: a fresh
97
+ // tab should be a fresh terminal session, not a resume of an older one.
98
+ try {
99
+ const existing = sessionStorage.getItem(SESSION_ID_KEY);
100
+ if (existing) {
101
+ return existing;
102
+ }
103
+ const fresh = generateSessionId();
104
+ sessionStorage.setItem(SESSION_ID_KEY, fresh);
105
+ return fresh;
106
+ } catch {
107
+ // Private mode or storage disabled — fall back to in-memory only.
108
+ // Reload-as-new-session semantics in this fallback path are
109
+ // unavoidable; the outbox-clear safeguard in handleResumeAck below
110
+ // protects against duplicate retransmission when the server returns
111
+ // bytesReceived=0 for a session it doesn't recognize.
112
+ return generateSessionId();
113
+ }
114
+ }
115
+
116
+ // Exported for unit testing of the RNG fallback. Not part of the
117
+ // stable client API surface; callers use loadOrCreateSessionId.
118
+ export function generateSessionId(): string {
119
+ if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
120
+ return crypto.randomUUID();
121
+ }
122
+ // Fallback when crypto.randomUUID is unavailable. randomUUID requires a
123
+ // secure context (HTTPS/localhost); getRandomValues does not, so it
124
+ // covers plain-HTTP origins while still being a CSPRNG. sessionId is a
125
+ // resume token the server trusts to re-attach a client to its prior
126
+ // session, so it must not be predictable — Math.random() (a non-crypto
127
+ // PRNG whose state is recoverable from output) would allow guessing
128
+ // another client's session. Emit 16 random bytes as hex (128 bits).
129
+ if (typeof crypto !== "undefined" && typeof crypto.getRandomValues === "function") {
130
+ const bytes = new Uint8Array(16);
131
+ crypto.getRandomValues(bytes);
132
+ return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
133
+ }
134
+ // No Web Crypto at all: refuse rather than mint a guessable token.
135
+ throw new Error("vterm: no cryptographically secure RNG available for session id");
136
+ }
137
+
138
+ export interface Callbacks {
139
+ onMessage(msg: ServerMessage): void;
140
+ onOpen(): void;
141
+ onClose(): void;
142
+ onConnecting?(): void;
143
+ onOutboxFull?(): void;
144
+ /** Fired when the server's boot-epoch in resumeAck differs from the
145
+ * one observed on a previous connection — i.e. the server has
146
+ * restarted. By the time this fires, the connection module has
147
+ * already reset bytesSent/bytesAcked/outbox so subsequent input
148
+ * starts from zero. UI should clear scrollback and surface a
149
+ * banner so the user knows old input may have been lost. */
150
+ onServerRestart?(): void;
151
+ computeSize(): { cols: number; rows: number };
152
+ /** Returns the highest absolute line index the client currently holds, or
153
+ * -1 if it holds nothing. Sent as `haveThrough` on resume so the server
154
+ * replays only the lines missed (e.g. printed while the device slept).
155
+ * When absent, the client requests a full retained replay (-1). */
156
+ getHaveThrough?(): number;
157
+ /** Fired on resume with the server's retained-history bounds: `committed`
158
+ * is one past the newest retained line, `oldest` the oldest retained
159
+ * absolute index. The consumer forwards these to the renderer/store so it
160
+ * can tell a genuine history trim (the server evicted lines the client was
161
+ * missing) from a still-loading state. Resync guard 8.2.2. */
162
+ onResumeBounds?(committed: number, oldest: number): void;
163
+ /** Optional WebSocket endpoint path (default "/ws"). vibekit serves
164
+ * the shell at "/api/shell/ws"; vibecli at "/ws". */
165
+ wsPath?: string;
166
+ }
167
+
168
+ let cb: Callbacks | null = null;
169
+
170
+ export function init(callbacks: Callbacks): void {
171
+ cb = callbacks;
172
+ if (callbacks.wsPath !== undefined) {
173
+ wsPath = callbacks.wsPath;
174
+ }
175
+ }
176
+
177
+ /**
178
+ * sendBinary queues data for delivery. Returns true if accepted, false
179
+ * if the outbox is full (caller should surface a UI signal that input
180
+ * was dropped). Always copies the input to defend against caller-side
181
+ * buffer reuse.
182
+ */
183
+ export function sendBinary(data: Uint8Array): boolean {
184
+ if (outboxBytes + data.length > MAX_OUTBOX_BYTES) {
185
+ cb?.onOutboxFull?.();
186
+ return false;
187
+ }
188
+ // Always go through the outbox. Bytes leave it only when the server
189
+ // explicitly acks them — guarantees correct retransmission after a
190
+ // network blip even if ws.send() reported success.
191
+ const copy = new Uint8Array(data); // defensive copy (caller may reuse buffer)
192
+ outbox.push(copy);
193
+ outboxBytes += copy.length;
194
+ bytesSent += copy.length;
195
+ if (connState.status === "connected") {
196
+ connState.sock.send(copy.buffer.slice(copy.byteOffset, copy.byteOffset + copy.byteLength));
197
+ }
198
+ return true;
199
+ }
200
+
201
+ function sendControl(msg: ControlMessage): void {
202
+ if (connState.status !== "connected") {
203
+ return;
204
+ }
205
+ connState.sock.send(controlFrame(msg));
206
+ }
207
+
208
+ export function sendResize(): void {
209
+ if (connState.status !== "connected" || !cb) {
210
+ return;
211
+ }
212
+ const { cols, rows } = cb.computeSize();
213
+ if (cols === lastSentCols && rows === lastSentRows) {
214
+ return;
215
+ }
216
+ lastSentCols = cols;
217
+ lastSentRows = rows;
218
+ sendControl({ type: "resize", cols, rows });
219
+ }
220
+
221
+ // applyAck drops chunks from the front of the outbox until the
222
+ // running total of unacked bytes matches (bytesSent - newAck). Runs
223
+ // in O(chunks_dropped) by tracking outboxBytes incrementally rather
224
+ // than re-summing on every loop iteration.
225
+ function applyAck(received: number): void {
226
+ if (received <= bytesAcked) {
227
+ return;
228
+ }
229
+ bytesAcked = Math.min(received, bytesSent);
230
+ const targetUnacked = bytesSent - bytesAcked;
231
+ while (outbox.length > 0 && outboxBytes > targetUnacked) {
232
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- length checked above
233
+ const head = outbox[0]!;
234
+ const dropFromHead = outboxBytes - targetUnacked;
235
+ if (head.length <= dropFromHead) {
236
+ outbox.shift();
237
+ outboxBytes -= head.length;
238
+ } else {
239
+ outbox[0] = head.subarray(dropFromHead);
240
+ outboxBytes -= dropFromHead;
241
+ break;
242
+ }
243
+ }
244
+ }
245
+
246
+ // On reconnect, after sending the resume control message and getting
247
+ // resumeAck, replay anything still in the outbox. The server has
248
+ // adjusted bytesAcked already — only unacked bytes remain.
249
+ function retransmitOutbox(): void {
250
+ if (connState.status !== "connected") {
251
+ return;
252
+ }
253
+ for (const chunk of outbox) {
254
+ connState.sock.send(
255
+ chunk.buffer.slice(chunk.byteOffset, chunk.byteOffset + chunk.byteLength) as ArrayBuffer,
256
+ );
257
+ }
258
+ }
259
+
260
+ function scheduleReconnect(): void {
261
+ if (connState.status === "reconnecting") {
262
+ return;
263
+ }
264
+ const step = nextBackoffDelay(reconnectDelay);
265
+ reconnectDelay = step.nextBaseMs;
266
+ const timer = setTimeout(() => {
267
+ connState = { status: "disconnected" };
268
+ connect();
269
+ }, step.scheduledMs);
270
+ connState = { status: "reconnecting", timer, delayMs: step.scheduledMs };
271
+ }
272
+
273
+ function cancelScheduledReconnect(): void {
274
+ if (connState.status === "reconnecting") {
275
+ clearTimeout(connState.timer);
276
+ connState = { status: "disconnected" };
277
+ }
278
+ }
279
+
280
+ // markActivity records that the socket just delivered a frame. Any frame —
281
+ // the pong, a screen update, anything — proves the socket is alive, so it
282
+ // refreshes the liveness clock and clears any outstanding probe.
283
+ function markActivity(): void {
284
+ lastActivityAt = Date.now();
285
+ probeSentAt = 0;
286
+ }
287
+
288
+ function startHeartbeat(): void {
289
+ stopHeartbeat();
290
+ markActivity();
291
+ heartbeatTimer = setInterval(heartbeatTick, HEARTBEAT_INTERVAL_MS);
292
+ }
293
+
294
+ function stopHeartbeat(): void {
295
+ if (heartbeatTimer !== null) {
296
+ clearInterval(heartbeatTimer);
297
+ heartbeatTimer = null;
298
+ }
299
+ probeSentAt = 0;
300
+ }
301
+
302
+ // heartbeatTick is the one place that decides a connected socket is stale.
303
+ // It never touches scrollTop, never reconnects a healthy socket, and never
304
+ // probes a backgrounded tab (timer throttling makes hidden-tab timing
305
+ // meaningless, and the wake path handles foregrounding). Its only actions
306
+ // are: send a probe after enough silence, or reconnect after a probe goes
307
+ // unanswered.
308
+ function heartbeatTick(): void {
309
+ if (connState.status !== "connected") {
310
+ return;
311
+ }
312
+ // A hidden tab is handled by visibilitychange/pageshow on wake; probing it
313
+ // is pointless (its timers are throttled or frozen) and could fire stale.
314
+ if (typeof document !== "undefined" && document.visibilityState === "hidden") {
315
+ return;
316
+ }
317
+ const now = Date.now();
318
+ if (probeSentAt > 0) {
319
+ if (now - probeSentAt >= PONG_TIMEOUT_MS) {
320
+ // The probe drew no reply (nor any other frame) in the grace window:
321
+ // the socket is stale. Tear it down and resume by absolute index.
322
+ probeSentAt = 0;
323
+ reconnectNow();
324
+ }
325
+ return;
326
+ }
327
+ if (now - lastActivityAt >= IDLE_BEFORE_PROBE_MS) {
328
+ probeSentAt = now;
329
+ sendControl({ type: "ping" });
330
+ }
331
+ }
332
+
333
+ export function reconnectNow(): void {
334
+ // Unconditional teardown. On iOS wake (visibilitychange + pageshow), the
335
+ // socket frequently reads OPEN/"connected" for a while but is actually a
336
+ // zombie — the OS froze it during sleep and frames printed meanwhile never
337
+ // arrive. The old early-return on "connected" trusted that stale state and
338
+ // skipped the reconnect, which is exactly why content printed during sleep
339
+ // stayed missing until a manual refresh (bug 2). So we never trust the
340
+ // current state on a wake: abort + close whatever socket exists and
341
+ // reconnect. The resume protocol (by absolute index) then backfills exactly
342
+ // the missed lines, so a reconnect over a still-healthy socket is a cheap,
343
+ // duplicate-free no-op rather than a risk.
344
+ if (connState.status === "connecting" || connState.status === "connected") {
345
+ // Abort BEFORE close: aborting detaches all listeners on the existing
346
+ // sock, so frames arriving between close() and the close handshake aren't
347
+ // processed twice (the iPad-wake duplicate-output race).
348
+ connState.abort.abort();
349
+ try {
350
+ connState.sock.close();
351
+ } catch {
352
+ /* ignore */
353
+ }
354
+ }
355
+ stopHeartbeat();
356
+ cancelScheduledReconnect();
357
+ connState = { status: "disconnected" };
358
+ connect();
359
+ }
360
+
361
+ export function connect(): void {
362
+ // Guard against double-call: a stray invocation while a previous
363
+ // socket is still CONNECTING/OPEN would orphan it (its handlers
364
+ // remain bound but the new sock assignment makes it unreachable).
365
+ // Aborting the previous controller detaches all listeners on the
366
+ // old sock so it can't deliver frames to the page after we've moved
367
+ // on (the iPad-wake duplicate-output race).
368
+ if (connState.status === "connecting" || connState.status === "connected") {
369
+ connState.abort.abort();
370
+ try {
371
+ connState.sock.close();
372
+ } catch {
373
+ /* ignore */
374
+ }
375
+ }
376
+
377
+ cb?.onConnecting?.();
378
+
379
+ const sock = new WebSocket(wsURL(location.protocol, location.host, wsPath));
380
+ sock.binaryType = "arraybuffer";
381
+
382
+ // One AbortController governs the lifetime of THIS sock's listeners.
383
+ // - Connect-timeout fallback: aborts after 10s if open never fires.
384
+ // - Listener auto-detach: every addEventListener below uses
385
+ // { signal: connectAbort.signal }, so when the controller is
386
+ // aborted (by reconnectNow / connect / close) the listeners are
387
+ // removed atomically and can't fire again.
388
+ const connectAbort = new AbortController();
389
+ const timeoutId = setTimeout(() => {
390
+ connectAbort.abort();
391
+ }, 10_000);
392
+ connectAbort.signal.addEventListener("abort", () => {
393
+ clearTimeout(timeoutId);
394
+ // Force-close on abort so the OS-level socket goes away promptly,
395
+ // not only when the browser eventually completes its close
396
+ // handshake. Belt-and-braces with the .close() in our callers.
397
+ try {
398
+ sock.close();
399
+ } catch {
400
+ /* ignore */
401
+ }
402
+ });
403
+
404
+ connState = { status: "connecting", sock, abort: connectAbort };
405
+
406
+ sock.addEventListener(
407
+ "open",
408
+ () => {
409
+ clearTimeout(timeoutId);
410
+ connState = { status: "connected", sock, abort: connectAbort };
411
+ reconnectDelay = INITIAL_DELAY_MS;
412
+ lastSentCols = 0;
413
+ lastSentRows = 0;
414
+ cb?.onOpen();
415
+
416
+ // Send resume immediately so server can respond with its current
417
+ // bytesReceived for this session — we trim/retransmit the outbox
418
+ // when that resumeAck arrives (handled in the message listener).
419
+ sock.send(
420
+ controlFrame({
421
+ type: "resume",
422
+ sessionId,
423
+ sentBytes: bytesSent,
424
+ // Highest absolute line index the client holds (-1 if none). The
425
+ // server replays everything after it, so lines printed while the
426
+ // device slept are backfilled exactly on wake (bug 2), with no
427
+ // duplication because applying a line by absolute index is
428
+ // idempotent. Falls back to -1 (full retained replay) if the
429
+ // consumer wired no getHaveThrough.
430
+ haveThrough: cb?.getHaveThrough?.() ?? -1,
431
+ // Lets the server detect a client built against a different wire
432
+ // revision (e.g. a stale cached bundle) and warn rather than
433
+ // silently mis-decode.
434
+ protocolVersion: WIRE_PROTOCOL_VERSION,
435
+ }),
436
+ );
437
+
438
+ // Begin client-side liveness probing for this socket. Idempotent
439
+ // (clears any prior timer) and resets the activity clock to now.
440
+ startHeartbeat();
441
+ },
442
+ { signal: connectAbort.signal },
443
+ );
444
+
445
+ // Queue for serializing Blob→ArrayBuffer conversion. iOS Safari can
446
+ // deliver binary WS frames as Blob; the conversion is async via
447
+ // .arrayBuffer() and unordered resolution would corrupt screen state.
448
+ // We chain promises so each frame is processed in arrival order.
449
+ let blobChain: Promise<void> = Promise.resolve();
450
+
451
+ sock.addEventListener(
452
+ "message",
453
+ (ev: MessageEvent) => {
454
+ // Any inbound frame — pong, screen update, anything — proves the
455
+ // socket is delivering, so it refreshes the liveness clock before we
456
+ // even decode it. A malformed frame that decodes to null still counts.
457
+ markActivity();
458
+ if (ev.data instanceof ArrayBuffer) {
459
+ handleDecoded(decodeWireBinary(ev.data));
460
+ return;
461
+ }
462
+ if (ev.data instanceof Blob) {
463
+ const blob = ev.data;
464
+ blobChain = blobChain.then(() =>
465
+ blob.arrayBuffer().then((ab) => {
466
+ handleDecoded(decodeWireBinary(ab));
467
+ }),
468
+ );
469
+ return;
470
+ }
471
+ if (typeof ev.data === "string") {
472
+ try {
473
+ handleDecoded(JSON.parse(ev.data) as ServerMessage);
474
+ } catch {
475
+ // ignore malformed text frames
476
+ }
477
+ }
478
+ },
479
+ { signal: connectAbort.signal },
480
+ );
481
+
482
+ function handleDecoded(msg: ServerMessage | null): void {
483
+ if (msg === null) {
484
+ return;
485
+ }
486
+ if (msg.type === "resumeAck") {
487
+ // Server-restart detection. The first resumeAck we see records
488
+ // the epoch; subsequent ones compare to it. A mismatch means the
489
+ // server's process has restarted, which invalidates our local
490
+ // bytesSent/bytesAcked accounting (the new server has no record
491
+ // of the previous boot's input). Reset state and notify the UI.
492
+ const epoch = msg.serverEpoch;
493
+ if (epoch !== undefined && epoch !== 0) {
494
+ if (lastServerEpoch !== null && lastServerEpoch !== epoch) {
495
+ bytesSent = 0;
496
+ bytesAcked = 0;
497
+ outbox.length = 0;
498
+ outboxBytes = 0;
499
+ cb?.onServerRestart?.();
500
+ }
501
+ lastServerEpoch = epoch;
502
+ }
503
+ // Resync guard 8.2.2: hand the server's retained-history bounds to the
504
+ // consumer so it can surface a trim marker when history the client was
505
+ // missing is gone for good. (If the session-forgotten path below resets
506
+ // state, a fresh server's oldest=0 simply reads as "no trim".)
507
+ if (typeof msg.committed === "number" && typeof msg.oldestIndex === "number") {
508
+ cb?.onResumeBounds?.(msg.committed, msg.oldestIndex);
509
+ }
510
+ // Server-doesn't-recognize-this-session safeguard: if the server
511
+ // returns received=0 but the client already had bytesAcked > 0,
512
+ // the server has forgotten our session (idle GC kicked in, or
513
+ // sessionId persistence failed and a reload created a new one).
514
+ // Replaying the outbox would deliver every queued chunk again,
515
+ // causing the iOS tab-suspend duplicate-resend bug. Drop the
516
+ // outbox and notify the UI as if the server restarted — input
517
+ // since the last successful ack is irrecoverable but at least
518
+ // not duplicated. Skip this branch when bytesSent = 0 (genuine
519
+ // first-connect; received=0 is correct).
520
+ if (msg.received === 0 && bytesAcked > 0) {
521
+ bytesSent = 0;
522
+ bytesAcked = 0;
523
+ outbox.length = 0;
524
+ outboxBytes = 0;
525
+ cb?.onServerRestart?.();
526
+ return;
527
+ }
528
+ applyAck(msg.received);
529
+ retransmitOutbox();
530
+ return;
531
+ }
532
+ if (msg.type === "modes") {
533
+ modes.setModes(
534
+ msg.bracketedPaste,
535
+ msg.applicationCursor,
536
+ msg.mouseSGR,
537
+ msg.focusReporting,
538
+ msg.mouseMode,
539
+ msg.applicationKeypad,
540
+ msg.reverseVideo,
541
+ );
542
+ if (typeof msg.inputAck === "number") {
543
+ applyAck(msg.inputAck);
544
+ }
545
+ // Notify the UI so it can react to mode changes (e.g. clear
546
+ // scrollback on alt-screen entry — handled by the caller).
547
+ cb?.onMessage(msg);
548
+ return;
549
+ }
550
+ if (typeof msg.inputAck === "number") {
551
+ applyAck(msg.inputAck);
552
+ }
553
+ cb?.onMessage(msg);
554
+ }
555
+
556
+ sock.addEventListener(
557
+ "close",
558
+ () => {
559
+ // Only the active sock's close should drive reconnect logic; an
560
+ // already-superseded sock has been aborted and this listener
561
+ // wouldn't fire (signal removes it). The check stays as a belt-
562
+ // and-braces guard in case the abort hasn't propagated yet.
563
+ if (connState.status !== "connecting" && connState.status !== "connected") {
564
+ return;
565
+ }
566
+ if (connState.sock !== sock) {
567
+ return;
568
+ }
569
+ stopHeartbeat();
570
+ connState = { status: "disconnected" };
571
+ cb?.onClose();
572
+ scheduleReconnect();
573
+ },
574
+ { signal: connectAbort.signal },
575
+ );
576
+
577
+ sock.addEventListener(
578
+ "error",
579
+ () => {
580
+ /* no-op: prevents unhandled error */
581
+ },
582
+ { signal: connectAbort.signal },
583
+ );
584
+ }
package/src/index.ts ADDED
@@ -0,0 +1,10 @@
1
+ export * from "./types.js";
2
+ export * as render from "./render.js";
3
+ export * as keyboard from "./keyboard.js";
4
+ export * as scroll from "./scroll.js";
5
+ export * as modes from "./modes.js";
6
+ export * as mouse from "./mouse.js";
7
+ export * as connection from "./connection.js";
8
+ export { decodeWireBinary } from "./wire-binary.js";
9
+ export { controlFrame, CONTROL_FRAME_PREFIX } from "./wire.js";
10
+ export { wsURL } from "./wsurl.js";