@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.
- package/LICENSE +692 -0
- package/README.md +97 -0
- package/jsr.json +18 -0
- package/package.json +42 -0
- package/src/connection.ts +584 -0
- package/src/index.ts +10 -0
- package/src/keyboard.ts +630 -0
- package/src/modes.ts +95 -0
- package/src/mouse.ts +203 -0
- package/src/reconnect.ts +64 -0
- package/src/render.ts +823 -0
- package/src/scroll.ts +103 -0
- package/src/store.ts +389 -0
- package/src/types.ts +165 -0
- package/src/wire-binary.ts +238 -0
- package/src/wire.ts +36 -0
- package/src/wsurl.ts +19 -0
|
@@ -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";
|