@boxes-dev/dvb 1.0.95 → 1.0.96

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,908 @@
1
+ import { logger } from "../logger.js";
2
+ import { BROWSER_OPEN_OSC_PREFIX, createOscMonitor } from "./osc.js";
3
+ const HEARTBEAT_INTERVAL_MS = 4000;
4
+ const INPUT_PROBE_TIMEOUT_MS = 1000;
5
+ const STREAM_STALL_PROBE_MISS_THRESHOLD = 2;
6
+ const CONNECT_OPEN_TIMEOUT_MS = 20_000;
7
+ const WS_OPEN_STATE = 1;
8
+ const MODAL_DISCONNECT_FILTER_CHUNKS = 8;
9
+ export const createScrollbackEraseFilter = () => {
10
+ // Strip only scrollback-erase control sequences:
11
+ // ESC [ 3 J
12
+ // CSI 3 J (C1 single-byte CSI = 0x9b)
13
+ let state = 0;
14
+ const flushPending = (output) => {
15
+ if (state === 1) {
16
+ output.push(0x1b);
17
+ }
18
+ else if (state === 2) {
19
+ output.push(0x1b, 0x5b);
20
+ }
21
+ else if (state === 3) {
22
+ output.push(0x1b, 0x5b, 0x33);
23
+ }
24
+ else if (state === 10) {
25
+ output.push(0x9b);
26
+ }
27
+ else if (state === 11) {
28
+ output.push(0x9b, 0x33);
29
+ }
30
+ state = 0;
31
+ };
32
+ const pushByte = (output, byte) => {
33
+ if (state === 0) {
34
+ if (byte === 0x1b) {
35
+ state = 1;
36
+ return;
37
+ }
38
+ if (byte === 0x9b) {
39
+ state = 10;
40
+ return;
41
+ }
42
+ output.push(byte);
43
+ return;
44
+ }
45
+ if (state === 1) {
46
+ if (byte === 0x5b) {
47
+ state = 2;
48
+ return;
49
+ }
50
+ output.push(0x1b);
51
+ state = 0;
52
+ pushByte(output, byte);
53
+ return;
54
+ }
55
+ if (state === 2) {
56
+ if (byte === 0x33) {
57
+ state = 3;
58
+ return;
59
+ }
60
+ output.push(0x1b, 0x5b);
61
+ state = 0;
62
+ pushByte(output, byte);
63
+ return;
64
+ }
65
+ if (state === 3) {
66
+ if (byte === 0x4a) {
67
+ state = 0;
68
+ return;
69
+ }
70
+ output.push(0x1b, 0x5b, 0x33);
71
+ state = 0;
72
+ pushByte(output, byte);
73
+ return;
74
+ }
75
+ if (state === 10) {
76
+ if (byte === 0x33) {
77
+ state = 11;
78
+ return;
79
+ }
80
+ output.push(0x9b);
81
+ state = 0;
82
+ pushByte(output, byte);
83
+ return;
84
+ }
85
+ if (state === 11) {
86
+ if (byte === 0x4a) {
87
+ state = 0;
88
+ return;
89
+ }
90
+ output.push(0x9b, 0x33);
91
+ state = 0;
92
+ pushByte(output, byte);
93
+ }
94
+ };
95
+ return {
96
+ reset() {
97
+ state = 0;
98
+ },
99
+ transform(chunk) {
100
+ const bytes = Buffer.from(chunk ?? Buffer.alloc(0));
101
+ if (bytes.length === 0) {
102
+ return bytes;
103
+ }
104
+ const output = [];
105
+ for (const byte of bytes) {
106
+ pushByte(output, byte);
107
+ }
108
+ return output.length === 0 ? Buffer.alloc(0) : Buffer.from(output);
109
+ },
110
+ flush() {
111
+ const output = [];
112
+ flushPending(output);
113
+ return output.length === 0 ? Buffer.alloc(0) : Buffer.from(output);
114
+ },
115
+ };
116
+ };
117
+ export const createInitialAttachClearFilter = () => {
118
+ const patterns = [
119
+ Buffer.from("\u001b[H\u001b[J", "latin1"),
120
+ Buffer.from("\u001b[H\u001b[2J", "latin1"),
121
+ Buffer.from("\u001b[2J\u001b[H", "latin1"),
122
+ Buffer.from("\u001b[H", "latin1"),
123
+ Buffer.from("\u001b[2J", "latin1"),
124
+ Buffer.from("\u001b[J", "latin1"),
125
+ Buffer.from("\u001b[?1049h", "latin1"),
126
+ Buffer.from("\u001b[?1048h", "latin1"),
127
+ Buffer.from("\u001b[?1047h", "latin1"),
128
+ Buffer.from("\u001b[?47h", "latin1"),
129
+ Buffer.from("\u001b[?1h", "latin1"),
130
+ Buffer.from("\u001b=", "latin1"),
131
+ Buffer.from("\x9bH", "latin1"),
132
+ Buffer.from("\x9b2J", "latin1"),
133
+ Buffer.from("\x9bJ", "latin1"),
134
+ Buffer.from("\x9b?1049h", "latin1"),
135
+ Buffer.from("\x9b?1048h", "latin1"),
136
+ Buffer.from("\x9b?1047h", "latin1"),
137
+ Buffer.from("\x9b?47h", "latin1"),
138
+ Buffer.from("\x9b?1h", "latin1"),
139
+ ];
140
+ const maxPatternLength = patterns.reduce((max, pattern) => Math.max(max, pattern.length), 0);
141
+ const STARTUP_FILTER_CHUNKS = 6;
142
+ let enabled = true;
143
+ let pending = Buffer.alloc(0);
144
+ let chunksRemaining = STARTUP_FILTER_CHUNKS;
145
+ const startsWithPatternPrefix = (bytes) => patterns.some((pattern) => {
146
+ if (bytes.length > pattern.length) {
147
+ return false;
148
+ }
149
+ return pattern.subarray(0, bytes.length).equals(bytes);
150
+ });
151
+ const stripLeadingPatterns = (bytes) => {
152
+ let remaining = bytes;
153
+ let removed = true;
154
+ while (removed && remaining.length > 0) {
155
+ removed = false;
156
+ for (const pattern of patterns) {
157
+ if (remaining.length >= pattern.length) {
158
+ const candidate = remaining.subarray(0, pattern.length);
159
+ if (candidate.equals(pattern)) {
160
+ remaining = remaining.subarray(pattern.length);
161
+ removed = true;
162
+ break;
163
+ }
164
+ }
165
+ }
166
+ }
167
+ return remaining;
168
+ };
169
+ const stripInlineArtifacts = (bytes) => {
170
+ if (bytes.length === 0)
171
+ return bytes;
172
+ let text = bytes.toString("latin1");
173
+ // Keep prompt redraw semantics without full-screen jumps by converting
174
+ // cursor-home moves into CR, then dropping destructive clear sequences.
175
+ text = text.replace(/\x1b\[[0-9;]*H/g, "\r");
176
+ text = text.replace(/\x9b[0-9;]*H/g, "\r");
177
+ text = text.replace(/\x1b\]([^\x07\x1b]*)(\x07|\x1b\\)/g, (match, payload) => payload.startsWith(BROWSER_OPEN_OSC_PREFIX) ? match : "");
178
+ const artifactPatterns = [
179
+ /\x01/g,
180
+ /\x1b\[\?1049h/g,
181
+ /\x1b\[\?1048h/g,
182
+ /\x1b\[\?1047h/g,
183
+ /\x1b\[\?47h/g,
184
+ /\x1b\[\?1h/g,
185
+ /\x1b\[\?2004h/g,
186
+ /\x1b=/g,
187
+ /\x9b\?1049h/g,
188
+ /\x9b\?1048h/g,
189
+ /\x9b\?1047h/g,
190
+ /\x9b\?47h/g,
191
+ /\x9b\?1h/g,
192
+ /\x9b\?2004h/g,
193
+ /\x1b\[[0-9;?]*J/g,
194
+ /\x9b[0-9;?]*J/g,
195
+ ];
196
+ for (const pattern of artifactPatterns) {
197
+ text = text.replace(pattern, "");
198
+ }
199
+ text = text.replace(/\r{2,}/g, "\r");
200
+ if (/^\r+$/.test(text)) {
201
+ return Buffer.alloc(0);
202
+ }
203
+ return Buffer.from(text, "latin1");
204
+ };
205
+ return {
206
+ reset() {
207
+ enabled = true;
208
+ pending = Buffer.alloc(0);
209
+ chunksRemaining = STARTUP_FILTER_CHUNKS;
210
+ },
211
+ transform(chunk) {
212
+ const bytes = Buffer.from(chunk ?? Buffer.alloc(0));
213
+ if (bytes.length === 0) {
214
+ return bytes;
215
+ }
216
+ if (!enabled) {
217
+ return bytes;
218
+ }
219
+ let work = pending.length > 0
220
+ ? Buffer.concat([pending, bytes])
221
+ : Buffer.from(bytes);
222
+ pending = Buffer.alloc(0);
223
+ work = stripLeadingPatterns(work);
224
+ work = stripInlineArtifacts(work);
225
+ chunksRemaining = Math.max(0, chunksRemaining - 1);
226
+ if (work.length === 0) {
227
+ if (chunksRemaining === 0) {
228
+ enabled = false;
229
+ }
230
+ return Buffer.alloc(0);
231
+ }
232
+ const holdBytes = Math.min(work.length, Math.max(1, maxPatternLength - 1));
233
+ if (holdBytes > 0) {
234
+ const prefix = work.subarray(0, holdBytes);
235
+ if (prefix.length === work.length && startsWithPatternPrefix(prefix)) {
236
+ pending = Buffer.from(prefix);
237
+ return Buffer.alloc(0);
238
+ }
239
+ }
240
+ if (chunksRemaining === 0) {
241
+ enabled = false;
242
+ }
243
+ return work;
244
+ },
245
+ flush() {
246
+ const out = pending;
247
+ pending = Buffer.alloc(0);
248
+ enabled = false;
249
+ chunksRemaining = 0;
250
+ return out;
251
+ },
252
+ };
253
+ };
254
+ export const createModalTtyOutputFilter = () => {
255
+ const initialAttachClearFilter = createInitialAttachClearFilter();
256
+ const scrollbackEraseFilter = createScrollbackEraseFilter();
257
+ return {
258
+ reset() {
259
+ initialAttachClearFilter.reset?.();
260
+ scrollbackEraseFilter.reset?.();
261
+ },
262
+ transform(chunk) {
263
+ const initialFiltered = initialAttachClearFilter.transform(chunk);
264
+ if (initialFiltered.length === 0) {
265
+ return Buffer.alloc(0);
266
+ }
267
+ return scrollbackEraseFilter.transform(initialFiltered);
268
+ },
269
+ flush() {
270
+ const pendingInitial = initialAttachClearFilter.flush();
271
+ const pendingFiltered = pendingInitial.length > 0
272
+ ? scrollbackEraseFilter.transform(pendingInitial)
273
+ : Buffer.alloc(0);
274
+ const pendingScrollback = scrollbackEraseFilter.flush();
275
+ if (pendingFiltered.length === 0) {
276
+ return pendingScrollback;
277
+ }
278
+ if (pendingScrollback.length === 0) {
279
+ return pendingFiltered;
280
+ }
281
+ return Buffer.concat([pendingFiltered, pendingScrollback]);
282
+ },
283
+ };
284
+ };
285
+ export const stripModalDisconnectArtifacts = (chunk) => {
286
+ if (chunk.length === 0)
287
+ return chunk;
288
+ let text = chunk.toString("latin1");
289
+ const artifactPatterns = [
290
+ /\x01/g,
291
+ /\x03\x00/g,
292
+ /\x1b\[\?1000l/g,
293
+ /\x1b\[\?1002l/g,
294
+ /\x1b\[\?1003l/g,
295
+ /\x1b\[\?1004l/g,
296
+ /\x1b\[\?1005l/g,
297
+ /\x1b\[\?1006l/g,
298
+ /\x1b\[\?2004l/g,
299
+ /\x1b\[\?1049l/g,
300
+ /\x1b\[\?1048l/g,
301
+ /\x1b\[\?1047l/g,
302
+ /\x1b\[\?47l/g,
303
+ /\x1b\[\?25h/g,
304
+ /\x1b\[<u/g,
305
+ /\x1b\[>4;0m/g,
306
+ /\x1b\[\?1l/g,
307
+ /\x1b>/g,
308
+ ];
309
+ for (const pattern of artifactPatterns) {
310
+ text = text.replace(pattern, "");
311
+ }
312
+ return Buffer.from(text, "latin1");
313
+ };
314
+ export const streamExecSession = async (ws, options) => new Promise((resolve, reject) => {
315
+ let exitCode = null;
316
+ let resolved = false;
317
+ let opened = false;
318
+ let closeCode;
319
+ let pendingResize = null;
320
+ let lastResize = null;
321
+ const pendingInput = [];
322
+ let lastSeenAt = Date.now();
323
+ let lastStreamActivityAt = Date.now();
324
+ let heartbeatTimer = null;
325
+ let inputProbeTimer = null;
326
+ let openTimer = null;
327
+ let inputProbeStamp = 0;
328
+ let inputProbeStreamStamp = 0;
329
+ let streamProbeMisses = 0;
330
+ let disconnectNoted = false;
331
+ let errorMessage;
332
+ const stdin = process.stdin;
333
+ const stdout = process.stdout;
334
+ const stderr = process.stderr;
335
+ const latency = options.latency;
336
+ const oscMonitor = createOscMonitor();
337
+ const ttyOutputFilter = options.ttyOutputFilter;
338
+ let disconnectFilterChunksRemaining = 0;
339
+ const armDisconnectFilter = () => {
340
+ if (!options.stripDisconnectArtifacts)
341
+ return;
342
+ disconnectFilterChunksRemaining = Math.max(disconnectFilterChunksRemaining, MODAL_DISCONNECT_FILTER_CHUNKS);
343
+ };
344
+ const applyDisconnectFilter = (chunk) => {
345
+ if (!options.stripDisconnectArtifacts)
346
+ return chunk;
347
+ if (disconnectFilterChunksRemaining <= 0)
348
+ return chunk;
349
+ disconnectFilterChunksRemaining -= 1;
350
+ return stripModalDisconnectArtifacts(chunk);
351
+ };
352
+ const initialInput = typeof options.initialInput === "string"
353
+ ? Buffer.from(options.initialInput, "utf8")
354
+ : options.initialInput;
355
+ const supportsResizeControlMessages = options.supportsResizeControlMessages ?? true;
356
+ if (options.stdin && initialInput && initialInput.length > 0) {
357
+ pendingInput.push(initialInput);
358
+ }
359
+ const markAlive = () => {
360
+ lastSeenAt = Date.now();
361
+ };
362
+ const markStreamAlive = () => {
363
+ const now = Date.now();
364
+ lastSeenAt = now;
365
+ lastStreamActivityAt = now;
366
+ streamProbeMisses = 0;
367
+ };
368
+ const noteDisconnect = () => {
369
+ options.onDisconnectNotice?.();
370
+ };
371
+ const recordDisconnect = (kind) => {
372
+ if (disconnectNoted)
373
+ return;
374
+ disconnectNoted = true;
375
+ const details = { kind, opened };
376
+ if (typeof closeCode === "number") {
377
+ details.closeCode = closeCode;
378
+ }
379
+ latency?.noteDisconnect(details);
380
+ };
381
+ const finish = (code) => {
382
+ if (resolved)
383
+ return;
384
+ resolved = true;
385
+ cleanup();
386
+ resolve({ kind: "exit", code });
387
+ };
388
+ const finishDetach = () => {
389
+ if (resolved)
390
+ return;
391
+ resolved = true;
392
+ cleanup();
393
+ try {
394
+ ws.close();
395
+ }
396
+ catch {
397
+ // ignore close failures
398
+ }
399
+ resolve({ kind: "detach" });
400
+ };
401
+ const writeTtyOutput = (chunk) => {
402
+ if (chunk.length === 0)
403
+ return;
404
+ const { output, browserUrls } = oscMonitor.write(chunk);
405
+ if (output.length > 0) {
406
+ stdout.write(output);
407
+ }
408
+ for (const url of browserUrls) {
409
+ if (!options.openBrowser(url)) {
410
+ stderr.write(`Failed to open browser. Visit: ${url}\r\n`);
411
+ }
412
+ }
413
+ };
414
+ const handleBinary = (buffer) => {
415
+ if (buffer.length === 0)
416
+ return;
417
+ if (options.tty) {
418
+ const rawBuffer = Buffer.from(buffer);
419
+ options.onOutputChunk?.(rawBuffer);
420
+ options.onOutput?.();
421
+ const filteredBuffer = ttyOutputFilter
422
+ ? ttyOutputFilter.transform(rawBuffer)
423
+ : rawBuffer;
424
+ writeTtyOutput(applyDisconnectFilter(filteredBuffer));
425
+ return;
426
+ }
427
+ const streamId = buffer[0];
428
+ const payload = buffer.subarray(1);
429
+ if (streamId === 1) {
430
+ if (payload.length > 0) {
431
+ options.onOutputChunk?.(payload);
432
+ }
433
+ if (payload.length > 0)
434
+ options.onOutput?.();
435
+ stdout.write(Buffer.from(payload));
436
+ }
437
+ if (streamId === 2) {
438
+ if (payload.length > 0) {
439
+ options.onOutputChunk?.(payload);
440
+ }
441
+ if (payload.length > 0)
442
+ options.onOutput?.();
443
+ stderr.write(Buffer.from(payload));
444
+ }
445
+ if (streamId === 3 && payload.length > 0) {
446
+ armDisconnectFilter();
447
+ exitCode = payload[0] ?? 0;
448
+ ws.close();
449
+ }
450
+ };
451
+ const handleText = (data) => {
452
+ try {
453
+ const payload = JSON.parse(data);
454
+ if (payload.type === "session_info") {
455
+ const rawId = payload.session_id ?? payload.id ?? payload.sessionId;
456
+ if (rawId !== undefined) {
457
+ options.onSessionInfo?.(String(rawId));
458
+ }
459
+ return;
460
+ }
461
+ if (payload.type === "exit" && typeof payload.exit_code === "number") {
462
+ exitCode = payload.exit_code;
463
+ ws.close();
464
+ return;
465
+ }
466
+ if (payload.type === "port_opened" || payload.type === "port_closed") {
467
+ const port = Number(payload.port);
468
+ if (Number.isFinite(port)) {
469
+ const event = { type: payload.type, port };
470
+ if (typeof payload.pid === "number")
471
+ event.pid = payload.pid;
472
+ if (typeof payload.address === "string") {
473
+ event.address = payload.address;
474
+ }
475
+ options.onPortEvent?.(event);
476
+ }
477
+ return;
478
+ }
479
+ if (payload.type === "tty_replay_start") {
480
+ options.onReplayStart?.();
481
+ return;
482
+ }
483
+ if (payload.type === "tty_replay_done") {
484
+ if (options.tty && ttyOutputFilter) {
485
+ writeTtyOutput(applyDisconnectFilter(ttyOutputFilter.flush()));
486
+ }
487
+ options.onReplayDone?.();
488
+ return;
489
+ }
490
+ }
491
+ catch {
492
+ // fall through
493
+ }
494
+ if (data.length > 0) {
495
+ options.onOutputChunk?.(Buffer.from(data, "utf8"));
496
+ }
497
+ options.onOutput?.();
498
+ if (options.tty) {
499
+ if (data.includes("logout")) {
500
+ armDisconnectFilter();
501
+ }
502
+ const dataBuffer = Buffer.from(data, "utf8");
503
+ const filtered = ttyOutputFilter
504
+ ? ttyOutputFilter.transform(dataBuffer)
505
+ : dataBuffer;
506
+ writeTtyOutput(applyDisconnectFilter(filtered));
507
+ }
508
+ else {
509
+ stdout.write(data);
510
+ }
511
+ };
512
+ const onMessage = (event) => {
513
+ markStreamAlive();
514
+ const data = event.data;
515
+ if (typeof data === "string") {
516
+ handleText(data);
517
+ return;
518
+ }
519
+ if (data instanceof ArrayBuffer) {
520
+ handleBinary(new Uint8Array(data));
521
+ return;
522
+ }
523
+ if (data instanceof Uint8Array) {
524
+ handleBinary(data);
525
+ return;
526
+ }
527
+ if (typeof Blob !== "undefined" && data instanceof Blob) {
528
+ data
529
+ .arrayBuffer()
530
+ .then((buffer) => handleBinary(new Uint8Array(buffer)))
531
+ .catch(() => {
532
+ // ignore blob decode failure
533
+ });
534
+ }
535
+ };
536
+ const sendResize = () => {
537
+ if (!options.tty)
538
+ return;
539
+ if (!supportsResizeControlMessages)
540
+ return;
541
+ const { cols, rows } = options.resolveTtySize();
542
+ if (lastResize && lastResize.cols === cols && lastResize.rows === rows) {
543
+ return;
544
+ }
545
+ if (!opened) {
546
+ pendingResize = { cols, rows };
547
+ return;
548
+ }
549
+ ws.send(JSON.stringify({ type: "resize", cols, rows }));
550
+ lastResize = { cols, rows };
551
+ };
552
+ const sendInput = (chunk) => {
553
+ if (chunk.length > 0) {
554
+ options.onInputChunk?.(chunk);
555
+ }
556
+ if (!latency) {
557
+ ws.send(chunk);
558
+ return;
559
+ }
560
+ const bufferedAmount = typeof ws.bufferedAmount === "number" ? ws.bufferedAmount : undefined;
561
+ const onSent = latency.noteSend(chunk.byteLength, bufferedAmount);
562
+ try {
563
+ ws.send(chunk, onSent);
564
+ }
565
+ catch (error) {
566
+ onSent(error instanceof Error ? error : new Error(String(error)));
567
+ throw error;
568
+ }
569
+ };
570
+ const summarizeInputChunk = (chunk) => ({
571
+ length: chunk.length,
572
+ hasCtrlC: chunk.includes(0x03),
573
+ hasCtrlD: chunk.includes(0x04),
574
+ hasCr: chunk.includes(0x0d),
575
+ hasLf: chunk.includes(0x0a),
576
+ });
577
+ const onStdin = (chunk) => {
578
+ if (options.tty && options.isRawCtrlCChunk(chunk)) {
579
+ const handled = options.onRawCtrlC?.() ?? false;
580
+ if (handled) {
581
+ return;
582
+ }
583
+ }
584
+ if (!opened) {
585
+ pendingInput.push(chunk);
586
+ logger.debug("connect_stdin_buffered_preopen", {
587
+ ...summarizeInputChunk(chunk),
588
+ pendingInputCount: pendingInput.length,
589
+ });
590
+ return;
591
+ }
592
+ if (options.tty && chunk.length === 1 && chunk[0] === 0x1c) {
593
+ stderr.write("\r\nDetached.\r\n");
594
+ logger.info("connect_stdin_detach_key", {
595
+ opened,
596
+ readyState: ws.readyState,
597
+ });
598
+ finishDetach();
599
+ return;
600
+ }
601
+ if (options.tty && chunk.length === 1 && chunk[0] === 0x04) {
602
+ armDisconnectFilter();
603
+ }
604
+ if (typeof ws.readyState === "number" &&
605
+ ws.readyState !== WS_OPEN_STATE) {
606
+ logger.warn("connect_stdin_socket_not_open", {
607
+ readyState: ws.readyState,
608
+ opened,
609
+ ...summarizeInputChunk(chunk),
610
+ });
611
+ noteDisconnect();
612
+ forceClose();
613
+ onError();
614
+ return;
615
+ }
616
+ const ageMs = Date.now() - lastSeenAt;
617
+ const hasRecoveryKey = chunk.includes(0x03) || // Ctrl+C
618
+ chunk.includes(0x04) || // Ctrl+D
619
+ chunk.includes(0x0d) || // Enter (CR)
620
+ chunk.includes(0x0a); // Enter (LF)
621
+ if (ageMs > HEARTBEAT_INTERVAL_MS ||
622
+ (streamProbeMisses > 0 && hasRecoveryKey)) {
623
+ startInputProbe();
624
+ }
625
+ if (hasRecoveryKey) {
626
+ logger.info("connect_stdin_recovery_key", {
627
+ ...summarizeInputChunk(chunk),
628
+ ageMs,
629
+ streamProbeMisses,
630
+ readyState: ws.readyState,
631
+ });
632
+ }
633
+ try {
634
+ sendInput(chunk);
635
+ }
636
+ catch {
637
+ logger.warn("connect_stdin_send_failed", {
638
+ ...summarizeInputChunk(chunk),
639
+ ageMs,
640
+ readyState: ws.readyState,
641
+ });
642
+ noteDisconnect();
643
+ forceClose();
644
+ onError();
645
+ }
646
+ };
647
+ const startInputProbe = () => {
648
+ if (inputProbeTimer || typeof ws.ping !== "function")
649
+ return;
650
+ inputProbeStamp = lastSeenAt;
651
+ inputProbeStreamStamp = lastStreamActivityAt;
652
+ try {
653
+ ws.ping?.();
654
+ latency?.notePingSent("input_probe");
655
+ }
656
+ catch {
657
+ // ignore ping failures
658
+ }
659
+ inputProbeTimer = setTimeout(() => {
660
+ inputProbeTimer = null;
661
+ if (resolved)
662
+ return;
663
+ if (lastSeenAt === inputProbeStamp) {
664
+ streamProbeMisses = 0;
665
+ latency?.noteInputProbeTimeout();
666
+ noteDisconnect();
667
+ forceClose();
668
+ onError();
669
+ return;
670
+ }
671
+ if (lastStreamActivityAt === inputProbeStreamStamp) {
672
+ streamProbeMisses += 1;
673
+ if (streamProbeMisses >= STREAM_STALL_PROBE_MISS_THRESHOLD) {
674
+ latency?.noteInputProbeTimeout();
675
+ noteDisconnect();
676
+ forceClose();
677
+ onError();
678
+ }
679
+ return;
680
+ }
681
+ }, INPUT_PROBE_TIMEOUT_MS);
682
+ };
683
+ const stopInputProbe = () => {
684
+ if (inputProbeTimer) {
685
+ clearTimeout(inputProbeTimer);
686
+ inputProbeTimer = null;
687
+ }
688
+ };
689
+ const stopOpenTimer = () => {
690
+ if (openTimer) {
691
+ clearTimeout(openTimer);
692
+ openTimer = null;
693
+ }
694
+ };
695
+ const onPong = () => {
696
+ markAlive();
697
+ latency?.notePong();
698
+ };
699
+ const forceClose = () => {
700
+ if (typeof ws.terminate === "function") {
701
+ ws.terminate();
702
+ return;
703
+ }
704
+ ws.close();
705
+ };
706
+ const startHeartbeat = () => {
707
+ if (typeof ws.ping !== "function")
708
+ return;
709
+ markAlive();
710
+ if (typeof ws.on === "function") {
711
+ ws.on("pong", onPong);
712
+ }
713
+ heartbeatTimer = setInterval(() => {
714
+ if (resolved)
715
+ return;
716
+ try {
717
+ ws.ping?.();
718
+ latency?.notePingSent("heartbeat");
719
+ }
720
+ catch {
721
+ // ignore ping failures
722
+ }
723
+ }, HEARTBEAT_INTERVAL_MS);
724
+ };
725
+ const stopHeartbeat = () => {
726
+ if (heartbeatTimer) {
727
+ clearInterval(heartbeatTimer);
728
+ heartbeatTimer = null;
729
+ }
730
+ if (typeof ws.off === "function") {
731
+ ws.off("pong", onPong);
732
+ }
733
+ else if (typeof ws.removeListener === "function") {
734
+ ws.removeListener("pong", onPong);
735
+ }
736
+ };
737
+ const startInput = () => {
738
+ if (!options.stdin)
739
+ return;
740
+ let rawModeEnabled = false;
741
+ if (options.tty && stdin.isTTY) {
742
+ try {
743
+ stdin.setRawMode(true);
744
+ rawModeEnabled = true;
745
+ }
746
+ catch {
747
+ logger.warn("connect_stdin_rawmode_enable_failed");
748
+ // ignore raw mode failure
749
+ }
750
+ }
751
+ logger.info("connect_stdin_start", {
752
+ tty: options.tty,
753
+ stdinIsTty: stdin.isTTY,
754
+ rawModeEnabled,
755
+ });
756
+ stdin.resume();
757
+ stdin.on("data", onStdin);
758
+ process.on("SIGWINCH", sendResize);
759
+ };
760
+ const stopInput = () => {
761
+ if (!options.stdin)
762
+ return;
763
+ stdin.removeListener("data", onStdin);
764
+ process.removeListener("SIGWINCH", sendResize);
765
+ stdin.pause();
766
+ let rawModeRestored = false;
767
+ if (options.tty && stdin.isTTY) {
768
+ try {
769
+ stdin.setRawMode(false);
770
+ rawModeRestored = true;
771
+ }
772
+ catch {
773
+ logger.warn("connect_stdin_rawmode_restore_failed");
774
+ // ignore restore errors
775
+ }
776
+ }
777
+ logger.info("connect_stdin_stop", {
778
+ tty: options.tty,
779
+ stdinIsTty: stdin.isTTY,
780
+ rawModeRestored,
781
+ });
782
+ };
783
+ const flushPending = () => {
784
+ if (!opened)
785
+ return;
786
+ if (pendingResize) {
787
+ ws.send(JSON.stringify({
788
+ type: "resize",
789
+ cols: pendingResize.cols,
790
+ rows: pendingResize.rows,
791
+ }));
792
+ lastResize = pendingResize;
793
+ pendingResize = null;
794
+ }
795
+ if (pendingInput.length > 0) {
796
+ for (const chunk of pendingInput) {
797
+ sendInput(chunk);
798
+ }
799
+ pendingInput.length = 0;
800
+ }
801
+ };
802
+ const cleanup = () => {
803
+ if (options.tty && ttyOutputFilter) {
804
+ writeTtyOutput(ttyOutputFilter.flush());
805
+ }
806
+ ws.removeEventListener("message", onMessage);
807
+ ws.removeEventListener("close", onClose);
808
+ ws.removeEventListener("error", onError);
809
+ ws.removeEventListener("open", onOpen);
810
+ stopInputProbe();
811
+ stopOpenTimer();
812
+ stopHeartbeat();
813
+ stopInput();
814
+ };
815
+ const onClose = (event) => {
816
+ closeCode = typeof event?.code === "number" ? event.code : closeCode;
817
+ logger.info("connect_ws_close", {
818
+ opened,
819
+ closeCode,
820
+ exitCode,
821
+ });
822
+ recordDisconnect("close");
823
+ if (exitCode !== null) {
824
+ finish(exitCode);
825
+ return;
826
+ }
827
+ if (closeCode === 1000) {
828
+ finish(0);
829
+ return;
830
+ }
831
+ const result = {
832
+ kind: "disconnect",
833
+ opened,
834
+ };
835
+ if (closeCode !== undefined)
836
+ result.closeCode = closeCode;
837
+ cleanup();
838
+ resolve(result);
839
+ };
840
+ const onError = (event) => {
841
+ if (resolved)
842
+ return;
843
+ const raw = event?.data;
844
+ if (raw instanceof Error && raw.message.trim()) {
845
+ errorMessage = raw.message.trim();
846
+ }
847
+ else if (typeof raw === "string" && raw.trim()) {
848
+ errorMessage = raw.trim();
849
+ }
850
+ else if (raw &&
851
+ typeof raw === "object" &&
852
+ "message" in raw &&
853
+ typeof raw.message === "string" &&
854
+ raw.message.trim()) {
855
+ errorMessage = raw.message.trim();
856
+ }
857
+ recordDisconnect("error");
858
+ const result = {
859
+ kind: "disconnect",
860
+ opened,
861
+ };
862
+ if (closeCode !== undefined)
863
+ result.closeCode = closeCode;
864
+ if (errorMessage)
865
+ result.errorMessage = errorMessage;
866
+ logger.warn("connect_ws_error", {
867
+ opened,
868
+ closeCode,
869
+ errorMessage,
870
+ });
871
+ cleanup();
872
+ resolve(result);
873
+ };
874
+ const onOpen = () => {
875
+ stopOpenTimer();
876
+ opened = true;
877
+ markAlive();
878
+ latency?.noteConnectOpen();
879
+ logger.info("connect_ws_open", {
880
+ tty: options.tty,
881
+ stdin: options.stdin,
882
+ });
883
+ flushPending();
884
+ startInput();
885
+ startHeartbeat();
886
+ options.onOpen?.();
887
+ };
888
+ ws.binaryType = "arraybuffer";
889
+ ws.addEventListener("message", onMessage);
890
+ ws.addEventListener("close", onClose);
891
+ ws.addEventListener("error", onError);
892
+ ws.addEventListener("open", onOpen);
893
+ sendResize();
894
+ const openTimeoutMs = options.openTimeoutMs ?? CONNECT_OPEN_TIMEOUT_MS;
895
+ if (openTimeoutMs > 0) {
896
+ openTimer = setTimeout(() => {
897
+ if (resolved || opened)
898
+ return;
899
+ noteDisconnect();
900
+ forceClose();
901
+ onError({
902
+ data: new Error(`Timed out waiting for session connection after ${openTimeoutMs}ms.`),
903
+ });
904
+ }, openTimeoutMs);
905
+ openTimer.unref();
906
+ }
907
+ });
908
+ //# sourceMappingURL=sessionProtocol.js.map