@myagentroam/marmgr 0.9.112 → 0.9.113
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 +1 -0
- package/src/client.mjs +5 -2
- package/src/direct.mjs +27 -5
- package/src/terminal.mjs +168 -3
package/package.json
CHANGED
package/src/cli.mjs
CHANGED
package/src/client.mjs
CHANGED
|
@@ -76,7 +76,9 @@ export class MarmgrClient {
|
|
|
76
76
|
!isNonEmptyString(payload.manifest.nodeGeneration)
|
|
77
77
|
)
|
|
78
78
|
throw new MarmgrError(this.#translate('errors.serverInvalidManifest'));
|
|
79
|
-
return payload.
|
|
79
|
+
return payload.deferredRelay === true
|
|
80
|
+
? { ...payload.manifest, deferredRelay: true }
|
|
81
|
+
: payload.manifest;
|
|
80
82
|
}
|
|
81
83
|
|
|
82
84
|
async createAttachTicket({ nodeId, terminalId, afterSequence }) {
|
|
@@ -105,7 +107,7 @@ export class MarmgrClient {
|
|
|
105
107
|
});
|
|
106
108
|
}
|
|
107
109
|
|
|
108
|
-
openTerminalWebSocket(WebSocketClass, { terminalId, ticket }) {
|
|
110
|
+
openTerminalWebSocket(WebSocketClass, { terminalId, ticket, deferred = false }) {
|
|
109
111
|
validateTerminalId(terminalId, this.#translate);
|
|
110
112
|
if (!isNonEmptyString(ticket))
|
|
111
113
|
throw new MarmgrError(this.#translate('errors.terminalTicketInvalid'));
|
|
@@ -115,6 +117,7 @@ export class MarmgrClient {
|
|
|
115
117
|
`${this.#serverUrl}/`
|
|
116
118
|
);
|
|
117
119
|
url.searchParams.set('ticket', ticket);
|
|
120
|
+
if (deferred) url.searchParams.set('deferred', '1');
|
|
118
121
|
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
119
122
|
|
|
120
123
|
try {
|
package/src/direct.mjs
CHANGED
|
@@ -3,6 +3,7 @@ import { EventEmitter } from 'node:events';
|
|
|
3
3
|
|
|
4
4
|
export const DIRECT_PREPARE_TIMEOUT_MS = 5000;
|
|
5
5
|
export const DIRECT_SETUP_TIMEOUT_MS = 15000;
|
|
6
|
+
export const DIRECT_ICE_TIMEOUT_MS = 3000;
|
|
6
7
|
export const DIRECT_HEARTBEAT_MS = 10000;
|
|
7
8
|
|
|
8
9
|
export class DirectFallbackError extends Error {
|
|
@@ -29,7 +30,9 @@ export async function openDirectTerminal({
|
|
|
29
30
|
afterSequence = 0,
|
|
30
31
|
timeoutMs = DIRECT_SETUP_TIMEOUT_MS,
|
|
31
32
|
heartbeatMs = DIRECT_HEARTBEAT_MS,
|
|
32
|
-
peerConnectionFactory
|
|
33
|
+
peerConnectionFactory,
|
|
34
|
+
onTransportReady,
|
|
35
|
+
signal
|
|
33
36
|
}) {
|
|
34
37
|
if (typeof client.getTerminalManifest !== 'function')
|
|
35
38
|
throw new DirectFallbackError('DIRECT_MANIFEST_UNAVAILABLE');
|
|
@@ -183,13 +186,19 @@ export async function openDirectTerminal({
|
|
|
183
186
|
}
|
|
184
187
|
});
|
|
185
188
|
try {
|
|
186
|
-
await withTimeout(
|
|
189
|
+
await withTimeout(
|
|
190
|
+
transportReady,
|
|
191
|
+
Math.min(timeoutMs, DIRECT_ICE_TIMEOUT_MS),
|
|
192
|
+
'DIRECT_SETUP_TIMEOUT',
|
|
193
|
+
signal
|
|
194
|
+
);
|
|
187
195
|
} finally {
|
|
188
196
|
transportNegotiationActive = false;
|
|
189
197
|
transportControlCleanup?.();
|
|
190
198
|
transportControlCleanup = undefined;
|
|
191
199
|
}
|
|
192
200
|
bootstrapChannel?.close?.();
|
|
201
|
+
if (onTransportReady) await onTransportReady();
|
|
193
202
|
|
|
194
203
|
const terminalPrepared = await live.request(
|
|
195
204
|
{
|
|
@@ -665,14 +674,27 @@ async function cancelSession(live, type, directSessionId) {
|
|
|
665
674
|
await closed.catch(() => undefined);
|
|
666
675
|
}
|
|
667
676
|
|
|
668
|
-
function withTimeout(promise, timeoutMs, code) {
|
|
677
|
+
function withTimeout(promise, timeoutMs, code, signal) {
|
|
669
678
|
let timer;
|
|
679
|
+
let onAbort;
|
|
670
680
|
return Promise.race([
|
|
671
681
|
promise,
|
|
672
682
|
new Promise((_, reject) => {
|
|
673
683
|
timer = setTimeout(() => reject(new DirectFallbackError(code)), timeoutMs);
|
|
674
|
-
})
|
|
675
|
-
|
|
684
|
+
}),
|
|
685
|
+
...(signal
|
|
686
|
+
? [
|
|
687
|
+
new Promise((_, reject) => {
|
|
688
|
+
onAbort = () => reject(new DirectFallbackError('DIRECT_RACE_CANCELLED'));
|
|
689
|
+
if (signal.aborted) onAbort();
|
|
690
|
+
else signal.addEventListener('abort', onAbort, { once: true });
|
|
691
|
+
})
|
|
692
|
+
]
|
|
693
|
+
: [])
|
|
694
|
+
]).finally(() => {
|
|
695
|
+
clearTimeout(timer);
|
|
696
|
+
if (onAbort) signal.removeEventListener('abort', onAbort);
|
|
697
|
+
});
|
|
676
698
|
}
|
|
677
699
|
|
|
678
700
|
function requestId() {
|
package/src/terminal.mjs
CHANGED
|
@@ -17,7 +17,8 @@ export async function runTerminalSession({
|
|
|
17
17
|
signals = process,
|
|
18
18
|
translate = createTranslator(),
|
|
19
19
|
connectionTimeoutMs = DEFAULT_CONNECTION_TIMEOUT_MS,
|
|
20
|
-
openDirectTerminal: openDirectTerminalImpl
|
|
20
|
+
openDirectTerminal: openDirectTerminalImpl,
|
|
21
|
+
raceConnections = false
|
|
21
22
|
}) {
|
|
22
23
|
const dimensions = getDimensions(stdout);
|
|
23
24
|
let terminalId;
|
|
@@ -34,14 +35,29 @@ export async function runTerminalSession({
|
|
|
34
35
|
try {
|
|
35
36
|
const openDirectTerminal =
|
|
36
37
|
openDirectTerminalImpl ?? (await import('./direct.mjs')).openDirectTerminal;
|
|
37
|
-
|
|
38
|
+
const directOptions = {
|
|
38
39
|
client,
|
|
39
40
|
WebSocketClass,
|
|
40
41
|
nodeId,
|
|
41
42
|
terminalId,
|
|
42
43
|
afterSequence: 0,
|
|
43
44
|
timeoutMs: connectionTimeoutMs
|
|
44
|
-
}
|
|
45
|
+
};
|
|
46
|
+
const canRace =
|
|
47
|
+
raceConnections &&
|
|
48
|
+
(await client.getTerminalManifest(nodeId).catch(() => undefined))?.deferredRelay === true;
|
|
49
|
+
transport = canRace
|
|
50
|
+
? await raceTerminalConnections({
|
|
51
|
+
openDirectTerminal,
|
|
52
|
+
directOptions,
|
|
53
|
+
client,
|
|
54
|
+
WebSocketClass,
|
|
55
|
+
nodeId,
|
|
56
|
+
terminalId,
|
|
57
|
+
translate,
|
|
58
|
+
connectionTimeoutMs
|
|
59
|
+
})
|
|
60
|
+
: await openDirectTerminal(directOptions);
|
|
45
61
|
result = await runAttachedTerminal({
|
|
46
62
|
transport,
|
|
47
63
|
terminalId,
|
|
@@ -91,6 +107,74 @@ export async function runTerminalSession({
|
|
|
91
107
|
return result;
|
|
92
108
|
}
|
|
93
109
|
|
|
110
|
+
async function raceTerminalConnections({
|
|
111
|
+
openDirectTerminal,
|
|
112
|
+
directOptions,
|
|
113
|
+
client,
|
|
114
|
+
WebSocketClass,
|
|
115
|
+
nodeId,
|
|
116
|
+
terminalId,
|
|
117
|
+
translate,
|
|
118
|
+
connectionTimeoutMs
|
|
119
|
+
}) {
|
|
120
|
+
const abort = new AbortController();
|
|
121
|
+
const relayAbort = new AbortController();
|
|
122
|
+
let selected;
|
|
123
|
+
let announce;
|
|
124
|
+
const first = new Promise((resolve) => {
|
|
125
|
+
announce = resolve;
|
|
126
|
+
});
|
|
127
|
+
const select = (candidate) => {
|
|
128
|
+
if (selected) return false;
|
|
129
|
+
selected = candidate;
|
|
130
|
+
announce(candidate);
|
|
131
|
+
if (candidate === 'relay') abort.abort();
|
|
132
|
+
else relayAbort.abort();
|
|
133
|
+
return true;
|
|
134
|
+
};
|
|
135
|
+
const direct = openDirectTerminal({
|
|
136
|
+
...directOptions,
|
|
137
|
+
signal: abort.signal,
|
|
138
|
+
onTransportReady: () => {
|
|
139
|
+
if (!select('direct')) throw new Error('DIRECT_RACE_CANCELLED');
|
|
140
|
+
}
|
|
141
|
+
}).then(
|
|
142
|
+
(value) => ({ value }),
|
|
143
|
+
(error) => ({ error })
|
|
144
|
+
);
|
|
145
|
+
const relay = prepareRelayTransport({
|
|
146
|
+
client,
|
|
147
|
+
WebSocketClass,
|
|
148
|
+
nodeId,
|
|
149
|
+
terminalId,
|
|
150
|
+
translate,
|
|
151
|
+
connectionTimeoutMs,
|
|
152
|
+
signal: relayAbort.signal
|
|
153
|
+
}).then(
|
|
154
|
+
(value) => {
|
|
155
|
+
if (!select('relay')) value.close();
|
|
156
|
+
return { value };
|
|
157
|
+
},
|
|
158
|
+
(error) => ({ error })
|
|
159
|
+
);
|
|
160
|
+
const winner = await Promise.race([
|
|
161
|
+
first,
|
|
162
|
+
Promise.all([direct, relay]).then(() => selected ?? 'none')
|
|
163
|
+
]);
|
|
164
|
+
if (winner === 'direct') {
|
|
165
|
+
const result = await direct;
|
|
166
|
+
if (result.error) throw result.error;
|
|
167
|
+
return result.value;
|
|
168
|
+
}
|
|
169
|
+
if (winner === 'relay') {
|
|
170
|
+
const result = await relay;
|
|
171
|
+
if (result.error) throw result.error;
|
|
172
|
+
return result.value.activate();
|
|
173
|
+
}
|
|
174
|
+
const [directResult, relayResult] = await Promise.all([direct, relay]);
|
|
175
|
+
throw relayResult.error ?? directResult.error;
|
|
176
|
+
}
|
|
177
|
+
|
|
94
178
|
export function splitInput(text, maxBytes = MAX_INPUT_BYTES) {
|
|
95
179
|
const value = Buffer.isBuffer(text) ? text.toString('utf8') : String(text);
|
|
96
180
|
const chunks = [];
|
|
@@ -144,6 +228,87 @@ async function openRelayTransport({
|
|
|
144
228
|
}
|
|
145
229
|
}
|
|
146
230
|
|
|
231
|
+
async function prepareRelayTransport({
|
|
232
|
+
client,
|
|
233
|
+
WebSocketClass,
|
|
234
|
+
nodeId,
|
|
235
|
+
terminalId,
|
|
236
|
+
translate,
|
|
237
|
+
connectionTimeoutMs,
|
|
238
|
+
signal
|
|
239
|
+
}) {
|
|
240
|
+
const ticket = await client.createAttachTicket({ nodeId, terminalId });
|
|
241
|
+
if (signal?.aborted) throw new MarmgrError(translate('errors.terminalAttachConnection'));
|
|
242
|
+
const socket = client.openTerminalWebSocket(WebSocketClass, {
|
|
243
|
+
terminalId,
|
|
244
|
+
ticket,
|
|
245
|
+
deferred: true
|
|
246
|
+
});
|
|
247
|
+
// Keep an error listener after readiness until the socket is discarded.
|
|
248
|
+
socket.on('error', () => {});
|
|
249
|
+
let activated = false;
|
|
250
|
+
const ready = new Promise((resolve, reject) => {
|
|
251
|
+
const timer = setTimeout(() => fail(), connectionTimeoutMs);
|
|
252
|
+
const cleanup = () => {
|
|
253
|
+
clearTimeout(timer);
|
|
254
|
+
socket.removeListener?.('message', onMessage);
|
|
255
|
+
socket.removeListener?.('error', fail);
|
|
256
|
+
socket.removeListener?.('close', fail);
|
|
257
|
+
signal?.removeEventListener('abort', fail);
|
|
258
|
+
};
|
|
259
|
+
const fail = () => {
|
|
260
|
+
cleanup();
|
|
261
|
+
try {
|
|
262
|
+
socket.close();
|
|
263
|
+
} catch {
|
|
264
|
+
/* Connection not yet established. */
|
|
265
|
+
}
|
|
266
|
+
reject(new MarmgrError(translate('errors.terminalAttachConnection')));
|
|
267
|
+
};
|
|
268
|
+
const onMessage = (raw) => {
|
|
269
|
+
let frame;
|
|
270
|
+
try {
|
|
271
|
+
frame = JSON.parse(String(raw));
|
|
272
|
+
} catch {
|
|
273
|
+
return fail();
|
|
274
|
+
}
|
|
275
|
+
if (frame?.type !== 'ready' || frame.terminalId !== terminalId) return fail();
|
|
276
|
+
cleanup();
|
|
277
|
+
resolve();
|
|
278
|
+
};
|
|
279
|
+
socket.on('message', onMessage);
|
|
280
|
+
socket.on('error', fail);
|
|
281
|
+
socket.on('close', fail);
|
|
282
|
+
signal?.addEventListener('abort', fail, { once: true });
|
|
283
|
+
if (signal?.aborted) fail();
|
|
284
|
+
});
|
|
285
|
+
await ready;
|
|
286
|
+
return {
|
|
287
|
+
async activate() {
|
|
288
|
+
if (activated || socket.readyState !== 1)
|
|
289
|
+
throw new MarmgrError(translate('errors.terminalAttachConnection'));
|
|
290
|
+
activated = true;
|
|
291
|
+
const transport = new BufferedRelayTransport({
|
|
292
|
+
socket,
|
|
293
|
+
terminalId,
|
|
294
|
+
translate,
|
|
295
|
+
connectionTimeoutMs
|
|
296
|
+
});
|
|
297
|
+
try {
|
|
298
|
+
socket.send(JSON.stringify({ type: 'activate', terminalId }));
|
|
299
|
+
await transport.waitForAttachment();
|
|
300
|
+
return transport;
|
|
301
|
+
} catch (error) {
|
|
302
|
+
transport.close();
|
|
303
|
+
throw error;
|
|
304
|
+
}
|
|
305
|
+
},
|
|
306
|
+
close() {
|
|
307
|
+
socket.close();
|
|
308
|
+
}
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
|
|
147
312
|
class BufferedRelayTransport extends EventEmitter {
|
|
148
313
|
#socket;
|
|
149
314
|
#terminalId;
|