@livedesk/hub 0.1.55 → 0.1.57

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,1538 @@
1
+ import crypto from 'node:crypto';
2
+ import WebSocket from 'ws';
3
+ import nodeDataChannel from 'node-datachannel';
4
+ import {
5
+ DIRECT_CONSOLE_MAX_MESSAGE_BYTES,
6
+ DIRECT_CONSOLE_WIRE_CHUNK_BYTES,
7
+ createDirectConsoleWireAssembler,
8
+ createDirectConsoleWireBudget,
9
+ encodeDirectConsoleWireMessage
10
+ } from '../../runtime-core/src/console-direct-wire.js';
11
+
12
+ const DEFAULT_SIGNAL_URL = 'https://livedesk-wake.lovecrdm.workers.dev';
13
+ const DEFAULT_STUN_URLS = Object.freeze(['stun:stun.cloudflare.com:3478']);
14
+ const DEFAULT_RETRY_DELAYS_MS = Object.freeze([1_000, 2_000, 5_000, 10_000, 20_000]);
15
+ const SIGNAL_HTTP_429_RETRY_MS = 60_000;
16
+ const SIGNAL_MESSAGE_MAX_BYTES = 32 * 1024;
17
+ const SDP_MAX_BYTES = 24 * 1024;
18
+ const ICE_CANDIDATE_MAX_BYTES = 4 * 1024;
19
+ const CONTROL_LABEL = 'livedesk-control-v1';
20
+ const LOGICAL_WS_LABEL_PREFIX = 'livedesk-ws-v1:';
21
+ const LOGICAL_WS_PURPOSES = new Set(['frame', 'atlas', 'input', 'audio']);
22
+ const MAX_PEERS = 4;
23
+ const MAX_LOGICAL_CHANNELS_PER_PEER = 16;
24
+ const MAX_PENDING_HTTP_PER_PEER = 32;
25
+ const MAX_HTTP_REQUEST_BYTES = 1024 * 1024;
26
+ const MAX_HTTP_RESPONSE_BYTES = 4 * 1024 * 1024;
27
+ const MAX_SHARED_ASSEMBLY_BYTES = 12 * 1024 * 1024;
28
+ const MAX_FRAME_MESSAGE_BYTES = (8 * 1024 * 1024) + DIRECT_CONSOLE_WIRE_CHUNK_BYTES;
29
+ const MAX_PENDING_MEDIA_WIRE_MESSAGES = 8;
30
+ const MAX_DATA_CHANNEL_BUFFERED_BYTES = 12 * 1024 * 1024;
31
+ const MAX_LOCAL_WS_BUFFERED_BYTES = 4 * 1024 * 1024;
32
+ const HTTP_TIMEOUT_MS = 15_000;
33
+ const ICE_CONNECT_TIMEOUT_MS = 10_000;
34
+ const PEER_DISCONNECTED_TIMEOUT_MS = 5_000;
35
+ const ACCESS_TOKEN_TIMEOUT_MS = 8_000;
36
+ const SIGNAL_READY_TIMEOUT_MS = 10_000;
37
+ const CONTROL_ASSEMBLY_TIMEOUT_MS = 15_000;
38
+ const MEDIA_ASSEMBLY_TIMEOUT_MS = 500;
39
+ const LOGICAL_BIND_TIMEOUT_MS = 5_000;
40
+ const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
41
+
42
+ function normalizeSignalUrl(value) {
43
+ const source = String(value || DEFAULT_SIGNAL_URL).trim();
44
+ if (!source || /^(off|disabled|none)$/i.test(source)) return '';
45
+ try {
46
+ const url = new URL(source);
47
+ url.protocol = url.protocol === 'http:' ? 'ws:' : url.protocol === 'https:' ? 'wss:' : url.protocol;
48
+ if (url.protocol !== 'ws:' && url.protocol !== 'wss:') return null;
49
+ url.pathname = '/v2/rtc/hub';
50
+ url.search = '';
51
+ url.hash = '';
52
+ return url;
53
+ } catch {
54
+ return null;
55
+ }
56
+ }
57
+
58
+ function normalizeStunUrls(value) {
59
+ const values = Array.isArray(value)
60
+ ? value
61
+ : String(value || '').split(/[\s,]+/);
62
+ const urls = [...new Set(values
63
+ .map(item => String(item || '').trim())
64
+ .filter(url => /^stun:[^\s@]+$/i.test(url)))];
65
+ const resolved = urls.length > 0 ? urls : [...DEFAULT_STUN_URLS];
66
+ return Object.freeze(resolved);
67
+ }
68
+
69
+ function byteLength(value) {
70
+ return Buffer.byteLength(String(value || ''), 'utf8');
71
+ }
72
+
73
+ function parseJson(value) {
74
+ try {
75
+ return JSON.parse(String(value || ''));
76
+ } catch {
77
+ return null;
78
+ }
79
+ }
80
+
81
+ function safeContentHeaders(headers) {
82
+ const result = {};
83
+ for (const name of ['accept', 'content-type', 'if-match', 'range', 'x-livedesk-csrf']) {
84
+ const value = headers && typeof headers === 'object'
85
+ ? headers[name] || headers[name.toUpperCase()]
86
+ : '';
87
+ if (value) result[name] = String(value).slice(0, 512);
88
+ }
89
+ return result;
90
+ }
91
+
92
+ function normalizeHttpBaseUrl(value) {
93
+ try {
94
+ const url = new URL(String(value || '').trim());
95
+ if (!['http:', 'https:'].includes(url.protocol)
96
+ || url.username
97
+ || url.password
98
+ || url.pathname !== '/'
99
+ || url.search
100
+ || url.hash) {
101
+ return null;
102
+ }
103
+ return url;
104
+ } catch {
105
+ return null;
106
+ }
107
+ }
108
+
109
+ function canonicalHttpRequestUrl(value, httpBaseUrl) {
110
+ const source = String(value || '');
111
+ const queryIndex = source.indexOf('?');
112
+ const rawPathname = queryIndex >= 0 ? source.slice(0, queryIndex) : source;
113
+ if (!(httpBaseUrl instanceof URL)
114
+ || !rawPathname.startsWith('/')
115
+ || rawPathname.startsWith('//')
116
+ || source.includes('#')
117
+ || rawPathname.includes('\\')
118
+ || /\/{2,}/.test(rawPathname)
119
+ || /(?:^|\/)\.{1,2}(?:\/|$)/.test(rawPathname)
120
+ || /%(?:25)*(?:2e|2f|5c)/i.test(rawPathname)) {
121
+ return null;
122
+ }
123
+ try {
124
+ const decodedPathname = decodeURIComponent(rawPathname);
125
+ if (/[\u0000-\u001f\u007f]/.test(decodedPathname)
126
+ || decodedPathname.includes('\\')
127
+ || /\/{2,}/.test(decodedPathname)
128
+ || /(?:^|\/)\.{1,2}(?:\/|$)/.test(decodedPathname)) {
129
+ return null;
130
+ }
131
+ const canonical = new URL(source, httpBaseUrl);
132
+ if (canonical.origin !== httpBaseUrl.origin
133
+ || canonical.protocol !== httpBaseUrl.protocol
134
+ || canonical.username
135
+ || canonical.password
136
+ || canonical.hash
137
+ || /\/{2,}/.test(canonical.pathname)) {
138
+ return null;
139
+ }
140
+ return canonical;
141
+ } catch {
142
+ return null;
143
+ }
144
+ }
145
+
146
+ function allowedHttpRequest(method, pathname) {
147
+ if (!['GET', 'POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) return false;
148
+ pathname = String(pathname || '');
149
+ if (pathname === '/api/health'
150
+ || pathname === '/api/runtime/status'
151
+ || pathname === '/api/runtime/restart'
152
+ || pathname === '/api/auth/status'
153
+ || pathname === '/api/remote/status'
154
+ || pathname === '/api/hub/status'
155
+ || pathname === '/api/update/status'
156
+ || pathname === '/api/update/apply') {
157
+ return true;
158
+ }
159
+ if (/^\/api\/settings(?:\/|$)/.test(pathname)
160
+ || /^\/api\/captures(?:\/|$)/.test(pathname)
161
+ || /^\/api\/capture-sessions(?:\/|$)/.test(pathname)
162
+ || /^\/api\/remote\/devices(?:\/|$)/.test(pathname)
163
+ || /^\/api\/remote\/frames$/.test(pathname)
164
+ || /^\/api\/remote\/filesystem(?:\/|$)/.test(pathname)
165
+ || /^\/api\/remote\/files(?:\/|$)/.test(pathname)
166
+ || /^\/api\/remote\/tasks(?:\/|$)/.test(pathname)
167
+ || /^\/api\/remote\/license(?:\/sync)?$/.test(pathname)) {
168
+ return pathname !== '/api/remote/registry-credentials'
169
+ && pathname !== '/api/remote/pairing-pin'
170
+ && !pathname.startsWith('/api/remote/host-target')
171
+ && !pathname.startsWith('/api/remote/synthetic');
172
+ }
173
+ return false;
174
+ }
175
+
176
+ const WEBSOCKET_PATH_BY_PURPOSE = Object.freeze({
177
+ frame: '/api/remote/frames/ws',
178
+ atlas: '/api/remote/atlas/ws',
179
+ input: '/api/remote/input/ws',
180
+ audio: '/api/remote/audio/ws'
181
+ });
182
+
183
+ function allowedWebSocketPath(path, purpose) {
184
+ return String(path || '').split('?')[0] === WEBSOCKET_PATH_BY_PURPOSE[purpose];
185
+ }
186
+
187
+ function parseLogicalWebSocketLabel(label) {
188
+ const source = String(label || '');
189
+ if (!source.startsWith(LOGICAL_WS_LABEL_PREFIX)) return null;
190
+ const remainder = source.slice(LOGICAL_WS_LABEL_PREFIX.length);
191
+ const separator = remainder.lastIndexOf(':');
192
+ if (separator <= 0) return null;
193
+ const channelId = remainder.slice(0, separator);
194
+ const purpose = remainder.slice(separator + 1);
195
+ if (!UUID_PATTERN.test(channelId) || !LOGICAL_WS_PURPOSES.has(purpose)) return null;
196
+ return Object.freeze({ channelId, purpose });
197
+ }
198
+
199
+ function candidateIsRelay(candidate) {
200
+ return /(?:^|\s)typ\s+relay(?:\s|$)/i.test(String(candidate || ''));
201
+ }
202
+
203
+ function peerSelectedRelay(peer) {
204
+ try {
205
+ const pair = peer?.getSelectedCandidatePair?.();
206
+ return String(pair?.local?.type || '').toLowerCase() === 'relay'
207
+ || String(pair?.remote?.type || '').toLowerCase() === 'relay'
208
+ || candidateIsRelay(pair?.local?.candidate)
209
+ || candidateIsRelay(pair?.remote?.candidate);
210
+ } catch {
211
+ return false;
212
+ }
213
+ }
214
+
215
+ function channelIsOpen(channel) {
216
+ try {
217
+ return channel?.isOpen?.() === true;
218
+ } catch {
219
+ return false;
220
+ }
221
+ }
222
+
223
+ function socketIsOpen(socket, WebSocketImpl) {
224
+ return Boolean(socket) && socket.readyState === Number(WebSocketImpl.OPEN ?? 1);
225
+ }
226
+
227
+ function decodeBase64Bounded(value, maxBytes) {
228
+ const source = String(value || '');
229
+ if (!source) return Buffer.alloc(0);
230
+ if (source.length > Math.ceil(maxBytes / 3) * 4 + 8
231
+ || !/^[A-Za-z0-9+/]*={0,2}$/.test(source)) {
232
+ throw new Error('invalid-console-http-body');
233
+ }
234
+ const bytes = Buffer.from(source, 'base64');
235
+ if (bytes.byteLength > maxBytes) throw new Error('console-http-body-too-large');
236
+ return bytes;
237
+ }
238
+
239
+ async function readBoundedResponseBytes(response, maxBytes) {
240
+ const contentLength = Number(response?.headers?.get?.('content-length') || 0);
241
+ if (Number.isFinite(contentLength) && contentLength > maxBytes) {
242
+ throw new Error('console-http-response-too-large');
243
+ }
244
+ const reader = response?.body?.getReader?.();
245
+ if (!reader) {
246
+ const bytes = Buffer.from(await response.arrayBuffer());
247
+ if (bytes.byteLength > maxBytes) throw new Error('console-http-response-too-large');
248
+ return bytes;
249
+ }
250
+ const chunks = [];
251
+ let total = 0;
252
+ try {
253
+ while (true) {
254
+ const result = await reader.read();
255
+ if (result.done) break;
256
+ const chunk = Buffer.from(result.value || []);
257
+ total += chunk.byteLength;
258
+ if (total > maxBytes) {
259
+ await reader.cancel('console-http-response-too-large').catch(() => {});
260
+ throw new Error('console-http-response-too-large');
261
+ }
262
+ chunks.push(chunk);
263
+ }
264
+ } finally {
265
+ reader.releaseLock?.();
266
+ }
267
+ return Buffer.concat(chunks, total);
268
+ }
269
+
270
+ export function createHubConsoleDirect(options = {}) {
271
+ const signalUrl = normalizeSignalUrl(options.url);
272
+ const deviceId = String(options.deviceId || '').trim();
273
+ const requestedHubInstanceId = String(options.hubInstanceId || '').trim();
274
+ const hubInstanceId = UUID_PATTERN.test(requestedHubInstanceId)
275
+ ? requestedHubInstanceId
276
+ : crypto.randomUUID();
277
+ const httpBaseUrl = String(options.httpBaseUrl || '').replace(/\/+$/, '');
278
+ const canonicalHttpBaseUrl = normalizeHttpBaseUrl(httpBaseUrl);
279
+ const stunUrls = normalizeStunUrls(options.stunUrls);
280
+ const SignalingWebSocketImpl = options.SignalingWebSocketImpl || options.WebSocketImpl || WebSocket;
281
+ const LocalWebSocketImpl = options.LocalWebSocketImpl || options.WebSocketImpl || WebSocket;
282
+ const fetchImpl = options.fetchImpl || globalThis.fetch;
283
+ const getAccessToken = typeof options.getAccessToken === 'function'
284
+ ? options.getAccessToken
285
+ : async () => String(options.accessToken || '').trim();
286
+ const createPeerConnection = typeof options.createPeerConnection === 'function'
287
+ ? options.createPeerConnection
288
+ : (name, config) => new nodeDataChannel.PeerConnection(name, config);
289
+ const retryDelaysMs = Array.isArray(options.retryDelaysMs) && options.retryDelaysMs.length > 0
290
+ ? options.retryDelaysMs.map(value => Math.max(0, Number(value) || 0))
291
+ : DEFAULT_RETRY_DELAYS_MS;
292
+ const iceConnectTimeoutMs = Math.max(1, Number(options.iceConnectTimeoutMs) || ICE_CONNECT_TIMEOUT_MS);
293
+ const peerDisconnectedTimeoutMs = Math.max(
294
+ 1,
295
+ Number(options.peerDisconnectedTimeoutMs) || PEER_DISCONNECTED_TIMEOUT_MS
296
+ );
297
+ const accessTokenTimeoutMs = Math.max(
298
+ 1,
299
+ Number(options.accessTokenTimeoutMs) || ACCESS_TOKEN_TIMEOUT_MS
300
+ );
301
+ const signalReadyTimeoutMs = Math.max(
302
+ 1,
303
+ Number(options.signalReadyTimeoutMs) || SIGNAL_READY_TIMEOUT_MS
304
+ );
305
+ const mediaAssemblyTimeoutMs = Math.max(
306
+ 1,
307
+ Number(options.mediaAssemblyTimeoutMs) || MEDIA_ASSEMBLY_TIMEOUT_MS
308
+ );
309
+ const logicalBindTimeoutMs = Math.max(
310
+ 1,
311
+ Number(options.logicalBindTimeoutMs) || LOGICAL_BIND_TIMEOUT_MS
312
+ );
313
+ const peers = new Map();
314
+ let signalSocket = null;
315
+ let signalGeneration = 0;
316
+ let peerGeneration = 0;
317
+ let retryTimer = null;
318
+ let accessTokenAttempt = null;
319
+ let signalReadyDeadline = null;
320
+ let retryAttempt = 0;
321
+ let retryNotBeforeAt = 0;
322
+ let nextRetryAt = '';
323
+ let stopped = true;
324
+ let state = signalUrl instanceof URL ? 'idle' : signalUrl === '' ? 'disabled' : 'error';
325
+ let hubEpoch = '';
326
+ let lastConnectedAt = '';
327
+ let lastError = signalUrl === null ? 'invalid-console-direct-signal-url' : '';
328
+ let lastHttpStatus = 0;
329
+ let rejectedRelayCandidates = 0;
330
+ let dataChannelBackpressureCloses = 0;
331
+ let malformedWireCloses = 0;
332
+ let wireBudgetCleanupFailures = 0;
333
+
334
+ const isOwnerActive = owner => !owner.retired
335
+ && owner.hubEpoch === hubEpoch
336
+ && peers.get(owner.consoleId) === owner
337
+ && owner.generation > 0;
338
+
339
+ const sendSignal = payload => {
340
+ if (!socketIsOpen(signalSocket, SignalingWebSocketImpl)) return false;
341
+ const serialized = JSON.stringify(payload);
342
+ if (byteLength(serialized) > SIGNAL_MESSAGE_MAX_BYTES) return false;
343
+ try {
344
+ signalSocket.send(serialized);
345
+ return true;
346
+ } catch {
347
+ return false;
348
+ }
349
+ };
350
+
351
+ const ownerEnvelope = owner => ({
352
+ consoleId: owner.consoleId,
353
+ connectionId: owner.connectionId,
354
+ hubEpoch: owner.hubEpoch
355
+ });
356
+
357
+ const disposeAssemblerTimer = channelState => {
358
+ if (channelState.assemblyTimer) clearTimeout(channelState.assemblyTimer);
359
+ channelState.assemblyTimer = null;
360
+ };
361
+
362
+ const disposePendingLogicalControl = (owner, channelId) => {
363
+ const pending = owner.pendingLogicalControl.get(channelId);
364
+ if (!pending) return null;
365
+ clearTimeout(pending.timer);
366
+ owner.pendingLogicalControl.delete(channelId);
367
+ return pending;
368
+ };
369
+
370
+ const lifecycleOwnerMatches = (owner, payload) => payload?.connectionId === owner.connectionId
371
+ && payload?.hubEpoch === owner.hubEpoch;
372
+
373
+ function sendLifecycleControl(owner, payload, options = {}) {
374
+ return sendControl(owner, {
375
+ ...payload,
376
+ connectionId: owner.connectionId,
377
+ hubEpoch: owner.hubEpoch
378
+ }, options);
379
+ }
380
+
381
+ const closeLocalSocket = (owner, channelState, code = 1000, reason = 'console-direct-channel-closed') => {
382
+ const socket = channelState.localSocket;
383
+ channelState.localSocket = null;
384
+ if (!socket) return;
385
+ try { socket.close(code, reason); } catch { /* exact socket is already closed */ }
386
+ try { socket.terminate?.(); } catch { /* exact socket is already closed */ }
387
+ };
388
+
389
+ const closeLogicalChannel = (owner, channelState, options = {}) => {
390
+ if (!channelState || channelState.closed) return;
391
+ if (options.notify === true && isOwnerActive(owner)) {
392
+ sendLifecycleControl(owner, {
393
+ type: 'ws-closed',
394
+ channelId: channelState.channelId,
395
+ code: Number(options.code || 1000),
396
+ reason: String(options.reason || 'console-direct-channel-closed').slice(0, 120)
397
+ }, { closeOnFailure: false });
398
+ }
399
+ channelState.closed = true;
400
+ owner.channels.delete(channelState.channelId);
401
+ disposePendingLogicalControl(owner, channelState.channelId);
402
+ disposeAssemblerTimer(channelState);
403
+ channelState.assembler.dispose();
404
+ closeLocalSocket(
405
+ owner,
406
+ channelState,
407
+ Number(options.code || 1000),
408
+ String(options.reason || 'console-direct-channel-closed').slice(0, 120)
409
+ );
410
+ try { channelState.channel.close(); } catch { /* exact channel is already closed */ }
411
+ };
412
+
413
+ const retirePeer = (owner, reason = 'console-direct-peer-closed', options = {}) => {
414
+ if (!owner || owner.retired) return;
415
+ owner.retired = true;
416
+ if (peers.get(owner.consoleId) === owner) peers.delete(owner.consoleId);
417
+ clearTimeout(owner.iceTimer);
418
+ owner.iceTimer = null;
419
+ clearTimeout(owner.disconnectTimer);
420
+ owner.disconnectTimer = null;
421
+ for (const pending of owner.pendingHttp.values()) {
422
+ clearTimeout(pending.timeout);
423
+ pending.cancelled = true;
424
+ try { pending.controller.abort(new Error(reason)); } catch { /* request is already terminal */ }
425
+ }
426
+ owner.pendingHttp.clear();
427
+ for (const channelId of [...owner.pendingLogicalControl.keys()]) {
428
+ disposePendingLogicalControl(owner, channelId);
429
+ }
430
+ for (const channelState of [...owner.channels.values()]) {
431
+ closeLogicalChannel(owner, channelState, { code: 1012, reason, notify: false });
432
+ }
433
+ if (owner.control) {
434
+ disposeAssemblerTimer(owner.control);
435
+ owner.control.assembler.dispose();
436
+ try { owner.control.channel.close(); } catch { /* exact channel is already closed */ }
437
+ owner.control = null;
438
+ }
439
+ const terminalBudget = owner.wireBudget.inspect();
440
+ if (terminalBudget.retainedBytes !== 0 || terminalBudget.reservationCount !== 0) {
441
+ wireBudgetCleanupFailures += 1;
442
+ lastError = 'console-direct-wire-budget-not-zero-after-peer-close';
443
+ }
444
+ try { owner.peer.close(); } catch { /* exact peer is already closed */ }
445
+ if (options.notify !== false) {
446
+ sendSignal({ type: 'rtc-close', ...ownerEnvelope(owner), reason: String(reason).slice(0, 120) });
447
+ }
448
+ };
449
+
450
+ const retireAllPeers = reason => {
451
+ for (const owner of [...peers.values()]) retirePeer(owner, reason, { notify: false });
452
+ };
453
+
454
+ const retireNegotiatingPeers = reason => {
455
+ for (const owner of [...peers.values()]) {
456
+ if (!owner.control || !channelIsOpen(owner.control.channel)) {
457
+ retirePeer(owner, reason, { notify: false });
458
+ }
459
+ }
460
+ };
461
+
462
+ const refreshPeerDisconnectDeadline = owner => {
463
+ if (!isOwnerActive(owner)) return;
464
+ const disconnected = owner.peerState === 'disconnected'
465
+ || owner.iceState === 'disconnected';
466
+ if (!disconnected) {
467
+ clearTimeout(owner.disconnectTimer);
468
+ owner.disconnectTimer = null;
469
+ return;
470
+ }
471
+ if (owner.disconnectTimer) return;
472
+ const timer = setTimeout(() => {
473
+ if (owner.disconnectTimer !== timer) return;
474
+ owner.disconnectTimer = null;
475
+ if (isOwnerActive(owner)
476
+ && (owner.peerState === 'disconnected' || owner.iceState === 'disconnected')) {
477
+ retirePeer(owner, 'console-direct-peer-disconnected-timeout');
478
+ }
479
+ }, peerDisconnectedTimeoutMs);
480
+ owner.disconnectTimer = timer;
481
+ timer.unref?.();
482
+ };
483
+
484
+ const ownerBufferedAmount = owner => {
485
+ let total = 0;
486
+ const states = [owner.control, ...owner.channels.values()];
487
+ for (const state of states) {
488
+ if (!state || state.closed) continue;
489
+ try { total += Math.max(0, Number(state.channel.bufferedAmount?.() || 0)); } catch {}
490
+ }
491
+ return total;
492
+ };
493
+
494
+ const channelBufferedAmount = channelState => {
495
+ if (!channelState || channelState.closed) return 0;
496
+ try {
497
+ return Math.max(0, Number(channelState.channel.bufferedAmount?.() || 0));
498
+ } catch {
499
+ return 0;
500
+ }
501
+ };
502
+
503
+ const releaseLogicalBacklogForControl = (owner, requiredBytes) => {
504
+ const purposePriority = { frame: 0, atlas: 1, audio: 2, input: 3 };
505
+ const candidates = [...owner.channels.values()]
506
+ .filter(state => !state.closed && channelBufferedAmount(state) > 0)
507
+ .sort((left, right) => (
508
+ (purposePriority[left.purpose] ?? 4) - (purposePriority[right.purpose] ?? 4)
509
+ || channelBufferedAmount(right) - channelBufferedAmount(left)
510
+ ));
511
+ for (const state of candidates) {
512
+ if (ownerBufferedAmount(owner) + requiredBytes <= MAX_DATA_CHANNEL_BUFFERED_BYTES) break;
513
+ dataChannelBackpressureCloses += 1;
514
+ closeLogicalChannel(owner, state, {
515
+ code: 1013,
516
+ reason: 'console-direct-control-headroom',
517
+ notify: false
518
+ });
519
+ }
520
+ };
521
+
522
+ function sendWire(owner, channelState, kind, value, maxMessageBytes, options = {}) {
523
+ if (!isOwnerActive(owner) || channelState.closed || !channelIsOpen(channelState.channel)) return false;
524
+ let chunks;
525
+ try {
526
+ const messageId = channelState.nextSendMessageId >>> 0;
527
+ channelState.nextSendMessageId = (messageId + 1) >>> 0;
528
+ chunks = encodeDirectConsoleWireMessage(value, { messageId, kind, maxMessageBytes });
529
+ } catch {
530
+ if (options.closeOnFailure !== false) {
531
+ if (channelState === owner.control) retirePeer(owner, 'console-direct-wire-encode-failed');
532
+ else closeLogicalChannel(owner, channelState, {
533
+ code: 1009,
534
+ reason: 'console-direct-wire-encode-failed',
535
+ notify: false
536
+ });
537
+ }
538
+ return false;
539
+ }
540
+ const wireBytes = chunks.reduce((total, chunk) => total + chunk.byteLength, 0);
541
+ if (channelState === owner.control
542
+ && ownerBufferedAmount(owner) + wireBytes > MAX_DATA_CHANNEL_BUFFERED_BYTES) {
543
+ releaseLogicalBacklogForControl(owner, wireBytes);
544
+ }
545
+ if (wireBytes > MAX_DATA_CHANNEL_BUFFERED_BYTES
546
+ || ownerBufferedAmount(owner) + wireBytes > MAX_DATA_CHANNEL_BUFFERED_BYTES) {
547
+ dataChannelBackpressureCloses += 1;
548
+ if (options.closeOnFailure !== false) {
549
+ if (channelState === owner.control) retirePeer(owner, 'console-direct-control-backpressure');
550
+ else closeLogicalChannel(owner, channelState, {
551
+ code: 1013,
552
+ reason: 'console-direct-datachannel-backpressure',
553
+ notify: false
554
+ });
555
+ }
556
+ return false;
557
+ }
558
+ for (const chunk of chunks) {
559
+ if (!isOwnerActive(owner) || channelState.closed || !channelIsOpen(channelState.channel)) return false;
560
+ try {
561
+ if (channelState.channel.sendMessageBinary(chunk) !== true) throw new Error('send-rejected');
562
+ } catch {
563
+ if (options.closeOnFailure !== false) {
564
+ if (channelState === owner.control) retirePeer(owner, 'console-direct-control-send-failed');
565
+ else closeLogicalChannel(owner, channelState, {
566
+ code: 1011,
567
+ reason: 'console-direct-datachannel-send-failed',
568
+ notify: false
569
+ });
570
+ }
571
+ return false;
572
+ }
573
+ }
574
+ return true;
575
+ }
576
+
577
+ const sendControl = (owner, payload, options = {}) => owner.control
578
+ ? sendWire(owner, owner.control, 'control', JSON.stringify(payload), DIRECT_CONSOLE_MAX_MESSAGE_BYTES, options)
579
+ : false;
580
+
581
+ const sendHttpResponse = (owner, requestId, response = {}) => sendControl(owner, {
582
+ type: 'http-response',
583
+ requestId,
584
+ status: Math.max(0, Number(response.status || 0)),
585
+ statusText: String(response.statusText || '').slice(0, 120),
586
+ headers: response.headers && typeof response.headers === 'object' ? response.headers : {},
587
+ bodyBase64: String(response.bodyBase64 || ''),
588
+ ...(response.error ? { error: String(response.error).slice(0, 240) } : {})
589
+ });
590
+
591
+ const handleHttpRequest = async (owner, payload) => {
592
+ const requestId = String(payload?.requestId || '');
593
+ const method = String(payload?.method || 'GET').toUpperCase();
594
+ const path = String(payload?.path || '');
595
+ const requestUrl = canonicalHttpRequestUrl(path, canonicalHttpBaseUrl);
596
+ if (!UUID_PATTERN.test(requestId)
597
+ || !requestUrl
598
+ || !allowedHttpRequest(method, requestUrl.pathname)) {
599
+ sendHttpResponse(owner, requestId, { error: 'console-route-not-allowed' });
600
+ return;
601
+ }
602
+ if (owner.pendingHttp.has(requestId)) {
603
+ sendHttpResponse(owner, requestId, { error: 'console-http-request-duplicate' });
604
+ return;
605
+ }
606
+ if (owner.pendingHttp.size >= MAX_PENDING_HTTP_PER_PEER) {
607
+ sendHttpResponse(owner, requestId, { error: 'console-http-capacity-reached' });
608
+ return;
609
+ }
610
+ let body;
611
+ try {
612
+ body = decodeBase64Bounded(payload?.bodyBase64, MAX_HTTP_REQUEST_BYTES);
613
+ } catch (error) {
614
+ sendHttpResponse(owner, requestId, { error: error instanceof Error ? error.message : error });
615
+ return;
616
+ }
617
+ const controller = new AbortController();
618
+ const pending = { controller, timeout: null, cancelled: false };
619
+ pending.timeout = setTimeout(() => {
620
+ pending.cancelled = true;
621
+ try { controller.abort(new Error('console-http-timeout')); } catch { /* request is already terminal */ }
622
+ }, HTTP_TIMEOUT_MS);
623
+ pending.timeout.unref?.();
624
+ owner.pendingHttp.set(requestId, pending);
625
+ try {
626
+ const response = await fetchImpl(requestUrl.href, {
627
+ method,
628
+ headers: safeContentHeaders(payload?.headers),
629
+ body: ['GET', 'HEAD'].includes(method) || body.byteLength === 0 ? undefined : body,
630
+ signal: controller.signal
631
+ });
632
+ const responseBytes = await readBoundedResponseBytes(response, MAX_HTTP_RESPONSE_BYTES);
633
+ if (!isOwnerActive(owner) || owner.pendingHttp.get(requestId) !== pending || pending.cancelled) return;
634
+ sendHttpResponse(owner, requestId, {
635
+ status: response.status,
636
+ statusText: response.statusText,
637
+ headers: {
638
+ 'content-type': String(response.headers?.get?.('content-type') || '').slice(0, 256),
639
+ 'content-range': String(response.headers?.get?.('content-range') || '').slice(0, 256)
640
+ },
641
+ bodyBase64: responseBytes.toString('base64')
642
+ });
643
+ } catch (error) {
644
+ if (isOwnerActive(owner)
645
+ && owner.pendingHttp.get(requestId) === pending
646
+ && !pending.cancelled) {
647
+ sendHttpResponse(owner, requestId, { error: error instanceof Error ? error.message : error });
648
+ }
649
+ } finally {
650
+ clearTimeout(pending.timeout);
651
+ if (owner.pendingHttp.get(requestId) === pending) owner.pendingHttp.delete(requestId);
652
+ }
653
+ };
654
+
655
+ const cancelHttpRequest = (owner, requestId) => {
656
+ const pending = owner.pendingHttp.get(String(requestId || ''));
657
+ if (!pending) return;
658
+ owner.pendingHttp.delete(String(requestId || ''));
659
+ clearTimeout(pending.timeout);
660
+ pending.cancelled = true;
661
+ try { pending.controller.abort(new Error('console-http-cancelled')); } catch { /* request is already terminal */ }
662
+ };
663
+
664
+ const queuePendingLogicalControl = (owner, payload) => {
665
+ const channelId = String(payload?.channelId || '');
666
+ if (!UUID_PATTERN.test(channelId)) throw new Error('console-direct-logical-channel-invalid');
667
+ const existing = owner.pendingLogicalControl.get(channelId);
668
+ if (payload.type === 'ws-open' && existing?.payload?.type === 'ws-open') {
669
+ throw new Error('console-direct-logical-open-duplicate');
670
+ }
671
+ if (!existing && owner.pendingLogicalControl.size >= MAX_LOGICAL_CHANNELS_PER_PEER) {
672
+ throw new Error('console-direct-logical-control-capacity');
673
+ }
674
+ if (existing) clearTimeout(existing.timer);
675
+ const pending = {
676
+ payload: { ...payload },
677
+ timer: setTimeout(() => {
678
+ if (!isOwnerActive(owner) || owner.pendingLogicalControl.get(channelId) !== pending) return;
679
+ owner.pendingLogicalControl.delete(channelId);
680
+ if (pending.payload.type === 'ws-open') {
681
+ sendLifecycleControl(owner, {
682
+ type: 'ws-error',
683
+ channelId,
684
+ error: 'console-direct-logical-channel-bind-timeout'
685
+ });
686
+ }
687
+ }, logicalBindTimeoutMs)
688
+ };
689
+ pending.timer.unref?.();
690
+ owner.pendingLogicalControl.set(channelId, pending);
691
+ };
692
+
693
+ const openLogicalWebSocket = (owner, channelState, payload) => {
694
+ const channelId = String(payload?.channelId || '');
695
+ const purpose = String(payload?.purpose || '');
696
+ const path = String(payload?.path || '');
697
+ if (channelState.openRequested
698
+ || channelId !== channelState.channelId
699
+ || purpose !== channelState.purpose
700
+ || !allowedWebSocketPath(path, purpose)) {
701
+ sendLifecycleControl(owner, {
702
+ type: 'ws-error',
703
+ channelId: channelState.channelId,
704
+ error: 'console-websocket-route-not-allowed'
705
+ });
706
+ closeLogicalChannel(owner, channelState, {
707
+ code: 1008,
708
+ reason: 'console-websocket-route-not-allowed',
709
+ notify: false
710
+ });
711
+ return;
712
+ }
713
+ channelState.openRequested = true;
714
+ const localUrl = new URL(path, httpBaseUrl);
715
+ const httpOrigin = new URL(httpBaseUrl).origin;
716
+ if (localUrl.origin !== httpOrigin) {
717
+ closeLogicalChannel(owner, channelState, { code: 1008, reason: 'console-websocket-origin-not-allowed' });
718
+ return;
719
+ }
720
+ localUrl.protocol = localUrl.protocol === 'https:' ? 'wss:' : 'ws:';
721
+ let socket;
722
+ try {
723
+ socket = new LocalWebSocketImpl(localUrl, { perMessageDeflate: false });
724
+ } catch (error) {
725
+ sendLifecycleControl(owner, {
726
+ type: 'ws-error',
727
+ channelId,
728
+ error: String(error instanceof Error ? error.message : error).slice(0, 240)
729
+ });
730
+ closeLogicalChannel(owner, channelState, { code: 1011, reason: 'console-local-websocket-open-failed' });
731
+ return;
732
+ }
733
+ channelState.localSocket = socket;
734
+ socket.once('open', () => {
735
+ if (!isOwnerActive(owner) || owner.channels.get(channelId) !== channelState || channelState.localSocket !== socket) {
736
+ try { socket.terminate?.(); } catch {}
737
+ return;
738
+ }
739
+ sendLifecycleControl(owner, { type: 'ws-opened', channelId });
740
+ });
741
+ socket.on('message', (data, isBinary) => {
742
+ if (!isOwnerActive(owner) || owner.channels.get(channelId) !== channelState || channelState.localSocket !== socket) return;
743
+ const kind = isBinary ? 'binary' : 'text';
744
+ const value = isBinary ? new Uint8Array(Buffer.from(data)) : String(data);
745
+ if (!sendWire(owner, channelState, kind, value, channelState.maxMessageBytes)) {
746
+ closeLogicalChannel(owner, channelState, {
747
+ code: 1013,
748
+ reason: 'console-direct-datachannel-backpressure',
749
+ notify: false
750
+ });
751
+ }
752
+ });
753
+ socket.once('error', error => {
754
+ if (!isOwnerActive(owner) || owner.channels.get(channelId) !== channelState) return;
755
+ sendLifecycleControl(owner, {
756
+ type: 'ws-error',
757
+ channelId,
758
+ error: String(error instanceof Error ? error.message : error).slice(0, 240)
759
+ });
760
+ });
761
+ socket.once('close', (code, reason) => {
762
+ if (channelState.localSocket === socket) channelState.localSocket = null;
763
+ if (!isOwnerActive(owner) || owner.channels.get(channelId) !== channelState) return;
764
+ sendLifecycleControl(owner, {
765
+ type: 'ws-closed',
766
+ channelId,
767
+ code: Number(code || 1000),
768
+ reason: String(reason || 'console-local-websocket-closed').slice(0, 120)
769
+ });
770
+ closeLogicalChannel(owner, channelState, { code, reason: 'console-local-websocket-closed', notify: false });
771
+ });
772
+ };
773
+
774
+ const dispatchLogicalControl = (owner, payload) => {
775
+ if (!lifecycleOwnerMatches(owner, payload)) {
776
+ throw new Error('console-direct-logical-owner-invalid');
777
+ }
778
+ const channelId = String(payload.channelId || '');
779
+ if (!UUID_PATTERN.test(channelId)) throw new Error('console-direct-logical-channel-invalid');
780
+ const channelState = owner.channels.get(channelId);
781
+ if (payload.type === 'ws-open') {
782
+ if (!LOGICAL_WS_PURPOSES.has(String(payload.purpose || ''))
783
+ || typeof payload.path !== 'string') {
784
+ throw new Error('console-direct-logical-open-invalid');
785
+ }
786
+ if (!channelState || !channelIsOpen(channelState.channel)) {
787
+ queuePendingLogicalControl(owner, payload);
788
+ return;
789
+ }
790
+ disposePendingLogicalControl(owner, channelId);
791
+ openLogicalWebSocket(owner, channelState, payload);
792
+ return;
793
+ }
794
+ if (payload.type === 'ws-close') {
795
+ if (!channelState) {
796
+ queuePendingLogicalControl(owner, payload);
797
+ return;
798
+ }
799
+ closeLogicalChannel(owner, channelState, {
800
+ code: Math.max(1000, Number(payload.code || 1000)),
801
+ reason: String(payload.reason || 'console-request').slice(0, 120),
802
+ notify: true
803
+ });
804
+ return;
805
+ }
806
+ throw new Error('console-direct-logical-control-type-not-allowed');
807
+ };
808
+
809
+ const consumePendingLogicalControl = (owner, channelState) => {
810
+ const pending = disposePendingLogicalControl(owner, channelState.channelId);
811
+ if (pending) dispatchLogicalControl(owner, pending.payload);
812
+ };
813
+
814
+ const handleControlPayload = (owner, text) => {
815
+ const payload = parseJson(text);
816
+ if (!payload?.type) throw new Error('console-direct-control-invalid');
817
+ if (payload.type === 'http-request') {
818
+ void handleHttpRequest(owner, payload);
819
+ return;
820
+ }
821
+ if (payload.type === 'http-cancel') {
822
+ cancelHttpRequest(owner, payload.requestId);
823
+ return;
824
+ }
825
+ if (payload.type === 'ws-open' || payload.type === 'ws-close') {
826
+ dispatchLogicalControl(owner, payload);
827
+ return;
828
+ }
829
+ throw new Error('console-direct-control-type-not-allowed');
830
+ };
831
+
832
+ const forwardLogicalPayload = (owner, channelState, message) => {
833
+ const socket = channelState.localSocket;
834
+ if (!channelState.openRequested || !socketIsOpen(socket, LocalWebSocketImpl)) {
835
+ throw new Error('console-direct-logical-websocket-not-open');
836
+ }
837
+ if (Number(socket.bufferedAmount || 0) + message.byteLength > MAX_LOCAL_WS_BUFFERED_BYTES) {
838
+ throw new Error('console-direct-local-websocket-backpressure');
839
+ }
840
+ if (message.kind === 'text') socket.send(message.data);
841
+ else if (message.kind === 'binary') socket.send(Buffer.from(message.data));
842
+ else throw new Error('console-direct-logical-wire-kind-invalid');
843
+ };
844
+
845
+ const scheduleAssemblyDeadline = (owner, channelState) => {
846
+ const pending = Number(channelState.assembler.inspect().pendingMessages || 0);
847
+ if (pending === 0) {
848
+ disposeAssemblerTimer(channelState);
849
+ return;
850
+ }
851
+ if (channelState.assemblyTimer) return;
852
+ const delay = channelState.purpose === 'frame' || channelState.purpose === 'atlas'
853
+ ? mediaAssemblyTimeoutMs
854
+ : CONTROL_ASSEMBLY_TIMEOUT_MS;
855
+ const timer = setTimeout(() => {
856
+ if (channelState.assemblyTimer !== timer) return;
857
+ channelState.assemblyTimer = null;
858
+ if (!isOwnerActive(owner) || channelState.closed) return;
859
+ if (Number(channelState.assembler.inspect().pendingMessages || 0) === 0) return;
860
+ malformedWireCloses += 1;
861
+ if (channelState === owner.control) retirePeer(owner, 'console-direct-wire-assembly-timeout');
862
+ else closeLogicalChannel(owner, channelState, {
863
+ code: 1009,
864
+ reason: 'console-direct-wire-assembly-timeout',
865
+ notify: false
866
+ });
867
+ }, delay);
868
+ channelState.assemblyTimer = timer;
869
+ timer.unref?.();
870
+ };
871
+
872
+ const handleDataChannelMessage = (owner, channelState, raw) => {
873
+ if (!isOwnerActive(owner) || channelState.closed) return;
874
+ try {
875
+ if (typeof raw === 'string') throw new Error('console-direct-unframed-message');
876
+ const message = channelState.assembler.push(raw);
877
+ scheduleAssemblyDeadline(owner, channelState);
878
+ if (!message) return;
879
+ if (channelState === owner.control) {
880
+ if (message.kind !== 'control') throw new Error('console-direct-control-wire-kind-invalid');
881
+ handleControlPayload(owner, message.data);
882
+ } else {
883
+ if (message.kind === 'control') throw new Error('console-direct-logical-control-forbidden');
884
+ forwardLogicalPayload(owner, channelState, message);
885
+ }
886
+ } catch {
887
+ malformedWireCloses += 1;
888
+ if (channelState === owner.control) retirePeer(owner, 'console-direct-wire-invalid');
889
+ else closeLogicalChannel(owner, channelState, {
890
+ code: 1008,
891
+ reason: 'console-direct-wire-invalid',
892
+ notify: false
893
+ });
894
+ }
895
+ };
896
+
897
+ const bindDataChannel = (owner, channel) => {
898
+ if (!isOwnerActive(owner)) {
899
+ try { channel.close(); } catch {}
900
+ return;
901
+ }
902
+ const label = String(channel.getLabel?.() || '');
903
+ let channelState;
904
+ if (label === CONTROL_LABEL) {
905
+ if (owner.control) {
906
+ try { channel.close(); } catch {}
907
+ return;
908
+ }
909
+ channelState = {
910
+ channel,
911
+ channelId: 'control',
912
+ purpose: 'control',
913
+ maxMessageBytes: DIRECT_CONSOLE_MAX_MESSAGE_BYTES,
914
+ assembler: createDirectConsoleWireAssembler({
915
+ budget: owner.wireBudget,
916
+ maxMessageBytes: DIRECT_CONSOLE_MAX_MESSAGE_BYTES
917
+ }),
918
+ assemblyTimer: null,
919
+ nextSendMessageId: 1,
920
+ closed: false
921
+ };
922
+ owner.control = channelState;
923
+ } else {
924
+ const identity = parseLogicalWebSocketLabel(label);
925
+ if (!identity
926
+ || owner.channels.size >= MAX_LOGICAL_CHANNELS_PER_PEER
927
+ || owner.channels.has(identity?.channelId)) {
928
+ try { channel.close(); } catch {}
929
+ return;
930
+ }
931
+ const maxMessageBytes = identity.purpose === 'frame' || identity.purpose === 'atlas'
932
+ ? MAX_FRAME_MESSAGE_BYTES
933
+ : DIRECT_CONSOLE_MAX_MESSAGE_BYTES;
934
+ channelState = {
935
+ channel,
936
+ ...identity,
937
+ maxMessageBytes,
938
+ assembler: createDirectConsoleWireAssembler({
939
+ budget: owner.wireBudget,
940
+ maxMessageBytes,
941
+ maxPendingMessages: identity.purpose === 'frame' || identity.purpose === 'atlas'
942
+ ? MAX_PENDING_MEDIA_WIRE_MESSAGES
943
+ : 2
944
+ }),
945
+ assemblyTimer: null,
946
+ nextSendMessageId: 1,
947
+ openRequested: false,
948
+ localSocket: null,
949
+ closed: false
950
+ };
951
+ owner.channels.set(identity.channelId, channelState);
952
+ }
953
+ try { channel.setBufferedAmountLowThreshold?.(4 * 1024 * 1024); } catch {}
954
+ channel.onMessage(raw => handleDataChannelMessage(owner, channelState, raw));
955
+ channel.onClosed(() => {
956
+ if (!isOwnerActive(owner) || channelState.closed) return;
957
+ if (channelState === owner.control) retirePeer(owner, 'console-direct-control-closed');
958
+ else closeLogicalChannel(owner, channelState, {
959
+ code: 1001,
960
+ reason: 'console-direct-datachannel-closed',
961
+ notify: false
962
+ });
963
+ });
964
+ channel.onError(() => {
965
+ if (!isOwnerActive(owner) || channelState.closed) return;
966
+ if (channelState === owner.control) retirePeer(owner, 'console-direct-control-error');
967
+ else closeLogicalChannel(owner, channelState, {
968
+ code: 1011,
969
+ reason: 'console-direct-datachannel-error',
970
+ notify: false
971
+ });
972
+ });
973
+ channel.onOpen(() => {
974
+ if (!isOwnerActive(owner) || channelState.closed) return;
975
+ if (channelState === owner.control) {
976
+ clearTimeout(owner.iceTimer);
977
+ owner.iceTimer = null;
978
+ sendControl(owner, {
979
+ type: 'direct-ready',
980
+ connectionId: owner.connectionId,
981
+ hubEpoch: owner.hubEpoch
982
+ });
983
+ } else {
984
+ consumePendingLogicalControl(owner, channelState);
985
+ }
986
+ });
987
+ };
988
+
989
+ const createPeerOwner = payload => {
990
+ const consoleId = String(payload?.consoleId || '');
991
+ const connectionId = String(payload?.connectionId || '');
992
+ const ownerHubEpoch = String(payload?.hubEpoch || '');
993
+ const description = payload?.description;
994
+ if (!UUID_PATTERN.test(consoleId)
995
+ || !UUID_PATTERN.test(connectionId)
996
+ || ownerHubEpoch !== hubEpoch
997
+ || description?.type !== 'offer'
998
+ || typeof description.sdp !== 'string'
999
+ || byteLength(description.sdp) > SDP_MAX_BYTES) {
1000
+ return null;
1001
+ }
1002
+ if (candidateIsRelay(description.sdp)) {
1003
+ rejectedRelayCandidates += 1;
1004
+ sendSignal({
1005
+ type: 'rtc-close',
1006
+ consoleId,
1007
+ connectionId,
1008
+ hubEpoch: ownerHubEpoch,
1009
+ reason: 'console-direct-relay-candidate-rejected'
1010
+ });
1011
+ return null;
1012
+ }
1013
+ const existing = peers.get(consoleId);
1014
+ if (existing
1015
+ && existing.connectionId === connectionId
1016
+ && existing.hubEpoch === ownerHubEpoch
1017
+ && !existing.retired) {
1018
+ const current = existing.peer.localDescription?.();
1019
+ if (current?.type === 'answer' && typeof current.sdp === 'string') {
1020
+ sendSignal({
1021
+ type: 'rtc-answer',
1022
+ ...ownerEnvelope(existing),
1023
+ description: { type: 'answer', sdp: current.sdp }
1024
+ });
1025
+ }
1026
+ return existing;
1027
+ }
1028
+ if (!existing && peers.size >= MAX_PEERS) {
1029
+ sendSignal({
1030
+ type: 'rtc-close',
1031
+ consoleId,
1032
+ connectionId,
1033
+ hubEpoch: ownerHubEpoch,
1034
+ reason: 'console-direct-peer-capacity-reached'
1035
+ });
1036
+ return null;
1037
+ }
1038
+ if (existing) retirePeer(existing, 'console-direct-peer-replaced');
1039
+ let peer;
1040
+ try {
1041
+ peer = createPeerConnection(`console-${consoleId}`, {
1042
+ iceServers: [...stunUrls],
1043
+ iceTransportPolicy: 'all',
1044
+ disableAutoNegotiation: false,
1045
+ disableFingerprintVerification: false,
1046
+ maxMessageSize: MAX_FRAME_MESSAGE_BYTES
1047
+ });
1048
+ } catch (error) {
1049
+ lastError = error instanceof Error ? error.message : String(error);
1050
+ sendSignal({
1051
+ type: 'rtc-close',
1052
+ consoleId,
1053
+ connectionId,
1054
+ hubEpoch: ownerHubEpoch,
1055
+ reason: 'console-direct-peer-create-failed'
1056
+ });
1057
+ return null;
1058
+ }
1059
+ const owner = {
1060
+ consoleId,
1061
+ connectionId,
1062
+ hubEpoch: ownerHubEpoch,
1063
+ generation: ++peerGeneration,
1064
+ peer,
1065
+ control: null,
1066
+ channels: new Map(),
1067
+ pendingLogicalControl: new Map(),
1068
+ pendingHttp: new Map(),
1069
+ wireBudget: createDirectConsoleWireBudget({ maxBytes: MAX_SHARED_ASSEMBLY_BYTES }),
1070
+ iceTimer: null,
1071
+ disconnectTimer: null,
1072
+ peerState: 'new',
1073
+ iceState: 'new',
1074
+ retired: false,
1075
+ createdAt: Date.now()
1076
+ };
1077
+ peers.set(consoleId, owner);
1078
+ peer.onLocalDescription((sdp, type) => {
1079
+ if (!isOwnerActive(owner) || String(type).toLowerCase() !== 'answer' || byteLength(sdp) > SDP_MAX_BYTES) return;
1080
+ if (candidateIsRelay(sdp)) {
1081
+ rejectedRelayCandidates += 1;
1082
+ retirePeer(owner, 'console-direct-relay-candidate-rejected');
1083
+ return;
1084
+ }
1085
+ sendSignal({
1086
+ type: 'rtc-answer',
1087
+ ...ownerEnvelope(owner),
1088
+ description: { type: 'answer', sdp: String(sdp) }
1089
+ });
1090
+ });
1091
+ peer.onLocalCandidate((candidate, sdpMid) => {
1092
+ if (!isOwnerActive(owner)) return;
1093
+ const candidateText = String(candidate || '');
1094
+ if (byteLength(candidateText) > ICE_CANDIDATE_MAX_BYTES) {
1095
+ retirePeer(owner, 'console-direct-candidate-invalid');
1096
+ return;
1097
+ }
1098
+ if (candidateIsRelay(candidateText)) {
1099
+ rejectedRelayCandidates += 1;
1100
+ retirePeer(owner, 'console-direct-relay-candidate-rejected');
1101
+ return;
1102
+ }
1103
+ sendSignal({
1104
+ type: 'rtc-ice',
1105
+ ...ownerEnvelope(owner),
1106
+ candidate: candidateText,
1107
+ sdpMid: sdpMid === undefined || sdpMid === null ? null : String(sdpMid).slice(0, 256)
1108
+ });
1109
+ });
1110
+ peer.onDataChannel(channel => bindDataChannel(owner, channel));
1111
+ peer.onStateChange(nextState => {
1112
+ if (!isOwnerActive(owner)) return;
1113
+ const normalized = String(nextState || '').toLowerCase();
1114
+ owner.peerState = normalized;
1115
+ if (normalized === 'connected') {
1116
+ if (peerSelectedRelay(peer)) {
1117
+ rejectedRelayCandidates += 1;
1118
+ retirePeer(owner, 'console-direct-relay-pair-rejected');
1119
+ return;
1120
+ }
1121
+ } else if (normalized === 'failed' || normalized === 'closed') {
1122
+ retirePeer(owner, `console-direct-peer-${normalized}`);
1123
+ return;
1124
+ }
1125
+ refreshPeerDisconnectDeadline(owner);
1126
+ });
1127
+ peer.onIceStateChange?.(nextState => {
1128
+ if (!isOwnerActive(owner)) return;
1129
+ const normalized = String(nextState || '').toLowerCase();
1130
+ owner.iceState = normalized;
1131
+ if (normalized === 'failed' || normalized === 'closed') {
1132
+ retirePeer(owner, `console-direct-ice-${normalized}`);
1133
+ return;
1134
+ }
1135
+ refreshPeerDisconnectDeadline(owner);
1136
+ });
1137
+ owner.iceTimer = setTimeout(() => {
1138
+ owner.iceTimer = null;
1139
+ if (isOwnerActive(owner)
1140
+ && (!owner.control || !channelIsOpen(owner.control.channel))) {
1141
+ retirePeer(owner, 'console-direct-ice-timeout');
1142
+ }
1143
+ }, iceConnectTimeoutMs);
1144
+ owner.iceTimer.unref?.();
1145
+ try {
1146
+ peer.setRemoteDescription(description.sdp, 'offer');
1147
+ } catch (error) {
1148
+ lastError = error instanceof Error ? error.message : String(error);
1149
+ retirePeer(owner, 'console-direct-description-failed');
1150
+ return null;
1151
+ }
1152
+ return owner;
1153
+ };
1154
+
1155
+ const handleSignalMessage = (raw, ownerSignalGeneration, socket) => {
1156
+ if (typeof raw !== 'string' && !Buffer.isBuffer(raw)) return;
1157
+ const text = Buffer.isBuffer(raw) ? raw.toString('utf8') : raw;
1158
+ if (byteLength(text) > SIGNAL_MESSAGE_MAX_BYTES) return;
1159
+ const payload = parseJson(text);
1160
+ if (!payload?.type) return;
1161
+ if (payload.type === 'signal-ready' && payload.role === 'hub') {
1162
+ const nextEpoch = String(payload.hubEpoch || '');
1163
+ if (!UUID_PATTERN.test(nextEpoch)) {
1164
+ lastError = 'console-direct-hub-epoch-invalid';
1165
+ return;
1166
+ }
1167
+ clearSignalReadyDeadline(ownerSignalGeneration, socket);
1168
+ if (hubEpoch && hubEpoch !== nextEpoch) retireAllPeers('console-direct-hub-epoch-replaced');
1169
+ hubEpoch = nextEpoch;
1170
+ state = 'connected';
1171
+ lastConnectedAt = new Date().toISOString();
1172
+ lastError = '';
1173
+ lastHttpStatus = 0;
1174
+ retryAttempt = 0;
1175
+ retryNotBeforeAt = 0;
1176
+ nextRetryAt = '';
1177
+ return;
1178
+ }
1179
+ const consoleId = String(payload.consoleId || '');
1180
+ const connectionId = String(payload.connectionId || '');
1181
+ const ownerHubEpoch = String(payload.hubEpoch || '');
1182
+ if (!UUID_PATTERN.test(consoleId)
1183
+ || !UUID_PATTERN.test(connectionId)
1184
+ || ownerHubEpoch !== hubEpoch) return;
1185
+ if (payload.type === 'rtc-offer') {
1186
+ createPeerOwner(payload);
1187
+ return;
1188
+ }
1189
+ const owner = peers.get(consoleId);
1190
+ if (!owner
1191
+ || owner.connectionId !== connectionId
1192
+ || owner.hubEpoch !== ownerHubEpoch
1193
+ || !isOwnerActive(owner)) return;
1194
+ if (payload.type === 'rtc-ice') {
1195
+ const candidate = String(payload.candidate || '');
1196
+ if (byteLength(candidate) > ICE_CANDIDATE_MAX_BYTES) {
1197
+ retirePeer(owner, 'console-direct-candidate-invalid');
1198
+ return;
1199
+ }
1200
+ if (candidateIsRelay(candidate)) {
1201
+ rejectedRelayCandidates += 1;
1202
+ retirePeer(owner, 'console-direct-relay-candidate-rejected');
1203
+ return;
1204
+ }
1205
+ try {
1206
+ if (candidate) owner.peer.addRemoteCandidate(candidate, String(payload.sdpMid || ''));
1207
+ } catch {
1208
+ retirePeer(owner, 'console-direct-candidate-failed');
1209
+ }
1210
+ return;
1211
+ }
1212
+ if (payload.type === 'rtc-close') {
1213
+ retirePeer(owner, String(payload.reason || 'console-direct-remote-closed'), { notify: false });
1214
+ }
1215
+ };
1216
+
1217
+ const cancelAccessTokenAttempt = () => {
1218
+ accessTokenAttempt?.cancel();
1219
+ };
1220
+
1221
+ const readAccessToken = ownerSignalGeneration => new Promise(resolve => {
1222
+ let settled = false;
1223
+ let attempt = null;
1224
+ const finish = result => {
1225
+ if (settled) return;
1226
+ settled = true;
1227
+ clearTimeout(attempt.timer);
1228
+ if (accessTokenAttempt === attempt) accessTokenAttempt = null;
1229
+ resolve(result);
1230
+ };
1231
+ const timer = setTimeout(() => {
1232
+ finish({ kind: 'timeout' });
1233
+ }, accessTokenTimeoutMs);
1234
+ timer.unref?.();
1235
+ attempt = {
1236
+ generation: ownerSignalGeneration,
1237
+ timer,
1238
+ cancel: () => finish({ kind: 'cancelled' })
1239
+ };
1240
+ accessTokenAttempt = attempt;
1241
+ void Promise.resolve()
1242
+ .then(() => getAccessToken())
1243
+ .then(
1244
+ value => finish({ kind: 'value', value }),
1245
+ error => finish({ kind: 'error', error })
1246
+ );
1247
+ });
1248
+
1249
+ const clearSignalReadyDeadline = (ownerSignalGeneration, socket) => {
1250
+ const deadline = signalReadyDeadline;
1251
+ if (!deadline
1252
+ || (ownerSignalGeneration !== undefined && deadline.generation !== ownerSignalGeneration)
1253
+ || (socket !== undefined && deadline.socket !== socket)) {
1254
+ return false;
1255
+ }
1256
+ signalReadyDeadline = null;
1257
+ clearTimeout(deadline.timer);
1258
+ return true;
1259
+ };
1260
+
1261
+ const armSignalReadyDeadline = (ownerSignalGeneration, socket, connect) => {
1262
+ clearSignalReadyDeadline();
1263
+ const deadline = {
1264
+ generation: ownerSignalGeneration,
1265
+ socket,
1266
+ timer: null
1267
+ };
1268
+ deadline.timer = setTimeout(() => {
1269
+ if (signalReadyDeadline !== deadline) return;
1270
+ signalReadyDeadline = null;
1271
+ if (stopped
1272
+ || ownerSignalGeneration !== signalGeneration
1273
+ || signalSocket !== socket) {
1274
+ return;
1275
+ }
1276
+ signalSocket = null;
1277
+ lastHttpStatus = 0;
1278
+ lastError = 'console-direct-signal-ready-timeout';
1279
+ retireNegotiatingPeers('console-direct-signal-ready-timeout');
1280
+ try { socket.terminate?.(); } catch {}
1281
+ scheduleReconnect(connect);
1282
+ }, signalReadyTimeoutMs);
1283
+ deadline.timer.unref?.();
1284
+ signalReadyDeadline = deadline;
1285
+ };
1286
+
1287
+ const scheduleReconnect = connect => {
1288
+ if (stopped || retryTimer || !(signalUrl instanceof URL)) return;
1289
+ const sequenceDelay = retryDelaysMs[Math.min(retryAttempt, retryDelaysMs.length - 1)];
1290
+ const delayMs = Math.max(sequenceDelay, retryNotBeforeAt - Date.now(), 0);
1291
+ retryAttempt += 1;
1292
+ state = 'waiting-retry';
1293
+ nextRetryAt = new Date(Date.now() + delayMs).toISOString();
1294
+ retryTimer = setTimeout(() => {
1295
+ retryTimer = null;
1296
+ nextRetryAt = '';
1297
+ void connect();
1298
+ }, delayMs);
1299
+ retryTimer.unref?.();
1300
+ };
1301
+
1302
+ const connect = async () => {
1303
+ if (stopped || !(signalUrl instanceof URL) || !deviceId) return;
1304
+ const ownerSignalGeneration = ++signalGeneration;
1305
+ let ownerHttpStatus = 0;
1306
+ state = 'connecting';
1307
+ nextRetryAt = '';
1308
+ let accessToken = '';
1309
+ const accessTokenResult = await readAccessToken(ownerSignalGeneration);
1310
+ if (stopped || ownerSignalGeneration !== signalGeneration) return;
1311
+ if (accessTokenResult.kind === 'cancelled') return;
1312
+ if (accessTokenResult.kind === 'timeout') {
1313
+ lastError = 'console-direct-access-token-timeout';
1314
+ scheduleReconnect(connect);
1315
+ return;
1316
+ }
1317
+ if (accessTokenResult.kind === 'error') {
1318
+ lastError = accessTokenResult.error instanceof Error
1319
+ ? accessTokenResult.error.message
1320
+ : String(accessTokenResult.error);
1321
+ } else {
1322
+ accessToken = String(accessTokenResult.value || '').trim();
1323
+ }
1324
+ if (!accessToken) {
1325
+ lastError = 'hub-session-required';
1326
+ scheduleReconnect(connect);
1327
+ return;
1328
+ }
1329
+ const url = new URL(signalUrl);
1330
+ url.searchParams.set('deviceId', deviceId);
1331
+ url.searchParams.set('hubInstanceId', hubInstanceId);
1332
+ let socket;
1333
+ try {
1334
+ socket = new SignalingWebSocketImpl(url, {
1335
+ headers: { Authorization: `Bearer ${accessToken}` },
1336
+ perMessageDeflate: false
1337
+ });
1338
+ } catch (error) {
1339
+ lastError = error instanceof Error ? error.message : String(error);
1340
+ scheduleReconnect(connect);
1341
+ return;
1342
+ }
1343
+ if (stopped || ownerSignalGeneration !== signalGeneration) {
1344
+ try { socket.terminate?.(); } catch {}
1345
+ return;
1346
+ }
1347
+ signalSocket = socket;
1348
+ armSignalReadyDeadline(ownerSignalGeneration, socket, connect);
1349
+ socket.once('open', () => {
1350
+ if (signalSocket !== socket || ownerSignalGeneration !== signalGeneration || stopped) return;
1351
+ state = 'authenticating';
1352
+ });
1353
+ socket.once('unexpected-response', (_request, response) => {
1354
+ if (signalSocket !== socket || ownerSignalGeneration !== signalGeneration || stopped) {
1355
+ response.resume?.();
1356
+ return;
1357
+ }
1358
+ const status = Math.max(0, Number(response?.statusCode || 0));
1359
+ ownerHttpStatus = status;
1360
+ lastHttpStatus = status;
1361
+ lastError = status > 0
1362
+ ? `Console direct signaling returned HTTP ${status}`
1363
+ : 'Console direct signaling returned an unexpected response.';
1364
+ if (status === 429) retryNotBeforeAt = Math.max(retryNotBeforeAt, Date.now() + SIGNAL_HTTP_429_RETRY_MS);
1365
+ if (signalSocket === socket) signalSocket = null;
1366
+ clearSignalReadyDeadline(ownerSignalGeneration, socket);
1367
+ retireNegotiatingPeers('console-direct-signaling-rejected');
1368
+ response.resume?.();
1369
+ try { socket.terminate?.(); } catch {}
1370
+ scheduleReconnect(connect);
1371
+ });
1372
+ socket.on('message', raw => {
1373
+ if (signalSocket === socket && ownerSignalGeneration === signalGeneration && !stopped) {
1374
+ handleSignalMessage(raw, ownerSignalGeneration, socket);
1375
+ }
1376
+ });
1377
+ socket.once('close', () => {
1378
+ const wasOwner = signalSocket === socket
1379
+ && ownerSignalGeneration === signalGeneration
1380
+ && !stopped;
1381
+ if (signalSocket === socket) signalSocket = null;
1382
+ clearSignalReadyDeadline(ownerSignalGeneration, socket);
1383
+ if (!wasOwner) return;
1384
+ retireNegotiatingPeers('console-direct-signaling-closed');
1385
+ if (!retryTimer) state = 'disconnected';
1386
+ scheduleReconnect(connect);
1387
+ });
1388
+ socket.once('error', error => {
1389
+ if (signalSocket !== socket || ownerSignalGeneration !== signalGeneration || stopped) return;
1390
+ if (ownerHttpStatus <= 0) {
1391
+ lastHttpStatus = 0;
1392
+ lastError = error instanceof Error ? error.message : String(error);
1393
+ }
1394
+ signalSocket = null;
1395
+ clearSignalReadyDeadline(ownerSignalGeneration, socket);
1396
+ retireNegotiatingPeers('console-direct-signaling-error');
1397
+ try { socket.terminate?.(); } catch {}
1398
+ scheduleReconnect(connect);
1399
+ });
1400
+ };
1401
+
1402
+ const start = () => {
1403
+ if (!stopped || !(signalUrl instanceof URL) || !deviceId) return;
1404
+ stopped = false;
1405
+ void connect();
1406
+ };
1407
+
1408
+ const refresh = () => {
1409
+ if (stopped) {
1410
+ start();
1411
+ return;
1412
+ }
1413
+ signalGeneration += 1;
1414
+ cancelAccessTokenAttempt();
1415
+ clearSignalReadyDeadline();
1416
+ if (retryTimer) clearTimeout(retryTimer);
1417
+ retryTimer = null;
1418
+ retryAttempt = 0;
1419
+ retryNotBeforeAt = 0;
1420
+ nextRetryAt = '';
1421
+ retireNegotiatingPeers('console-direct-refresh');
1422
+ const socket = signalSocket;
1423
+ signalSocket = null;
1424
+ try { socket?.terminate?.(); } catch {}
1425
+ void connect();
1426
+ };
1427
+
1428
+ const close = () => {
1429
+ if (stopped) return;
1430
+ stopped = true;
1431
+ signalGeneration += 1;
1432
+ cancelAccessTokenAttempt();
1433
+ clearSignalReadyDeadline();
1434
+ if (retryTimer) clearTimeout(retryTimer);
1435
+ retryTimer = null;
1436
+ nextRetryAt = '';
1437
+ retireAllPeers('hub-shutdown');
1438
+ hubEpoch = '';
1439
+ const socket = signalSocket;
1440
+ signalSocket = null;
1441
+ try { socket?.close(1001, 'hub-shutdown'); } catch {}
1442
+ try { socket?.terminate?.(); } catch {}
1443
+ state = signalUrl ? 'closed' : 'disabled';
1444
+ };
1445
+
1446
+ const inspect = () => {
1447
+ let logicalWebSocketChannels = 0;
1448
+ let localWebSocketChannels = 0;
1449
+ let pendingHttpRequests = 0;
1450
+ let pendingLogicalControlRequests = 0;
1451
+ let controlChannels = 0;
1452
+ let retainedAssemblyBytes = 0;
1453
+ let bufferedSendBytes = 0;
1454
+ let iceConnectTimers = 0;
1455
+ let peerDisconnectTimers = 0;
1456
+ let assemblyDeadlineTimers = 0;
1457
+ for (const owner of peers.values()) {
1458
+ logicalWebSocketChannels += owner.channels.size;
1459
+ pendingHttpRequests += owner.pendingHttp.size;
1460
+ pendingLogicalControlRequests += owner.pendingLogicalControl.size;
1461
+ if (owner.iceTimer) iceConnectTimers += 1;
1462
+ if (owner.disconnectTimer) peerDisconnectTimers += 1;
1463
+ if (owner.control && !owner.control.closed) controlChannels += 1;
1464
+ if (owner.control?.assemblyTimer) assemblyDeadlineTimers += 1;
1465
+ for (const channelState of owner.channels.values()) {
1466
+ if (channelState.localSocket) localWebSocketChannels += 1;
1467
+ if (channelState.assemblyTimer) assemblyDeadlineTimers += 1;
1468
+ }
1469
+ retainedAssemblyBytes += Number(owner.wireBudget.inspect().retainedBytes || 0);
1470
+ bufferedSendBytes += ownerBufferedAmount(owner);
1471
+ }
1472
+ const reconnectTimerActive = Boolean(retryTimer);
1473
+ const accessTokenDeadlineActive = Boolean(accessTokenAttempt);
1474
+ const signalReadyDeadlineActive = Boolean(signalReadyDeadline);
1475
+ return Object.freeze({
1476
+ enabled: signalUrl instanceof URL && Boolean(deviceId),
1477
+ state,
1478
+ connected: state === 'connected',
1479
+ hubEpoch,
1480
+ peerConnections: peers.size,
1481
+ controlChannels,
1482
+ logicalWebSocketChannels,
1483
+ localWebSocketChannels,
1484
+ pendingHttpRequests,
1485
+ pendingLogicalControlRequests,
1486
+ retainedAssemblyBytes,
1487
+ bufferedSendBytes,
1488
+ iceConnectTimers,
1489
+ peerDisconnectTimers,
1490
+ assemblyDeadlineTimers,
1491
+ reconnectTimerActive,
1492
+ accessTokenDeadlineActive,
1493
+ signalReadyDeadlineActive,
1494
+ resourceTimers: iceConnectTimers
1495
+ + peerDisconnectTimers
1496
+ + assemblyDeadlineTimers
1497
+ + pendingHttpRequests
1498
+ + pendingLogicalControlRequests
1499
+ + Number(reconnectTimerActive)
1500
+ + Number(accessTokenDeadlineActive)
1501
+ + Number(signalReadyDeadlineActive),
1502
+ rejectedRelayCandidates,
1503
+ dataChannelBackpressureCloses,
1504
+ malformedWireCloses,
1505
+ wireBudgetCleanupFailures,
1506
+ lastConnectedAt,
1507
+ lastError,
1508
+ lastHttpStatus,
1509
+ retryAttempt,
1510
+ nextRetryAt
1511
+ });
1512
+ };
1513
+
1514
+ return Object.freeze({ start, refresh, close, inspect });
1515
+ }
1516
+
1517
+ export const consoleDirectContract = Object.freeze({
1518
+ controlLabel: CONTROL_LABEL,
1519
+ logicalWebSocketLabelPrefix: LOGICAL_WS_LABEL_PREFIX,
1520
+ maxPeers: MAX_PEERS,
1521
+ maxLogicalChannelsPerPeer: MAX_LOGICAL_CHANNELS_PER_PEER,
1522
+ maxPendingHttpPerPeer: MAX_PENDING_HTTP_PER_PEER,
1523
+ maxHttpRequestBytes: MAX_HTTP_REQUEST_BYTES,
1524
+ maxHttpResponseBytes: MAX_HTTP_RESPONSE_BYTES,
1525
+ maxSharedAssemblyBytes: MAX_SHARED_ASSEMBLY_BYTES,
1526
+ maxFrameMessageBytes: MAX_FRAME_MESSAGE_BYTES,
1527
+ maxPendingMediaWireMessages: MAX_PENDING_MEDIA_WIRE_MESSAGES,
1528
+ maxBufferedBytes: MAX_DATA_CHANNEL_BUFFERED_BYTES,
1529
+ maxLocalWebSocketBufferedBytes: MAX_LOCAL_WS_BUFFERED_BYTES,
1530
+ maxDefaultRetryDelayMs: DEFAULT_RETRY_DELAYS_MS.at(-1),
1531
+ accessTokenTimeoutMs: ACCESS_TOKEN_TIMEOUT_MS,
1532
+ signalReadyTimeoutMs: SIGNAL_READY_TIMEOUT_MS,
1533
+ logicalBindTimeoutMs: LOGICAL_BIND_TIMEOUT_MS,
1534
+ iceConnectTimeoutMs: ICE_CONNECT_TIMEOUT_MS,
1535
+ peerDisconnectedTimeoutMs: PEER_DISCONNECTED_TIMEOUT_MS,
1536
+ reliableAssemblyTimeoutMs: CONTROL_ASSEMBLY_TIMEOUT_MS,
1537
+ partialMediaAssemblyTimeoutMs: MEDIA_ASSEMBLY_TIMEOUT_MS
1538
+ });