@myagentroam/marctl 0.11.3 → 0.11.5

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 CHANGED
@@ -1,27 +1,18 @@
1
+ import {
2
+ DirectFallbackError,
3
+ DirectUnsafeError,
4
+ DirectRejectedError,
5
+ directControlError
6
+ } from './direct-errors.mjs';
1
7
  import { randomUUID } from 'node:crypto';
2
- import { EventEmitter } from 'node:events';
8
+ import { LiveControl, cancelSession, withTimeout, isRecord } from './direct-control.mjs';
9
+ import { DirectTerminalTransport } from './direct-transport.mjs';
3
10
 
4
11
  export const DIRECT_PREPARE_TIMEOUT_MS = 5000;
5
12
  export const DIRECT_SETUP_TIMEOUT_MS = 15000;
6
13
  export const DIRECT_ICE_TIMEOUT_MS = 3000;
7
14
  export const DIRECT_HEARTBEAT_MS = 10000;
8
15
 
9
- export class DirectFallbackError extends Error {
10
- constructor(code = 'DIRECT_UNAVAILABLE') {
11
- super(code);
12
- this.name = 'DirectFallbackError';
13
- this.code = code;
14
- }
15
- }
16
-
17
- export class DirectUnsafeError extends Error {
18
- constructor(code = 'DIRECT_CANCEL_UNCONFIRMED') {
19
- super(code);
20
- this.name = 'DirectUnsafeError';
21
- this.code = code;
22
- }
23
- }
24
-
25
16
  export async function openDirectTerminal(options) {
26
17
  const {
27
18
  client,
@@ -36,6 +27,7 @@ export async function openDirectTerminal(options) {
36
27
  onIceConnected,
37
28
  onTransportReady,
38
29
  beforeOpen,
30
+ onRejected,
39
31
  cleanupNative: cleanupNativeImpl,
40
32
  signal
41
33
  } = options;
@@ -55,6 +47,7 @@ export async function openDirectTerminal(options) {
55
47
  let unsafeFallback = false;
56
48
  let transportControlCleanup;
57
49
  let transportNegotiationActive = true;
50
+ let preparationError;
58
51
  let cleanupNative = cleanupNativeImpl;
59
52
  try {
60
53
  manifest ??= await client.getTerminalManifest(nodeId);
@@ -67,33 +60,79 @@ export async function openDirectTerminal(options) {
67
60
  throw new DirectFallbackError('DIRECT_UNSUPPORTED');
68
61
 
69
62
  live = new LiveControl(client.openWorkbenchWebSocket(WebSocketClass), timeoutMs);
70
- await live.openAndResume();
63
+ await withTimeout(live.openAndResume(), timeoutMs, 'DIRECT_CONTROL_TIMEOUT', signal);
71
64
 
72
- const transportPrepared = await live.request(
73
- {
74
- type: 'direct.prepare',
75
- requestId: requestId(),
76
- nodeId,
77
- nodeGeneration: manifest.nodeGeneration,
78
- purpose: 'TRANSPORT'
79
- },
80
- (message, request) =>
81
- message.type === 'direct.prepared' &&
82
- message.requestId === request.requestId &&
83
- message.purpose === 'TRANSPORT',
84
- 'DIRECT_TRANSPORT_PREPARE_FAILED'
65
+ let dispatchControl;
66
+ let rejectPreparation;
67
+ const earlyTransportErrors = new Map();
68
+ const preparationFailure = new Promise((_, reject) => {
69
+ rejectPreparation = reject;
70
+ });
71
+ void preparationFailure.catch(() => undefined);
72
+ const failPreparation = (error) => {
73
+ preparationError ??= error;
74
+ rejectPreparation(preparationError);
75
+ };
76
+ // Node errors carry a session ID, not necessarily the browser's request ID.
77
+ // Listen before prepare and native initialization, then correlate with prepared.
78
+ transportControlCleanup = live.onControl((message) => {
79
+ if (dispatchControl) dispatchControl(message);
80
+ else if (
81
+ (message.type === 'direct.error' || message.type === 'direct.closed') &&
82
+ typeof message.directSessionId === 'string'
83
+ ) {
84
+ const error = directControlError(message.code);
85
+ if (transportSessionId === undefined) {
86
+ if (earlyTransportErrors.size >= 16 && !earlyTransportErrors.has(message.directSessionId))
87
+ failPreparation(new DirectRejectedError('DIRECT_CONTROL_MESSAGE_LIMIT'));
88
+ else if (!earlyTransportErrors.has(message.directSessionId))
89
+ earlyTransportErrors.set(message.directSessionId, error);
90
+ } else if (message.directSessionId === transportSessionId) failPreparation(error);
91
+ }
92
+ });
93
+ const transportPrepared = await withTimeout(
94
+ Promise.race([
95
+ live.request(
96
+ {
97
+ type: 'direct.prepare',
98
+ requestId: requestId(),
99
+ nodeId,
100
+ nodeGeneration: manifest.nodeGeneration,
101
+ purpose: 'TRANSPORT'
102
+ },
103
+ (message, request) =>
104
+ message.type === 'direct.prepared' &&
105
+ message.requestId === request.requestId &&
106
+ message.purpose === 'TRANSPORT',
107
+ 'DIRECT_TRANSPORT_PREPARE_FAILED'
108
+ ),
109
+ preparationFailure
110
+ ]),
111
+ timeoutMs,
112
+ 'DIRECT_TRANSPORT_PREPARE_FAILED',
113
+ signal
85
114
  );
86
115
  transportSessionId = transportPrepared.directSessionId;
116
+ const earlyError = earlyTransportErrors.get(transportSessionId);
117
+ earlyTransportErrors.clear();
118
+ if (earlyError) throw earlyError;
87
119
 
88
120
  const native =
89
121
  peerConnectionFactory === undefined ? await import('node-datachannel') : undefined;
90
122
  cleanupNative ??= native?.cleanup;
123
+ if (preparationError) throw preparationError;
91
124
  const createPeer =
92
125
  peerConnectionFactory ?? ((name, options) => new native.PeerConnection(name, options));
93
126
  peer = createPeer(`marctl-${transportSessionId}`, { iceServers: [] });
127
+ if (preparationError) throw preparationError;
94
128
 
95
129
  // Start negotiation only after both paths finish their control-plane preparation.
96
- if (onPrepared) await onPrepared();
130
+ await withTimeout(
131
+ Promise.race([preparationFailure, onPrepared?.()]),
132
+ timeoutMs,
133
+ 'DIRECT_SETUP_TIMEOUT',
134
+ signal
135
+ );
97
136
  if (signal?.aborted) throw new DirectFallbackError('DIRECT_RACE_CANCELLED');
98
137
 
99
138
  const transportReady = new Promise((resolve, reject) => {
@@ -111,7 +150,7 @@ export async function openDirectTerminal(options) {
111
150
  const maybeReady = () => {
112
151
  if (connected && serverOpened) finish();
113
152
  };
114
- transportControlCleanup = live.onControl((message) => {
153
+ dispatchControl = (message) => {
115
154
  if (!transportNegotiationActive || settled) return;
116
155
  if (message.type === 'direct.signal' && message.directSessionId === transportSessionId) {
117
156
  try {
@@ -134,8 +173,8 @@ export async function openDirectTerminal(options) {
134
173
  (message.type === 'direct.error' || message.type === 'direct.closed') &&
135
174
  message.directSessionId === transportSessionId
136
175
  )
137
- finish(new DirectFallbackError(message.code));
138
- });
176
+ finish(directControlError(message.code));
177
+ };
139
178
  peer.onLocalDescription((sdp, type) => {
140
179
  if (!transportNegotiationActive || settled) return;
141
180
  if (String(type).toLowerCase() !== 'offer') return;
@@ -224,23 +263,28 @@ export async function openDirectTerminal(options) {
224
263
  bootstrapChannel?.close?.();
225
264
  if (onTransportReady) await onTransportReady();
226
265
 
227
- const terminalPrepared = await live.request(
228
- {
229
- type: 'terminal.direct.prepare',
230
- requestId: requestId(),
231
- nodeId,
232
- nodeGeneration: manifest.nodeGeneration,
233
- transportSessionId,
234
- terminalId,
235
- afterSequence,
236
- readonly: false
237
- },
238
- (message, request) =>
239
- message.type === 'terminal.direct.prepared' &&
240
- message.requestId === request.requestId &&
241
- typeof message.directSessionId === 'string' &&
242
- typeof message.grant === 'string',
243
- 'TERMINAL_DIRECT_PREPARE_FAILED'
266
+ const terminalPrepared = await withTimeout(
267
+ live.request(
268
+ {
269
+ type: 'terminal.direct.prepare',
270
+ requestId: requestId(),
271
+ nodeId,
272
+ nodeGeneration: manifest.nodeGeneration,
273
+ transportSessionId,
274
+ terminalId,
275
+ afterSequence,
276
+ readonly: false
277
+ },
278
+ (message, request) =>
279
+ message.type === 'terminal.direct.prepared' &&
280
+ message.requestId === request.requestId &&
281
+ typeof message.directSessionId === 'string' &&
282
+ typeof message.grant === 'string',
283
+ 'TERMINAL_DIRECT_PREPARE_FAILED'
284
+ ),
285
+ timeoutMs,
286
+ 'TERMINAL_DIRECT_PREPARE_FAILED',
287
+ signal
244
288
  );
245
289
  terminalSessionId = terminalPrepared.directSessionId;
246
290
  terminalChannel = peer.createDataChannel(`mar-terminal-${terminalSessionId}`, {
@@ -258,16 +302,20 @@ export async function openDirectTerminal(options) {
258
302
  cleanupNative
259
303
  });
260
304
  if (beforeOpen) await beforeOpen();
305
+ if (signal?.aborted) throw new DirectFallbackError('DIRECT_RACE_CANCELLED');
261
306
  await withTimeout(
262
307
  transport.waitForAttachment(terminalPrepared.grant),
263
308
  timeoutMs,
264
- 'TERMINAL_DIRECT_TIMEOUT'
309
+ 'TERMINAL_DIRECT_TIMEOUT',
310
+ signal
265
311
  );
266
312
  return transport;
267
313
  } catch (error) {
314
+ error = preparationError ?? error;
268
315
  transportControlCleanup?.();
269
316
  transportControlCleanup = undefined;
270
317
  unsafeFallback = transport?.openSent === true;
318
+ if (error instanceof DirectRejectedError) onRejected?.(error);
271
319
  if (transport !== undefined) {
272
320
  await transport.cancelAndDispose().catch(() => undefined);
273
321
  transport.dispose({ skipCancel: true });
@@ -285,457 +333,17 @@ export async function openDirectTerminal(options) {
285
333
  live?.close();
286
334
  if (transport === undefined) peer?.close?.();
287
335
  if (transport === undefined) cleanupNative?.();
336
+ if (error instanceof DirectRejectedError) throw error;
288
337
  if (unsafeFallback) throw new DirectUnsafeError('DIRECT_OPEN_CANCEL_UNCONFIRMED');
289
338
  if (error instanceof DirectFallbackError || error instanceof DirectUnsafeError) throw error;
290
339
  throw new DirectFallbackError(errorCode(error));
291
340
  }
292
341
  }
293
342
 
294
- class LiveControl {
295
- #socket;
296
- #timeoutMs;
297
- #ready = false;
298
- #closed = false;
299
- #waiters = new Set();
300
- #listeners = new Set();
301
- #onMessage;
302
- #onError;
303
- #onClose;
304
-
305
- constructor(socket, timeoutMs) {
306
- this.#socket = socket;
307
- this.#timeoutMs = timeoutMs;
308
- this.#onMessage = (raw, isBinary) => {
309
- if (isBinary) return;
310
- let message;
311
- try {
312
- message = JSON.parse(rawToString(raw));
313
- } catch {
314
- return;
315
- }
316
- if (!isRecord(message) || typeof message.type !== 'string') return;
317
- for (const listener of this.#listeners) listener(message);
318
- for (const waiter of [...this.#waiters]) {
319
- if (!waiter.predicate(message, waiter.request)) continue;
320
- this.#waiters.delete(waiter);
321
- clearTimeout(waiter.timer);
322
- waiter.resolve(message);
323
- }
324
- };
325
- this.#onError = () => this.#fail(new DirectFallbackError('DIRECT_CONTROL_UNAVAILABLE'));
326
- this.#onClose = () => this.#fail(new DirectFallbackError('DIRECT_CONTROL_CLOSED'));
327
- socket.on?.('message', this.#onMessage);
328
- socket.on?.('error', this.#onError);
329
- socket.on?.('close', this.#onClose);
330
- }
331
-
332
- async openAndResume() {
333
- await this.#waitForSocket('open', 'DIRECT_CONTROL_TIMEOUT');
334
- const ready = this.waitFor(
335
- (message) =>
336
- message.type === 'workbench.ready' && typeof message.workbenchConnectionId === 'string',
337
- undefined,
338
- 'DIRECT_RESUME_TIMEOUT'
339
- );
340
- if (!this.send({ type: 'resume' })) {
341
- ready.catch(() => undefined);
342
- throw new DirectFallbackError('DIRECT_CONTROL_UNAVAILABLE');
343
- }
344
- await ready;
345
- this.#ready = true;
346
- }
347
-
348
- request(message, predicate, fallbackCode) {
349
- if (!this.#ready) throw new DirectFallbackError('DIRECT_CONTROL_UNAVAILABLE');
350
- const response = this.waitFor(predicate, message, fallbackCode);
351
- if (!this.send(message)) {
352
- response.catch(() => undefined);
353
- throw new DirectFallbackError('DIRECT_CONTROL_UNAVAILABLE');
354
- }
355
- return response;
356
- }
357
-
358
- waitFor(predicate, request, fallbackCode, timeoutMs = this.#timeoutMs) {
359
- return new Promise((resolve, reject) => {
360
- if (this.#closed) {
361
- reject(new DirectFallbackError('DIRECT_CONTROL_CLOSED'));
362
- return;
363
- }
364
- const waiter = {
365
- predicate,
366
- request,
367
- resolve,
368
- reject,
369
- timer: setTimeout(() => {
370
- this.#waiters.delete(waiter);
371
- reject(new DirectFallbackError(fallbackCode ?? 'DIRECT_SETUP_TIMEOUT'));
372
- }, timeoutMs)
373
- };
374
- this.#waiters.add(waiter);
375
- });
376
- }
377
-
378
- onControl(listener) {
379
- if (this.#closed) return () => {};
380
- this.#listeners.add(listener);
381
- return () => this.#listeners.delete(listener);
382
- }
383
-
384
- send(message) {
385
- if (this.#closed || (this.#socket.readyState !== undefined && this.#socket.readyState !== 1))
386
- return false;
387
- try {
388
- this.#socket.send(JSON.stringify(message));
389
- return true;
390
- } catch {
391
- return false;
392
- }
393
- }
394
-
395
- close() {
396
- if (this.#closed) return;
397
- this.#closed = true;
398
- this.#fail(new DirectFallbackError('DIRECT_CONTROL_CLOSED'));
399
- this.#listeners.clear();
400
- this.#socket.removeListener?.('message', this.#onMessage);
401
- this.#socket.removeListener?.('error', this.#onError);
402
- this.#socket.removeListener?.('close', this.#onClose);
403
- try {
404
- this.#socket.close();
405
- } catch {
406
- // The control socket is already closed.
407
- }
408
- }
409
-
410
- #waitForSocket(event, code) {
411
- if (this.#socket.readyState === 1) {
412
- return Promise.resolve();
413
- }
414
- return new Promise((resolve, reject) => {
415
- const onEvent = () => {
416
- cleanup();
417
- resolve();
418
- };
419
- const onError = () => {
420
- cleanup();
421
- reject(new DirectFallbackError(code));
422
- };
423
- const onClose = () => {
424
- cleanup();
425
- reject(new DirectFallbackError(code));
426
- };
427
- const timer = setTimeout(() => {
428
- cleanup();
429
- reject(new DirectFallbackError(code));
430
- }, this.#timeoutMs);
431
- const cleanup = () => {
432
- clearTimeout(timer);
433
- this.#socket.removeListener?.(event, onEvent);
434
- this.#socket.removeListener?.('error', onError);
435
- this.#socket.removeListener?.('close', onClose);
436
- };
437
- this.#socket.once?.(event, onEvent);
438
- this.#socket.once?.('error', onError);
439
- this.#socket.once?.('close', onClose);
440
- });
441
- }
442
-
443
- #fail(error) {
444
- if (this.#closed && this.#waiters.size === 0) return;
445
- for (const waiter of [...this.#waiters]) {
446
- this.#waiters.delete(waiter);
447
- clearTimeout(waiter.timer);
448
- waiter.reject(error);
449
- }
450
- }
451
- }
452
-
453
- class DirectTerminalTransport extends EventEmitter {
454
- #channel;
455
- #live;
456
- #peer;
457
- #transportSessionId;
458
- #terminalSessionId;
459
- #terminalId;
460
- #cleanupNative;
461
- #heartbeatTimer;
462
- #unsubscribeLive;
463
- #closed = false;
464
- #attached = false;
465
- #active = false;
466
- #pendingEvents = [];
467
- #attachmentResolve;
468
- #attachmentReject;
469
- #openSent = false;
470
- #cancelSent = false;
471
-
472
- constructor({
473
- channel,
474
- live,
475
- peer,
476
- transportSessionId,
477
- terminalSessionId,
478
- terminalId,
479
- heartbeatMs,
480
- cleanupNative
481
- }) {
482
- super();
483
- this.#channel = channel;
484
- this.#live = live;
485
- this.#peer = peer;
486
- this.#transportSessionId = transportSessionId;
487
- this.#terminalSessionId = terminalSessionId;
488
- this.#terminalId = terminalId;
489
- this.#cleanupNative = cleanupNative;
490
- this.#channel.onMessage?.((raw) => this.#handleMessage(raw));
491
- this.#channel.onClosed?.(() => this.#fail('TERMINAL_DIRECT_CLOSED'));
492
- this.#channel.onError?.(() => this.#fail('TERMINAL_CONNECTION_FAILED'));
493
- this.#unsubscribeLive = this.#live.onControl((message) => this.#handleControl(message));
494
- this.#heartbeatTimer = setInterval(() => {
495
- if (
496
- !this.#live.send({
497
- type: 'direct.heartbeat',
498
- directSessionId: this.#transportSessionId
499
- }) ||
500
- !this.#live.send({
501
- type: 'terminal.direct.heartbeat',
502
- directSessionId: this.#terminalSessionId
503
- })
504
- )
505
- this.#fail('TERMINAL_CONNECTION_LOST');
506
- }, heartbeatMs);
507
- this.#heartbeatTimer.unref?.();
508
- }
509
-
510
- get openSent() {
511
- return this.#openSent;
512
- }
513
-
514
- activate() {
515
- if (this.#active) return;
516
- this.#active = true;
517
- const pendingEvents = this.#pendingEvents;
518
- this.#pendingEvents = [];
519
- for (const { type, args } of pendingEvents) this.#emit(type, ...args);
520
- }
521
-
522
- waitForAttachment(grant) {
523
- return new Promise((resolve, reject) => {
524
- if (this.#closed) {
525
- reject(new DirectFallbackError('TERMINAL_DIRECT_CLOSED'));
526
- return;
527
- }
528
- this.#attachmentResolve = resolve;
529
- this.#attachmentReject = reject;
530
- let opened = false;
531
- const onOpen = () => {
532
- if (opened || this.#closed) return;
533
- opened = true;
534
- try {
535
- this.#openSent = true;
536
- if (
537
- !this.#channel.sendMessage(
538
- JSON.stringify({
539
- type: 'OPEN',
540
- directSessionId: this.#terminalSessionId,
541
- grant
542
- })
543
- )
544
- )
545
- throw new Error('TERMINAL_ATTACH_FAILED');
546
- } catch {
547
- this.#fail('TERMINAL_ATTACH_FAILED');
548
- }
549
- };
550
- this.#channel.onOpen?.(onOpen);
551
- if (this.#channel.isOpen?.() === true) onOpen();
552
- });
553
- }
554
-
555
- send(frame) {
556
- if (this.#closed || this.#channel.isOpen?.() !== true)
557
- throw new Error('TERMINAL_CONNECTION_LOST');
558
- const value = typeof frame === 'string' ? frame : JSON.stringify(frame);
559
- if (!this.#channel.sendMessage(value)) throw new Error('TERMINAL_INPUT_FAILED');
560
- }
561
-
562
- close() {
563
- this.dispose();
564
- }
565
-
566
- dispose({ skipCancel = false } = {}) {
567
- if (this.#closed) return;
568
- this.#closed = true;
569
- clearInterval(this.#heartbeatTimer);
570
- if (!skipCancel) this.#sendCancel();
571
- this.#attachmentReject?.(new DirectFallbackError('TERMINAL_DIRECT_CLOSED'));
572
- this.#attachmentResolve = undefined;
573
- this.#attachmentReject = undefined;
574
- this.#unsubscribeLive?.();
575
- this.#unsubscribeLive = undefined;
576
- this.#channel.close?.();
577
- this.#peer.close?.();
578
- this.#live.close();
579
- this.#cleanupNative?.();
580
- this.#emit('close');
581
- }
582
-
583
- async cancelAndDispose() {
584
- const terminalClosed = this.#live.waitFor(
585
- (message) =>
586
- message.type === 'terminal.direct.closed' &&
587
- message.directSessionId === this.#terminalSessionId,
588
- undefined,
589
- 'DIRECT_CANCEL_TIMEOUT',
590
- 1000
591
- );
592
- const transportClosed = this.#live.waitFor(
593
- (message) =>
594
- message.type === 'direct.closed' && message.directSessionId === this.#transportSessionId,
595
- undefined,
596
- 'DIRECT_CANCEL_TIMEOUT',
597
- 1000
598
- );
599
- this.#sendCancel();
600
- await Promise.allSettled([terminalClosed, transportClosed]);
601
- this.dispose({ skipCancel: true });
602
- }
603
-
604
- #handleMessage(raw) {
605
- if (this.#closed) return;
606
- if (!this.#attached) {
607
- let frame;
608
- try {
609
- frame = JSON.parse(rawToString(raw));
610
- } catch {
611
- this.#fail('TERMINAL_FRAME_INVALID');
612
- return;
613
- }
614
- if (
615
- !isRecord(frame) ||
616
- frame.type !== 'ATTACHED' ||
617
- frame.terminalId !== this.#terminalId ||
618
- !Number.isSafeInteger(frame.afterSequence) ||
619
- frame.afterSequence < 0
620
- ) {
621
- this.#fail('TERMINAL_ATTACH_FAILED');
622
- return;
623
- }
624
- this.#attached = true;
625
- this.#attachmentResolve?.();
626
- this.#attachmentResolve = undefined;
627
- this.#attachmentReject = undefined;
628
- return;
629
- }
630
- this.#emit(
631
- 'message',
632
- raw,
633
- typeof raw === 'string'
634
- ? false
635
- : Buffer.isBuffer(raw) || raw instanceof ArrayBuffer || ArrayBuffer.isView(raw)
636
- );
637
- }
638
-
639
- #handleControl(message) {
640
- if (message.type === 'terminal.direct.error' || message.type === 'terminal.direct.closed') {
641
- if (!this.#attached) this.#fail(message.code);
642
- else this.#fail(message.code);
643
- }
644
- if (message.type === 'direct.closed' || message.type === 'direct.error')
645
- this.#fail(message.code);
646
- }
647
-
648
- #fail(code) {
649
- if (this.#closed) return;
650
- const error = new Error(code);
651
- if (!this.#attached) this.#attachmentReject?.(new DirectFallbackError(code));
652
- this.#attachmentResolve = undefined;
653
- this.#attachmentReject = undefined;
654
- this.#emit('error', error);
655
- this.dispose();
656
- }
657
-
658
- #emit(type, ...args) {
659
- if (!this.#active) {
660
- this.#pendingEvents.push({ type, args });
661
- return;
662
- }
663
- if (type !== 'error' || this.listenerCount('error') > 0) this.emit(type, ...args);
664
- }
665
-
666
- #sendCancel() {
667
- if (this.#cancelSent) return;
668
- this.#cancelSent = true;
669
- this.#live.send({
670
- type: 'terminal.direct.cancel',
671
- directSessionId: this.#terminalSessionId
672
- });
673
- this.#live.send({
674
- type: 'direct.cancel',
675
- directSessionId: this.#transportSessionId,
676
- reason: 'CLI_CLOSED'
677
- });
678
- }
679
- }
680
-
681
- async function cancelSession(live, type, directSessionId) {
682
- if (live === undefined) return;
683
- const closed = live.waitFor(
684
- (message) =>
685
- message.type === `${type.replace('.cancel', '.closed')}` &&
686
- message.directSessionId === directSessionId,
687
- undefined,
688
- 'DIRECT_CANCEL_TIMEOUT',
689
- 1000
690
- );
691
- live.send({
692
- type,
693
- directSessionId,
694
- ...(type === 'direct.cancel' ? { reason: 'CLI_FALLBACK' } : {})
695
- });
696
- await closed.catch(() => undefined);
697
- }
698
-
699
- function withTimeout(promise, timeoutMs, code, signal) {
700
- let timer;
701
- let onAbort;
702
- return Promise.race([
703
- promise,
704
- new Promise((_, reject) => {
705
- timer = setTimeout(() => reject(new DirectFallbackError(code)), timeoutMs);
706
- }),
707
- ...(signal
708
- ? [
709
- new Promise((_, reject) => {
710
- onAbort = () => reject(new DirectFallbackError('DIRECT_RACE_CANCELLED'));
711
- if (signal.aborted) onAbort();
712
- else signal.addEventListener('abort', onAbort, { once: true });
713
- })
714
- ]
715
- : [])
716
- ]).finally(() => {
717
- clearTimeout(timer);
718
- if (onAbort) signal.removeEventListener('abort', onAbort);
719
- });
720
- }
721
-
722
343
  function requestId() {
723
344
  return randomUUID().replaceAll('-', '');
724
345
  }
725
346
 
726
- function rawToString(raw) {
727
- if (typeof raw === 'string') return raw;
728
- if (Buffer.isBuffer(raw)) return raw.toString('utf8');
729
- if (raw instanceof ArrayBuffer) return Buffer.from(raw).toString('utf8');
730
- if (ArrayBuffer.isView(raw))
731
- return Buffer.from(raw.buffer, raw.byteOffset, raw.byteLength).toString('utf8');
732
- return String(raw);
733
- }
734
-
735
- function isRecord(value) {
736
- return value !== null && typeof value === 'object' && !Array.isArray(value);
737
- }
738
-
739
347
  function localOfferSignal(sdp) {
740
348
  const fingerprint = extractFingerprint(sdp);
741
349
  if (fingerprint === undefined) throw new DirectFallbackError('DIRECT_FINGERPRINT_INVALID');
package/src/errors.mjs ADDED
@@ -0,0 +1,11 @@
1
+ export class MarctlError extends Error {
2
+ constructor(message, options = {}) {
3
+ super(message, options);
4
+ this.name = 'MarctlError';
5
+ this.code = options.code;
6
+ this.requestId = options.requestId;
7
+ this.upstreamRequestId = options.upstreamRequestId;
8
+ this.details = options.details;
9
+ this.statusCode = options.statusCode;
10
+ }
11
+ }