@myagentroam/marmgr 0.9.112

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/direct.mjs ADDED
@@ -0,0 +1,769 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { EventEmitter } from 'node:events';
3
+
4
+ export const DIRECT_PREPARE_TIMEOUT_MS = 5000;
5
+ export const DIRECT_SETUP_TIMEOUT_MS = 15000;
6
+ export const DIRECT_HEARTBEAT_MS = 10000;
7
+
8
+ export class DirectFallbackError extends Error {
9
+ constructor(code = 'DIRECT_UNAVAILABLE') {
10
+ super(code);
11
+ this.name = 'DirectFallbackError';
12
+ this.code = code;
13
+ }
14
+ }
15
+
16
+ export class DirectUnsafeError extends Error {
17
+ constructor(code = 'DIRECT_CANCEL_UNCONFIRMED') {
18
+ super(code);
19
+ this.name = 'DirectUnsafeError';
20
+ this.code = code;
21
+ }
22
+ }
23
+
24
+ export async function openDirectTerminal({
25
+ client,
26
+ WebSocketClass,
27
+ nodeId,
28
+ terminalId,
29
+ afterSequence = 0,
30
+ timeoutMs = DIRECT_SETUP_TIMEOUT_MS,
31
+ heartbeatMs = DIRECT_HEARTBEAT_MS,
32
+ peerConnectionFactory
33
+ }) {
34
+ if (typeof client.getTerminalManifest !== 'function')
35
+ throw new DirectFallbackError('DIRECT_MANIFEST_UNAVAILABLE');
36
+
37
+ let live;
38
+ let peer;
39
+ let bootstrapChannel;
40
+ let terminalChannel;
41
+ let transportSessionId;
42
+ let terminalSessionId;
43
+ let transport;
44
+ let unsafeFallback = false;
45
+ let transportControlCleanup;
46
+ let transportNegotiationActive = true;
47
+ let cleanupNative;
48
+ try {
49
+ const manifest = await client.getTerminalManifest(nodeId);
50
+ if (
51
+ typeof manifest?.nodeGeneration !== 'string' ||
52
+ manifest.nodeGeneration.length < 8 ||
53
+ manifest.terminalDirect?.apiVersion !== 1 ||
54
+ manifest.terminalDirect.transport !== true
55
+ )
56
+ throw new DirectFallbackError('DIRECT_UNSUPPORTED');
57
+
58
+ live = new LiveControl(client.openWorkbenchWebSocket(WebSocketClass), timeoutMs);
59
+ await live.openAndResume();
60
+
61
+ const transportPrepared = await live.request(
62
+ {
63
+ type: 'direct.prepare',
64
+ requestId: requestId(),
65
+ nodeId,
66
+ nodeGeneration: manifest.nodeGeneration,
67
+ purpose: 'TRANSPORT'
68
+ },
69
+ (message, request) =>
70
+ message.type === 'direct.prepared' &&
71
+ message.requestId === request.requestId &&
72
+ message.purpose === 'TRANSPORT',
73
+ 'DIRECT_TRANSPORT_PREPARE_FAILED'
74
+ );
75
+ transportSessionId = transportPrepared.directSessionId;
76
+
77
+ const native =
78
+ peerConnectionFactory === undefined ? await import('node-datachannel') : undefined;
79
+ cleanupNative = native?.cleanup;
80
+ const createPeer =
81
+ peerConnectionFactory ?? ((name, options) => new native.PeerConnection(name, options));
82
+ peer = createPeer(`marmgr-${transportSessionId}`, { iceServers: [] });
83
+
84
+ const transportReady = new Promise((resolve, reject) => {
85
+ let connected = false;
86
+ let serverOpened = false;
87
+ let settled = false;
88
+ const finish = (error) => {
89
+ if (settled) return;
90
+ settled = true;
91
+ transportNegotiationActive = false;
92
+ if (error === undefined) resolve();
93
+ else reject(error);
94
+ };
95
+ const maybeReady = () => {
96
+ if (connected && serverOpened) finish();
97
+ };
98
+ transportControlCleanup = live.onControl((message) => {
99
+ if (!transportNegotiationActive || settled) return;
100
+ if (message.type === 'direct.signal' && message.directSessionId === transportSessionId) {
101
+ try {
102
+ applyRemoteSignal(peer, message.signal);
103
+ } catch (error) {
104
+ finish(
105
+ error instanceof DirectFallbackError
106
+ ? error
107
+ : new DirectFallbackError('DIRECT_SIGNAL_INVALID')
108
+ );
109
+ }
110
+ return;
111
+ }
112
+ if (message.type === 'direct.opened' && message.directSessionId === transportSessionId) {
113
+ serverOpened = true;
114
+ maybeReady();
115
+ return;
116
+ }
117
+ if (
118
+ (message.type === 'direct.error' || message.type === 'direct.closed') &&
119
+ message.directSessionId === transportSessionId
120
+ )
121
+ finish(new DirectFallbackError(message.code));
122
+ });
123
+ peer.onLocalDescription((sdp, type) => {
124
+ if (!transportNegotiationActive || settled) return;
125
+ if (String(type).toLowerCase() !== 'offer') return;
126
+ let signal;
127
+ try {
128
+ signal = localOfferSignal(sdp);
129
+ } catch (error) {
130
+ finish(error instanceof DirectFallbackError ? error : new DirectFallbackError());
131
+ return;
132
+ }
133
+ if (!live.send({ type: 'direct.signal', directSessionId: transportSessionId, signal }))
134
+ finish(new DirectFallbackError('DIRECT_CONTROL_UNAVAILABLE'));
135
+ });
136
+ peer.onLocalCandidate((candidate, mid) => {
137
+ if (!transportNegotiationActive || settled || typeof candidate !== 'string') return;
138
+ if (candidate === '') {
139
+ if (
140
+ !live.send({
141
+ type: 'direct.signal',
142
+ directSessionId: transportSessionId,
143
+ signal: { kind: 'END_OF_CANDIDATES' }
144
+ })
145
+ )
146
+ finish(new DirectFallbackError('DIRECT_CONTROL_UNAVAILABLE'));
147
+ return;
148
+ }
149
+ if (!safeHostCandidate(candidate)) return;
150
+ if (
151
+ !live.send({
152
+ type: 'direct.signal',
153
+ directSessionId: transportSessionId,
154
+ signal: {
155
+ kind: 'CANDIDATE',
156
+ candidate: { candidate, sdpMid: mid || null, sdpMLineIndex: null }
157
+ }
158
+ })
159
+ )
160
+ finish(new DirectFallbackError('DIRECT_CONTROL_UNAVAILABLE'));
161
+ });
162
+ peer.onStateChange((state) => {
163
+ if (!transportNegotiationActive || settled) return;
164
+ const normalizedState = String(state).toLowerCase();
165
+ if (normalizedState === 'connected') {
166
+ if (!connected) {
167
+ connected = true;
168
+ if (!live.send({ type: 'direct.connected', directSessionId: transportSessionId }))
169
+ finish(new DirectFallbackError('DIRECT_CONTROL_UNAVAILABLE'));
170
+ }
171
+ maybeReady();
172
+ } else if (normalizedState === 'failed' || normalizedState === 'closed') {
173
+ finish(new DirectFallbackError('DIRECT_CONNECTION_FAILED'));
174
+ }
175
+ });
176
+ try {
177
+ bootstrapChannel = peer.createDataChannel('mar-direct-transport', {
178
+ unordered: false
179
+ });
180
+ if (peer.localDescription?.() == null) peer.setLocalDescription('Offer');
181
+ } catch {
182
+ finish(new DirectFallbackError('DIRECT_OFFER_FAILED'));
183
+ }
184
+ });
185
+ try {
186
+ await withTimeout(transportReady, timeoutMs, 'DIRECT_SETUP_TIMEOUT');
187
+ } finally {
188
+ transportNegotiationActive = false;
189
+ transportControlCleanup?.();
190
+ transportControlCleanup = undefined;
191
+ }
192
+ bootstrapChannel?.close?.();
193
+
194
+ const terminalPrepared = await live.request(
195
+ {
196
+ type: 'terminal.direct.prepare',
197
+ requestId: requestId(),
198
+ nodeId,
199
+ nodeGeneration: manifest.nodeGeneration,
200
+ transportSessionId,
201
+ terminalId,
202
+ afterSequence,
203
+ readonly: false
204
+ },
205
+ (message, request) =>
206
+ message.type === 'terminal.direct.prepared' &&
207
+ message.requestId === request.requestId &&
208
+ typeof message.directSessionId === 'string' &&
209
+ typeof message.grant === 'string',
210
+ 'TERMINAL_DIRECT_PREPARE_FAILED'
211
+ );
212
+ terminalSessionId = terminalPrepared.directSessionId;
213
+ terminalChannel = peer.createDataChannel(`mar-terminal-${terminalSessionId}`, {
214
+ unordered: false
215
+ });
216
+
217
+ transport = new DirectTerminalTransport({
218
+ channel: terminalChannel,
219
+ live,
220
+ peer,
221
+ transportSessionId,
222
+ terminalSessionId,
223
+ terminalId,
224
+ heartbeatMs,
225
+ cleanupNative
226
+ });
227
+ await withTimeout(
228
+ transport.waitForAttachment(terminalPrepared.grant),
229
+ timeoutMs,
230
+ 'TERMINAL_DIRECT_TIMEOUT'
231
+ );
232
+ return transport;
233
+ } catch (error) {
234
+ transportControlCleanup?.();
235
+ transportControlCleanup = undefined;
236
+ unsafeFallback = transport?.openSent === true;
237
+ if (transport !== undefined) {
238
+ await transport.cancelAndDispose().catch(() => undefined);
239
+ transport.dispose({ skipCancel: true });
240
+ }
241
+ bootstrapChannel?.close?.();
242
+ terminalChannel?.close?.();
243
+ try {
244
+ if (transport === undefined && terminalSessionId !== undefined)
245
+ await cancelSession(live, 'terminal.direct.cancel', terminalSessionId);
246
+ if (transport === undefined && transportSessionId !== undefined)
247
+ await cancelSession(live, 'direct.cancel', transportSessionId);
248
+ } catch {
249
+ // Direct setup cleanup is best effort before relay fallback.
250
+ }
251
+ live?.close();
252
+ if (transport === undefined) peer?.close?.();
253
+ if (transport === undefined) cleanupNative?.();
254
+ if (unsafeFallback) throw new DirectUnsafeError('DIRECT_OPEN_CANCEL_UNCONFIRMED');
255
+ if (error instanceof DirectFallbackError || error instanceof DirectUnsafeError) throw error;
256
+ throw new DirectFallbackError(errorCode(error));
257
+ }
258
+ }
259
+
260
+ class LiveControl {
261
+ #socket;
262
+ #timeoutMs;
263
+ #opened = false;
264
+ #ready = false;
265
+ #closed = false;
266
+ #waiters = new Set();
267
+ #listeners = new Set();
268
+ #onMessage;
269
+ #onError;
270
+ #onClose;
271
+
272
+ constructor(socket, timeoutMs) {
273
+ this.#socket = socket;
274
+ this.#timeoutMs = timeoutMs;
275
+ this.#onMessage = (raw, isBinary) => {
276
+ if (isBinary) return;
277
+ let message;
278
+ try {
279
+ message = JSON.parse(rawToString(raw));
280
+ } catch {
281
+ return;
282
+ }
283
+ if (!isRecord(message) || typeof message.type !== 'string') return;
284
+ for (const listener of this.#listeners) listener(message);
285
+ for (const waiter of [...this.#waiters]) {
286
+ if (!waiter.predicate(message, waiter.request)) continue;
287
+ this.#waiters.delete(waiter);
288
+ clearTimeout(waiter.timer);
289
+ waiter.resolve(message);
290
+ }
291
+ };
292
+ this.#onError = () => this.#fail(new DirectFallbackError('DIRECT_CONTROL_UNAVAILABLE'));
293
+ this.#onClose = () => this.#fail(new DirectFallbackError('DIRECT_CONTROL_CLOSED'));
294
+ socket.on?.('message', this.#onMessage);
295
+ socket.on?.('error', this.#onError);
296
+ socket.on?.('close', this.#onClose);
297
+ }
298
+
299
+ async openAndResume() {
300
+ await this.#waitForSocket('open', 'DIRECT_CONTROL_TIMEOUT');
301
+ const ready = this.waitFor(
302
+ (message) =>
303
+ message.type === 'workbench.ready' && typeof message.workbenchConnectionId === 'string',
304
+ undefined,
305
+ 'DIRECT_RESUME_TIMEOUT'
306
+ );
307
+ if (!this.send({ type: 'resume' })) {
308
+ ready.catch(() => undefined);
309
+ throw new DirectFallbackError('DIRECT_CONTROL_UNAVAILABLE');
310
+ }
311
+ await ready;
312
+ this.#ready = true;
313
+ }
314
+
315
+ request(message, predicate, fallbackCode) {
316
+ if (!this.#ready) throw new DirectFallbackError('DIRECT_CONTROL_UNAVAILABLE');
317
+ const response = this.waitFor(predicate, message, fallbackCode);
318
+ if (!this.send(message)) {
319
+ response.catch(() => undefined);
320
+ throw new DirectFallbackError('DIRECT_CONTROL_UNAVAILABLE');
321
+ }
322
+ return response;
323
+ }
324
+
325
+ waitFor(predicate, request, fallbackCode, timeoutMs = this.#timeoutMs) {
326
+ return new Promise((resolve, reject) => {
327
+ if (this.#closed) {
328
+ reject(new DirectFallbackError('DIRECT_CONTROL_CLOSED'));
329
+ return;
330
+ }
331
+ const waiter = {
332
+ predicate,
333
+ request,
334
+ resolve,
335
+ reject,
336
+ timer: setTimeout(() => {
337
+ this.#waiters.delete(waiter);
338
+ reject(new DirectFallbackError(fallbackCode ?? 'DIRECT_SETUP_TIMEOUT'));
339
+ }, timeoutMs)
340
+ };
341
+ this.#waiters.add(waiter);
342
+ });
343
+ }
344
+
345
+ onControl(listener) {
346
+ if (this.#closed) return () => {};
347
+ this.#listeners.add(listener);
348
+ return () => this.#listeners.delete(listener);
349
+ }
350
+
351
+ send(message) {
352
+ if (this.#closed || (this.#socket.readyState !== undefined && this.#socket.readyState !== 1))
353
+ return false;
354
+ try {
355
+ this.#socket.send(JSON.stringify(message));
356
+ return true;
357
+ } catch {
358
+ return false;
359
+ }
360
+ }
361
+
362
+ close() {
363
+ if (this.#closed) return;
364
+ this.#closed = true;
365
+ this.#fail(new DirectFallbackError('DIRECT_CONTROL_CLOSED'));
366
+ this.#listeners.clear();
367
+ this.#socket.removeListener?.('message', this.#onMessage);
368
+ this.#socket.removeListener?.('error', this.#onError);
369
+ this.#socket.removeListener?.('close', this.#onClose);
370
+ try {
371
+ this.#socket.close();
372
+ } catch {
373
+ // The control socket is already closed.
374
+ }
375
+ }
376
+
377
+ #waitForSocket(event, code) {
378
+ if (this.#socket.readyState === 1) {
379
+ this.#opened = true;
380
+ return Promise.resolve();
381
+ }
382
+ return new Promise((resolve, reject) => {
383
+ const onEvent = () => {
384
+ cleanup();
385
+ this.#opened = true;
386
+ resolve();
387
+ };
388
+ const onError = () => {
389
+ cleanup();
390
+ reject(new DirectFallbackError(code));
391
+ };
392
+ const onClose = () => {
393
+ cleanup();
394
+ reject(new DirectFallbackError(code));
395
+ };
396
+ const timer = setTimeout(() => {
397
+ cleanup();
398
+ reject(new DirectFallbackError(code));
399
+ }, this.#timeoutMs);
400
+ const cleanup = () => {
401
+ clearTimeout(timer);
402
+ this.#socket.removeListener?.(event, onEvent);
403
+ this.#socket.removeListener?.('error', onError);
404
+ this.#socket.removeListener?.('close', onClose);
405
+ };
406
+ this.#socket.once?.(event, onEvent);
407
+ this.#socket.once?.('error', onError);
408
+ this.#socket.once?.('close', onClose);
409
+ });
410
+ }
411
+
412
+ #fail(error) {
413
+ if (this.#closed && this.#waiters.size === 0) return;
414
+ for (const waiter of [...this.#waiters]) {
415
+ this.#waiters.delete(waiter);
416
+ clearTimeout(waiter.timer);
417
+ waiter.reject(error);
418
+ }
419
+ }
420
+ }
421
+
422
+ class DirectTerminalTransport extends EventEmitter {
423
+ #channel;
424
+ #live;
425
+ #peer;
426
+ #transportSessionId;
427
+ #terminalSessionId;
428
+ #terminalId;
429
+ #cleanupNative;
430
+ #heartbeatTimer;
431
+ #unsubscribeLive;
432
+ #closed = false;
433
+ #attached = false;
434
+ #active = false;
435
+ #pendingEvents = [];
436
+ #attachmentResolve;
437
+ #attachmentReject;
438
+ #openSent = false;
439
+ #cancelSent = false;
440
+
441
+ constructor({
442
+ channel,
443
+ live,
444
+ peer,
445
+ transportSessionId,
446
+ terminalSessionId,
447
+ terminalId,
448
+ heartbeatMs,
449
+ cleanupNative
450
+ }) {
451
+ super();
452
+ this.#channel = channel;
453
+ this.#live = live;
454
+ this.#peer = peer;
455
+ this.#transportSessionId = transportSessionId;
456
+ this.#terminalSessionId = terminalSessionId;
457
+ this.#terminalId = terminalId;
458
+ this.#cleanupNative = cleanupNative;
459
+ this.#channel.onMessage?.((raw) => this.#handleMessage(raw));
460
+ this.#channel.onClosed?.(() => this.#fail('TERMINAL_DIRECT_CLOSED'));
461
+ this.#channel.onError?.(() => this.#fail('TERMINAL_CONNECTION_FAILED'));
462
+ this.#unsubscribeLive = this.#live.onControl((message) => this.#handleControl(message));
463
+ this.#heartbeatTimer = setInterval(() => {
464
+ if (
465
+ !this.#live.send({
466
+ type: 'direct.heartbeat',
467
+ directSessionId: this.#transportSessionId
468
+ }) ||
469
+ !this.#live.send({
470
+ type: 'terminal.direct.heartbeat',
471
+ directSessionId: this.#terminalSessionId
472
+ })
473
+ )
474
+ this.#fail('TERMINAL_CONNECTION_LOST');
475
+ }, heartbeatMs);
476
+ this.#heartbeatTimer.unref?.();
477
+ }
478
+
479
+ get openSent() {
480
+ return this.#openSent;
481
+ }
482
+
483
+ activate() {
484
+ if (this.#active) return;
485
+ this.#active = true;
486
+ const pendingEvents = this.#pendingEvents;
487
+ this.#pendingEvents = [];
488
+ for (const { type, args } of pendingEvents) this.#emit(type, ...args);
489
+ }
490
+
491
+ waitForAttachment(grant) {
492
+ return new Promise((resolve, reject) => {
493
+ if (this.#closed) {
494
+ reject(new DirectFallbackError('TERMINAL_DIRECT_CLOSED'));
495
+ return;
496
+ }
497
+ this.#attachmentResolve = resolve;
498
+ this.#attachmentReject = reject;
499
+ let opened = false;
500
+ const onOpen = () => {
501
+ if (opened || this.#closed) return;
502
+ opened = true;
503
+ try {
504
+ this.#openSent = true;
505
+ if (
506
+ !this.#channel.sendMessage(
507
+ JSON.stringify({
508
+ type: 'OPEN',
509
+ directSessionId: this.#terminalSessionId,
510
+ grant
511
+ })
512
+ )
513
+ )
514
+ throw new Error('TERMINAL_ATTACH_FAILED');
515
+ } catch {
516
+ this.#fail('TERMINAL_ATTACH_FAILED');
517
+ }
518
+ };
519
+ this.#channel.onOpen?.(onOpen);
520
+ if (this.#channel.isOpen?.() === true) onOpen();
521
+ });
522
+ }
523
+
524
+ send(frame) {
525
+ if (this.#closed || this.#channel.isOpen?.() !== true)
526
+ throw new Error('TERMINAL_CONNECTION_LOST');
527
+ const value = typeof frame === 'string' ? frame : JSON.stringify(frame);
528
+ if (!this.#channel.sendMessage(value)) throw new Error('TERMINAL_INPUT_FAILED');
529
+ }
530
+
531
+ close() {
532
+ this.dispose();
533
+ }
534
+
535
+ dispose({ skipCancel = false } = {}) {
536
+ if (this.#closed) return;
537
+ this.#closed = true;
538
+ clearInterval(this.#heartbeatTimer);
539
+ if (!skipCancel) this.#sendCancel();
540
+ this.#attachmentReject?.(new DirectFallbackError('TERMINAL_DIRECT_CLOSED'));
541
+ this.#attachmentResolve = undefined;
542
+ this.#attachmentReject = undefined;
543
+ this.#unsubscribeLive?.();
544
+ this.#unsubscribeLive = undefined;
545
+ this.#channel.close?.();
546
+ this.#peer.close?.();
547
+ this.#live.close();
548
+ this.#cleanupNative?.();
549
+ this.#emit('close');
550
+ }
551
+
552
+ async cancelAndDispose() {
553
+ const terminalClosed = this.#live.waitFor(
554
+ (message) =>
555
+ message.type === 'terminal.direct.closed' &&
556
+ message.directSessionId === this.#terminalSessionId,
557
+ undefined,
558
+ 'DIRECT_CANCEL_TIMEOUT',
559
+ 1000
560
+ );
561
+ const transportClosed = this.#live.waitFor(
562
+ (message) =>
563
+ message.type === 'direct.closed' && message.directSessionId === this.#transportSessionId,
564
+ undefined,
565
+ 'DIRECT_CANCEL_TIMEOUT',
566
+ 1000
567
+ );
568
+ this.#sendCancel();
569
+ await Promise.allSettled([terminalClosed, transportClosed]);
570
+ this.dispose({ skipCancel: true });
571
+ }
572
+
573
+ #handleMessage(raw) {
574
+ if (this.#closed) return;
575
+ if (!this.#attached) {
576
+ let frame;
577
+ try {
578
+ frame = JSON.parse(rawToString(raw));
579
+ } catch {
580
+ this.#fail('TERMINAL_FRAME_INVALID');
581
+ return;
582
+ }
583
+ if (
584
+ !isRecord(frame) ||
585
+ frame.type !== 'ATTACHED' ||
586
+ frame.terminalId !== this.#terminalId ||
587
+ !Number.isSafeInteger(frame.afterSequence) ||
588
+ frame.afterSequence < 0
589
+ ) {
590
+ this.#fail('TERMINAL_ATTACH_FAILED');
591
+ return;
592
+ }
593
+ this.#attached = true;
594
+ this.#attachmentResolve?.();
595
+ this.#attachmentResolve = undefined;
596
+ this.#attachmentReject = undefined;
597
+ return;
598
+ }
599
+ this.#emit(
600
+ 'message',
601
+ raw,
602
+ typeof raw === 'string'
603
+ ? false
604
+ : Buffer.isBuffer(raw) || raw instanceof ArrayBuffer || ArrayBuffer.isView(raw)
605
+ );
606
+ }
607
+
608
+ #handleControl(message) {
609
+ if (message.type === 'terminal.direct.error' || message.type === 'terminal.direct.closed') {
610
+ if (!this.#attached) this.#fail(message.code);
611
+ else this.#fail(message.code);
612
+ }
613
+ if (message.type === 'direct.closed' || message.type === 'direct.error')
614
+ this.#fail(message.code);
615
+ }
616
+
617
+ #fail(code) {
618
+ if (this.#closed) return;
619
+ const error = new Error(code);
620
+ if (!this.#attached) this.#attachmentReject?.(new DirectFallbackError(code));
621
+ this.#attachmentResolve = undefined;
622
+ this.#attachmentReject = undefined;
623
+ this.#emit('error', error);
624
+ this.dispose();
625
+ }
626
+
627
+ #emit(type, ...args) {
628
+ if (!this.#active) {
629
+ this.#pendingEvents.push({ type, args });
630
+ return;
631
+ }
632
+ if (type !== 'error' || this.listenerCount('error') > 0) this.emit(type, ...args);
633
+ }
634
+
635
+ #sendCancel() {
636
+ if (this.#cancelSent) return;
637
+ this.#cancelSent = true;
638
+ this.#live.send({
639
+ type: 'terminal.direct.cancel',
640
+ directSessionId: this.#terminalSessionId
641
+ });
642
+ this.#live.send({
643
+ type: 'direct.cancel',
644
+ directSessionId: this.#transportSessionId,
645
+ reason: 'CLI_CLOSED'
646
+ });
647
+ }
648
+ }
649
+
650
+ async function cancelSession(live, type, directSessionId) {
651
+ if (live === undefined) return;
652
+ const closed = live.waitFor(
653
+ (message) =>
654
+ message.type === `${type.replace('.cancel', '.closed')}` &&
655
+ message.directSessionId === directSessionId,
656
+ undefined,
657
+ 'DIRECT_CANCEL_TIMEOUT',
658
+ 1000
659
+ );
660
+ live.send({
661
+ type,
662
+ directSessionId,
663
+ ...(type === 'direct.cancel' ? { reason: 'CLI_FALLBACK' } : {})
664
+ });
665
+ await closed.catch(() => undefined);
666
+ }
667
+
668
+ function withTimeout(promise, timeoutMs, code) {
669
+ let timer;
670
+ return Promise.race([
671
+ promise,
672
+ new Promise((_, reject) => {
673
+ timer = setTimeout(() => reject(new DirectFallbackError(code)), timeoutMs);
674
+ })
675
+ ]).finally(() => clearTimeout(timer));
676
+ }
677
+
678
+ function requestId() {
679
+ return randomUUID().replaceAll('-', '');
680
+ }
681
+
682
+ function rawToString(raw) {
683
+ if (typeof raw === 'string') return raw;
684
+ if (Buffer.isBuffer(raw)) return raw.toString('utf8');
685
+ if (raw instanceof ArrayBuffer) return Buffer.from(raw).toString('utf8');
686
+ if (ArrayBuffer.isView(raw))
687
+ return Buffer.from(raw.buffer, raw.byteOffset, raw.byteLength).toString('utf8');
688
+ return String(raw);
689
+ }
690
+
691
+ function isRecord(value) {
692
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
693
+ }
694
+
695
+ function localOfferSignal(sdp) {
696
+ const fingerprint = extractFingerprint(sdp);
697
+ if (fingerprint === undefined) throw new DirectFallbackError('DIRECT_FINGERPRINT_INVALID');
698
+ return { kind: 'OFFER', sdp: stripHostCandidates(sdp), fingerprint };
699
+ }
700
+
701
+ function applyRemoteSignal(peer, signal) {
702
+ if (signal.kind === 'CANDIDATE') {
703
+ if (
704
+ !isRecord(signal.candidate) ||
705
+ typeof signal.candidate.candidate !== 'string' ||
706
+ !safeHostCandidate(signal.candidate.candidate)
707
+ )
708
+ throw new DirectFallbackError('DIRECT_CANDIDATE_REJECTED');
709
+ peer.addRemoteCandidate(
710
+ signal.candidate.candidate,
711
+ signal.candidate.sdpMid ?? String(signal.candidate.sdpMLineIndex ?? 0)
712
+ );
713
+ return;
714
+ }
715
+ if (signal.kind === 'END_OF_CANDIDATES') return;
716
+ if (
717
+ !hostOnlySdp(signal.sdp) ||
718
+ !fingerprintMatches(signal.sdp, signal.fingerprint) ||
719
+ signal.kind !== 'ANSWER'
720
+ )
721
+ throw new DirectFallbackError('DIRECT_FINGERPRINT_INVALID');
722
+ peer.setRemoteDescription(signal.sdp, 'Answer');
723
+ }
724
+
725
+ function extractFingerprint(sdp) {
726
+ if (typeof sdp !== 'string') return undefined;
727
+ const match = sdp.match(/^a=fingerprint:sha-256 ([A-F0-9:]+)\r?$/imu);
728
+ if (match === null) return undefined;
729
+ const value = match[1].toUpperCase();
730
+ return /^([A-F0-9]{2}:){31}[A-F0-9]{2}$/u.test(value) ? `sha-256 ${value}` : undefined;
731
+ }
732
+
733
+ function safeHostCandidate(candidate) {
734
+ return typeof candidate === 'string' && /\styp host(?:\s|$)/u.test(candidate);
735
+ }
736
+
737
+ function hostOnlySdp(sdp) {
738
+ if (typeof sdp !== 'string') return false;
739
+ return sdp
740
+ .split(/\r?\n/u)
741
+ .filter((line) => line.startsWith('a=candidate:'))
742
+ .every((line) => safeHostCandidate(line.slice(2)));
743
+ }
744
+
745
+ function stripHostCandidates(sdp) {
746
+ if (typeof sdp !== 'string') throw new DirectFallbackError('DIRECT_SDP_INVALID');
747
+ return sdp
748
+ .split(/\r?\n/u)
749
+ .filter((line) => !line.startsWith('a=candidate:') || safeHostCandidate(line.slice(2)))
750
+ .join('\r\n');
751
+ }
752
+
753
+ function fingerprintMatches(sdp, fingerprint) {
754
+ return extractFingerprint(sdp) === normalizeFingerprint(fingerprint);
755
+ }
756
+
757
+ function normalizeFingerprint(value) {
758
+ if (typeof value !== 'string') return undefined;
759
+ const match = /^sha-256 ([A-F0-9:]+)$/iu.exec(value);
760
+ if (match === null) return undefined;
761
+ const normalized = match[1].toUpperCase();
762
+ return /^([A-F0-9]{2}:){31}[A-F0-9]{2}$/u.test(normalized) ? `sha-256 ${normalized}` : undefined;
763
+ }
764
+
765
+ function errorCode(error) {
766
+ return error instanceof Error && /^[A-Z][A-Z0-9_]{0,127}$/u.test(error.message)
767
+ ? error.message
768
+ : 'DIRECT_CONNECTION_FAILED';
769
+ }