@myagentroam/marctl 0.11.2 → 0.11.4
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/package.json +1 -1
- package/src/cli.mjs +54 -29
- package/src/client.mjs +1 -1
- package/src/config.mjs +1 -11
- package/src/direct-control.mjs +225 -0
- package/src/direct-errors.mjs +48 -0
- package/src/direct-process.mjs +40 -17
- package/src/direct-transport.mjs +235 -0
- package/src/direct.mjs +102 -494
- package/src/errors.mjs +11 -0
- package/src/i18n/en-US.mjs +6 -3
- package/src/i18n/zh-CN.mjs +5 -3
- package/src/scp-channel.mjs +99 -0
- package/src/scp-copy.mjs +46 -0
- package/src/scp-endpoint.mjs +29 -0
- package/src/scp-protocol.mjs +2 -0
- package/src/scp-workers.mjs +179 -0
- package/src/scp.mjs +26 -344
- package/src/session-args.mjs +176 -0
- package/src/session-input.mjs +88 -0
- package/src/session-prepare.mjs +51 -0
- package/src/session-target.mjs +25 -0
- package/src/session-wait.mjs +232 -0
- package/src/sessions.mjs +27 -567
- package/src/terminal-frame.mjs +48 -0
- package/src/terminal-interaction.mjs +177 -0
- package/src/terminal-race.mjs +117 -0
- package/src/terminal-relay.mjs +305 -0
- package/src/terminal.mjs +50 -603
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 {
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
message
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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
|
+
}
|