@livedesk/hub 0.1.74 → 0.1.76
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/console-direct-frame-admission.mjs +60 -12
- package/src/console-direct-frame-admission.test.mjs +57 -0
- package/src/console-direct-ice-evidence.mjs +87 -0
- package/src/console-direct-ice-evidence.test.mjs +59 -0
- package/src/console-direct.js +267 -134
- package/src/console-direct.test.mjs +244 -1
- package/src/console-ice-setup-gate.mjs +30 -0
- package/src/console-router-mapping.mjs +206 -0
- package/src/console-router-mapping.test.mjs +323 -0
- package/src/console-upnp-gateway.mjs +230 -0
- package/src/server.js +9 -2
- package/src/settings/settings-schema.js +3 -2
package/src/console-direct.js
CHANGED
|
@@ -10,6 +10,12 @@ import {
|
|
|
10
10
|
} from '../../runtime-core/src/console-direct-wire.js';
|
|
11
11
|
import { workspaceRoleCanControl, workspaceRoleCanRequest } from './auth/workspace-access.js';
|
|
12
12
|
import { createConsoleFrameAdmission } from './console-direct-frame-admission.mjs';
|
|
13
|
+
import { createConsoleRouterMapping } from './console-router-mapping.mjs';
|
|
14
|
+
import { createConsoleIceSetupGate } from './console-ice-setup-gate.mjs';
|
|
15
|
+
import {
|
|
16
|
+
createConsoleIceEvidence, normalizeConsoleIcePortRange, recordConsoleIceCandidate, recordConsoleIceDescription,
|
|
17
|
+
retainConsoleIceRetirement, safeIceState, snapshotConsoleIceEvidence
|
|
18
|
+
} from './console-direct-ice-evidence.mjs';
|
|
13
19
|
|
|
14
20
|
const DEFAULT_SIGNAL_URL = 'https://livedesk-wake.lovecrdm.workers.dev';
|
|
15
21
|
const DEFAULT_STUN_URLS = Object.freeze(['stun:stun.cloudflare.com:3478']);
|
|
@@ -328,6 +334,10 @@ export function createHubConsoleDirect(options = {}) {
|
|
|
328
334
|
const httpBaseUrl = String(options.httpBaseUrl || '').replace(/\/+$/, '');
|
|
329
335
|
const canonicalHttpBaseUrl = normalizeHttpBaseUrl(httpBaseUrl);
|
|
330
336
|
const stunUrls = normalizeStunUrls(options.stunUrls);
|
|
337
|
+
const icePortRange = normalizeConsoleIcePortRange(options.icePortRange);
|
|
338
|
+
const routerMapping = options.routerMapping || createConsoleRouterMapping({
|
|
339
|
+
isEnabled: options.isRouterMappingEnabled
|
|
340
|
+
});
|
|
331
341
|
const SignalingWebSocketImpl = options.SignalingWebSocketImpl || options.WebSocketImpl || WebSocket;
|
|
332
342
|
const LocalWebSocketImpl = options.LocalWebSocketImpl || options.WebSocketImpl || WebSocket;
|
|
333
343
|
const fetchImpl = options.fetchImpl || globalThis.fetch;
|
|
@@ -380,6 +390,7 @@ export function createHubConsoleDirect(options = {}) {
|
|
|
380
390
|
Number(options.logicalBindTimeoutMs) || LOGICAL_BIND_TIMEOUT_MS
|
|
381
391
|
);
|
|
382
392
|
const peers = new Map();
|
|
393
|
+
const recentPeerConnections = [];
|
|
383
394
|
const revokedMembers = new Map();
|
|
384
395
|
let signalSocket = null;
|
|
385
396
|
let signalGeneration = 0;
|
|
@@ -470,8 +481,9 @@ export function createHubConsoleDirect(options = {}) {
|
|
|
470
481
|
|
|
471
482
|
const isOwnerActive = owner => !owner.retired
|
|
472
483
|
&& owner.hubEpoch === hubEpoch
|
|
473
|
-
&& peers.get(owner.
|
|
484
|
+
&& peers.get(owner.key) === owner
|
|
474
485
|
&& owner.generation > 0
|
|
486
|
+
&& (!owner.parent || isOwnerActive(owner.parent))
|
|
475
487
|
&& ownerWorkspaceAccessCurrent(owner);
|
|
476
488
|
|
|
477
489
|
const memberFenceKey = (workspaceId, userId) => `${String(workspaceId || '').trim()}:${String(userId || '').trim()}`;
|
|
@@ -509,6 +521,12 @@ export function createHubConsoleDirect(options = {}) {
|
|
|
509
521
|
hubEpoch: owner.hubEpoch
|
|
510
522
|
});
|
|
511
523
|
|
|
524
|
+
const sendMediaSignal = (parent, mediaId, signal) => isOwnerActive(parent)
|
|
525
|
+
&& sendControl(parent, { type: 'media-signal', connectionId: parent.connectionId,
|
|
526
|
+
hubEpoch: parent.hubEpoch, mediaId, signal });
|
|
527
|
+
const sendPeerSignal = (owner, signal) => owner.parent
|
|
528
|
+
? sendMediaSignal(owner.parent, owner.consoleId, signal) : sendSignal(signal);
|
|
529
|
+
|
|
512
530
|
const disposeAssemblerTimer = channelState => {
|
|
513
531
|
if (channelState.assemblyTimer) clearTimeout(channelState.assemblyTimer);
|
|
514
532
|
channelState.assemblyTimer = null;
|
|
@@ -564,12 +582,21 @@ export function createHubConsoleDirect(options = {}) {
|
|
|
564
582
|
String(options.reason || 'console-direct-channel-closed').slice(0, 120)
|
|
565
583
|
);
|
|
566
584
|
try { channelState.channel.close(); } catch { /* exact channel is already closed */ }
|
|
585
|
+
if (owner.parent && !owner.retired && owner.channels.size === 0) {
|
|
586
|
+
retirePeer(owner, String(options.reason || 'console-direct-media-lane-idle'));
|
|
587
|
+
}
|
|
567
588
|
};
|
|
568
589
|
|
|
569
590
|
const retirePeer = (owner, reason = 'console-direct-peer-closed', options = {}) => {
|
|
570
591
|
if (!owner || owner.retired) return;
|
|
571
592
|
owner.retired = true;
|
|
572
|
-
|
|
593
|
+
for (const child of [...owner.mediaPeers.values()]) retirePeer(child, reason, { notify: false });
|
|
594
|
+
owner.mediaPeers.clear();
|
|
595
|
+
if (owner.parent?.mediaPeers.get(owner.consoleId) === owner) owner.parent.mediaPeers.delete(owner.consoleId);
|
|
596
|
+
owner.iceSetupGate?.close();
|
|
597
|
+
void owner.routerMapping?.release();
|
|
598
|
+
retainConsoleIceRetirement(recentPeerConnections, owner, reason);
|
|
599
|
+
if (peers.get(owner.key) === owner) peers.delete(owner.key);
|
|
573
600
|
clearTimeout(owner.iceTimer);
|
|
574
601
|
owner.iceTimer = null;
|
|
575
602
|
clearTimeout(owner.disconnectTimer);
|
|
@@ -595,13 +622,13 @@ export function createHubConsoleDirect(options = {}) {
|
|
|
595
622
|
owner.control = null;
|
|
596
623
|
}
|
|
597
624
|
const terminalBudget = owner.wireBudget.inspect();
|
|
598
|
-
if (terminalBudget.retainedBytes !== 0 || terminalBudget.reservationCount !== 0) {
|
|
625
|
+
if (!owner.parent && (terminalBudget.retainedBytes !== 0 || terminalBudget.reservationCount !== 0)) {
|
|
599
626
|
wireBudgetCleanupFailures += 1;
|
|
600
627
|
lastError = 'console-direct-wire-budget-not-zero-after-peer-close';
|
|
601
628
|
}
|
|
602
629
|
try { owner.peer.close(); } catch { /* exact peer is already closed */ }
|
|
603
630
|
if (options.notify !== false) {
|
|
604
|
-
|
|
631
|
+
sendPeerSignal(owner, { type: 'rtc-close', ...ownerEnvelope(owner), reason: String(reason).slice(0, 120) });
|
|
605
632
|
}
|
|
606
633
|
};
|
|
607
634
|
|
|
@@ -617,7 +644,7 @@ export function createHubConsoleDirect(options = {}) {
|
|
|
617
644
|
const generation = owner.generation;
|
|
618
645
|
owner.accessTimer = setTimeout(() => {
|
|
619
646
|
owner.accessTimer = null;
|
|
620
|
-
if (peers.get(owner.
|
|
647
|
+
if (peers.get(owner.key) !== owner || owner.generation !== generation || owner.retired) return;
|
|
621
648
|
retirePeer(owner, 'console-workspace-access-expired');
|
|
622
649
|
}, delay);
|
|
623
650
|
owner.accessTimer.unref?.();
|
|
@@ -629,6 +656,7 @@ export function createHubConsoleDirect(options = {}) {
|
|
|
629
656
|
|
|
630
657
|
const retireNegotiatingPeers = reason => {
|
|
631
658
|
for (const owner of [...peers.values()]) {
|
|
659
|
+
if (owner.parent) continue; // Child negotiation uses the healthy direct parent, not the Worker.
|
|
632
660
|
if (!owner.control || !channelIsOpen(owner.control.channel)) {
|
|
633
661
|
retirePeer(owner, reason, { notify: false });
|
|
634
662
|
}
|
|
@@ -676,6 +704,13 @@ export function createHubConsoleDirect(options = {}) {
|
|
|
676
704
|
}
|
|
677
705
|
};
|
|
678
706
|
|
|
707
|
+
const familyBufferedAmount = owner => {
|
|
708
|
+
const root = owner.parent || owner;
|
|
709
|
+
let total = ownerBufferedAmount(root);
|
|
710
|
+
for (const child of root.mediaPeers.values()) total += ownerBufferedAmount(child);
|
|
711
|
+
return total;
|
|
712
|
+
};
|
|
713
|
+
|
|
679
714
|
const releaseLogicalBacklogForControl = (owner, requiredBytes) => {
|
|
680
715
|
const purposePriority = { frame: 0, atlas: 1, audio: 2, input: 3 };
|
|
681
716
|
const candidates = [...owner.channels.values()]
|
|
@@ -729,7 +764,7 @@ export function createHubConsoleDirect(options = {}) {
|
|
|
729
764
|
const partialMedia = channelState.purpose === 'frame' || channelState.purpose === 'atlas';
|
|
730
765
|
if (partialMedia
|
|
731
766
|
&& (wireBytes > MAX_DATA_CHANNEL_BUFFERED_BYTES
|
|
732
|
-
||
|
|
767
|
+
|| familyBufferedAmount(owner) + wireBytes > MAX_DATA_CHANNEL_BUFFERED_BYTES)) {
|
|
733
768
|
dataChannelBackpressureCloses += 1;
|
|
734
769
|
if (options.closeOnFailure !== false) {
|
|
735
770
|
closeLogicalChannel(owner, channelState, {
|
|
@@ -762,7 +797,7 @@ export function createHubConsoleDirect(options = {}) {
|
|
|
762
797
|
releaseLogicalBacklogForControl(owner, wireBytes);
|
|
763
798
|
}
|
|
764
799
|
if (wireBytes > MAX_DATA_CHANNEL_BUFFERED_BYTES
|
|
765
|
-
||
|
|
800
|
+
|| familyBufferedAmount(owner) + wireBytes > MAX_DATA_CHANNEL_BUFFERED_BYTES) {
|
|
766
801
|
dataChannelBackpressureCloses += 1;
|
|
767
802
|
if (options.closeOnFailure !== false) {
|
|
768
803
|
if (channelState === owner.control) retirePeer(owner, 'console-direct-control-backpressure');
|
|
@@ -774,6 +809,7 @@ export function createHubConsoleDirect(options = {}) {
|
|
|
774
809
|
}
|
|
775
810
|
return false;
|
|
776
811
|
}
|
|
812
|
+
const sendStartedAt = performance.now();
|
|
777
813
|
for (const [chunkIndex, chunk] of chunks.entries()) {
|
|
778
814
|
if (!isOwnerActive(owner) || channelState.closed || !channelIsOpen(channelState.channel)) return false;
|
|
779
815
|
try {
|
|
@@ -810,6 +846,8 @@ export function createHubConsoleDirect(options = {}) {
|
|
|
810
846
|
return false;
|
|
811
847
|
}
|
|
812
848
|
}
|
|
849
|
+
if (!isOwnerActive(owner) || channelState.closed) return false;
|
|
850
|
+
channelState.frameAdmission?.sent(wireBytes, sendStartedAt);
|
|
813
851
|
return true;
|
|
814
852
|
}
|
|
815
853
|
|
|
@@ -1088,7 +1126,12 @@ export function createHubConsoleDirect(options = {}) {
|
|
|
1088
1126
|
const handleControlPayload = (owner, text) => {
|
|
1089
1127
|
const payload = parseJson(text);
|
|
1090
1128
|
if (!payload?.type) throw new Error('console-direct-control-invalid');
|
|
1129
|
+
if (payload.type === 'media-signal') {
|
|
1130
|
+
handleMediaSignal(owner, payload);
|
|
1131
|
+
return;
|
|
1132
|
+
}
|
|
1091
1133
|
if (payload.type === 'http-request') {
|
|
1134
|
+
if (owner.parent) throw new Error('console-direct-media-http-forbidden');
|
|
1092
1135
|
void handleHttpRequest(owner, payload);
|
|
1093
1136
|
return;
|
|
1094
1137
|
}
|
|
@@ -1201,7 +1244,9 @@ export function createHubConsoleDirect(options = {}) {
|
|
|
1201
1244
|
} else {
|
|
1202
1245
|
const identity = parseLogicalWebSocketLabel(label);
|
|
1203
1246
|
if (!identity
|
|
1204
|
-
|| owner.channels.size >= MAX_LOGICAL_CHANNELS_PER_PEER
|
|
1247
|
+
|| owner.channels.size + owner.mediaPeers.size >= MAX_LOGICAL_CHANNELS_PER_PEER
|
|
1248
|
+
|| (owner.parent && (identity.channelId !== owner.consoleId
|
|
1249
|
+
|| !['frame', 'atlas'].includes(identity.purpose)))
|
|
1205
1250
|
|| owner.channels.has(identity?.channelId)) {
|
|
1206
1251
|
try { channel.close(); } catch {}
|
|
1207
1252
|
return;
|
|
@@ -1252,12 +1297,15 @@ export function createHubConsoleDirect(options = {}) {
|
|
|
1252
1297
|
channel.onOpen(() => {
|
|
1253
1298
|
if (!isOwnerActive(owner) || channelState.closed) return;
|
|
1254
1299
|
if (channelState === owner.control) {
|
|
1300
|
+
owner.controlOpened = true;
|
|
1301
|
+
void owner.routerMapping?.release();
|
|
1255
1302
|
clearTimeout(owner.iceTimer);
|
|
1256
1303
|
owner.iceTimer = null;
|
|
1257
1304
|
sendControl(owner, {
|
|
1258
1305
|
type: 'direct-ready',
|
|
1259
1306
|
connectionId: owner.connectionId,
|
|
1260
|
-
hubEpoch: owner.hubEpoch
|
|
1307
|
+
hubEpoch: owner.hubEpoch,
|
|
1308
|
+
...(!owner.parent ? { mediaPeerVersion: 1 } : {})
|
|
1261
1309
|
});
|
|
1262
1310
|
} else {
|
|
1263
1311
|
consumePendingLogicalControl(owner, channelState);
|
|
@@ -1265,12 +1313,16 @@ export function createHubConsoleDirect(options = {}) {
|
|
|
1265
1313
|
});
|
|
1266
1314
|
};
|
|
1267
1315
|
|
|
1268
|
-
const createPeerOwner = payload => {
|
|
1316
|
+
const createPeerOwner = (payload, parent = null) => {
|
|
1269
1317
|
const consoleId = String(payload?.consoleId || '');
|
|
1270
1318
|
const connectionId = String(payload?.connectionId || '');
|
|
1271
1319
|
const ownerHubEpoch = String(payload?.hubEpoch || '');
|
|
1272
1320
|
const description = payload?.description;
|
|
1273
|
-
|
|
1321
|
+
// A child receives authority only from the exact authenticated parent.
|
|
1322
|
+
// Browser-supplied workspace/member fields can never elevate it.
|
|
1323
|
+
const workspaceAccess = parent ? parent.workspaceAccess : consoleWorkspaceAccess(payload);
|
|
1324
|
+
const reply = message => parent ? sendMediaSignal(parent, consoleId, message) : sendSignal(message);
|
|
1325
|
+
const key = parent ? `${parent.key}/${consoleId}` : consoleId;
|
|
1274
1326
|
if (!UUID_PATTERN.test(consoleId)
|
|
1275
1327
|
|| !UUID_PATTERN.test(connectionId)
|
|
1276
1328
|
|| ownerHubEpoch !== hubEpoch
|
|
@@ -1279,7 +1331,7 @@ export function createHubConsoleDirect(options = {}) {
|
|
|
1279
1331
|
|| typeof description.sdp !== 'string'
|
|
1280
1332
|
|| byteLength(description.sdp) > SDP_MAX_BYTES) {
|
|
1281
1333
|
if (UUID_PATTERN.test(consoleId) && UUID_PATTERN.test(connectionId)) {
|
|
1282
|
-
|
|
1334
|
+
reply({
|
|
1283
1335
|
type: 'rtc-close',
|
|
1284
1336
|
consoleId,
|
|
1285
1337
|
connectionId,
|
|
@@ -1290,7 +1342,7 @@ export function createHubConsoleDirect(options = {}) {
|
|
|
1290
1342
|
return null;
|
|
1291
1343
|
}
|
|
1292
1344
|
if (memberIsFenced(workspaceAccess)) {
|
|
1293
|
-
|
|
1345
|
+
reply({
|
|
1294
1346
|
type: 'rtc-close',
|
|
1295
1347
|
consoleId,
|
|
1296
1348
|
connectionId,
|
|
@@ -1301,7 +1353,7 @@ export function createHubConsoleDirect(options = {}) {
|
|
|
1301
1353
|
}
|
|
1302
1354
|
if (candidateIsRelay(description.sdp)) {
|
|
1303
1355
|
rejectedRelayCandidates += 1;
|
|
1304
|
-
|
|
1356
|
+
reply({
|
|
1305
1357
|
type: 'rtc-close',
|
|
1306
1358
|
consoleId,
|
|
1307
1359
|
connectionId,
|
|
@@ -1310,14 +1362,14 @@ export function createHubConsoleDirect(options = {}) {
|
|
|
1310
1362
|
});
|
|
1311
1363
|
return null;
|
|
1312
1364
|
}
|
|
1313
|
-
const existing = peers.get(
|
|
1365
|
+
const existing = peers.get(key);
|
|
1314
1366
|
if (existing
|
|
1315
1367
|
&& existing.connectionId === connectionId
|
|
1316
1368
|
&& existing.hubEpoch === ownerHubEpoch
|
|
1317
1369
|
&& !existing.retired) {
|
|
1318
|
-
const current = existing.peer
|
|
1370
|
+
const current = existing.peer?.localDescription?.();
|
|
1319
1371
|
if (current?.type === 'answer' && typeof current.sdp === 'string') {
|
|
1320
|
-
|
|
1372
|
+
reply({
|
|
1321
1373
|
type: 'rtc-answer',
|
|
1322
1374
|
...ownerEnvelope(existing),
|
|
1323
1375
|
description: { type: 'answer', sdp: current.sdp }
|
|
@@ -1325,8 +1377,16 @@ export function createHubConsoleDirect(options = {}) {
|
|
|
1325
1377
|
}
|
|
1326
1378
|
return existing;
|
|
1327
1379
|
}
|
|
1328
|
-
if (
|
|
1329
|
-
|
|
1380
|
+
if (parent && existing) {
|
|
1381
|
+
reply({ type: 'rtc-close', consoleId, connectionId, hubEpoch: ownerHubEpoch,
|
|
1382
|
+
reason: 'console-direct-media-owner-already-current' });
|
|
1383
|
+
return null;
|
|
1384
|
+
}
|
|
1385
|
+
const rootPeerCount = [...peers.values()].filter(entry => !entry.parent).length;
|
|
1386
|
+
if (!existing && (parent
|
|
1387
|
+
? parent.channels.size + parent.mediaPeers.size >= MAX_LOGICAL_CHANNELS_PER_PEER
|
|
1388
|
+
: rootPeerCount >= MAX_PEERS)) {
|
|
1389
|
+
reply({
|
|
1330
1390
|
type: 'rtc-close',
|
|
1331
1391
|
consoleId,
|
|
1332
1392
|
connectionId,
|
|
@@ -1337,30 +1397,10 @@ export function createHubConsoleDirect(options = {}) {
|
|
|
1337
1397
|
}
|
|
1338
1398
|
if (existing) retirePeer(existing, 'console-direct-peer-replaced');
|
|
1339
1399
|
let peer;
|
|
1340
|
-
try {
|
|
1341
|
-
peer = createPeerConnection(`console-${consoleId}`, {
|
|
1342
|
-
iceServers: [...stunUrls],
|
|
1343
|
-
iceTransportPolicy: 'all',
|
|
1344
|
-
disableAutoNegotiation: false,
|
|
1345
|
-
disableFingerprintVerification: false,
|
|
1346
|
-
// Each native send is one wire chunk. libdatachannel also uses this
|
|
1347
|
-
// setting as the minimum SCTP socket buffer size, below bufferedAmount.
|
|
1348
|
-
// A whole-frame limit here hides megabytes from our admission gate.
|
|
1349
|
-
// Complete frame/HTTP limits remain enforced by the wire assemblers.
|
|
1350
|
-
maxMessageSize: DIRECT_CONSOLE_WIRE_CHUNK_BYTES
|
|
1351
|
-
});
|
|
1352
|
-
} catch (error) {
|
|
1353
|
-
lastError = error instanceof Error ? error.message : String(error);
|
|
1354
|
-
sendSignal({
|
|
1355
|
-
type: 'rtc-close',
|
|
1356
|
-
consoleId,
|
|
1357
|
-
connectionId,
|
|
1358
|
-
hubEpoch: ownerHubEpoch,
|
|
1359
|
-
reason: 'console-direct-peer-create-failed'
|
|
1360
|
-
});
|
|
1361
|
-
return null;
|
|
1362
|
-
}
|
|
1363
1400
|
const owner = {
|
|
1401
|
+
key,
|
|
1402
|
+
parent,
|
|
1403
|
+
mediaPeers: new Map(),
|
|
1364
1404
|
consoleId,
|
|
1365
1405
|
connectionId,
|
|
1366
1406
|
hubEpoch: ownerHubEpoch,
|
|
@@ -1371,76 +1411,25 @@ export function createHubConsoleDirect(options = {}) {
|
|
|
1371
1411
|
channels: new Map(),
|
|
1372
1412
|
pendingLogicalControl: new Map(),
|
|
1373
1413
|
pendingHttp: new Map(),
|
|
1374
|
-
wireBudget: createDirectConsoleWireBudget({ maxBytes: MAX_SHARED_ASSEMBLY_BYTES }),
|
|
1414
|
+
wireBudget: parent?.wireBudget || createDirectConsoleWireBudget({ maxBytes: MAX_SHARED_ASSEMBLY_BYTES }),
|
|
1375
1415
|
iceTimer: null,
|
|
1376
1416
|
disconnectTimer: null,
|
|
1377
1417
|
accessTimer: null,
|
|
1378
1418
|
peerState: 'new',
|
|
1379
1419
|
iceState: 'new',
|
|
1420
|
+
controlOpened: false,
|
|
1421
|
+
routerMapping: null,
|
|
1422
|
+
iceSetupGate: null,
|
|
1423
|
+
iceEvidence: createConsoleIceEvidence(),
|
|
1380
1424
|
retired: false,
|
|
1381
1425
|
createdAt: Date.now()
|
|
1382
1426
|
};
|
|
1383
|
-
peers.set(
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
retirePeer(owner, 'console-direct-relay-candidate-rejected');
|
|
1390
|
-
return;
|
|
1391
|
-
}
|
|
1392
|
-
sendSignal({
|
|
1393
|
-
type: 'rtc-answer',
|
|
1394
|
-
...ownerEnvelope(owner),
|
|
1395
|
-
description: { type: 'answer', sdp: String(sdp) }
|
|
1396
|
-
});
|
|
1397
|
-
});
|
|
1398
|
-
peer.onLocalCandidate((candidate, sdpMid) => {
|
|
1399
|
-
if (!isOwnerActive(owner)) return;
|
|
1400
|
-
const candidateText = String(candidate || '');
|
|
1401
|
-
if (byteLength(candidateText) > ICE_CANDIDATE_MAX_BYTES) {
|
|
1402
|
-
retirePeer(owner, 'console-direct-candidate-invalid');
|
|
1403
|
-
return;
|
|
1404
|
-
}
|
|
1405
|
-
if (candidateIsRelay(candidateText)) {
|
|
1406
|
-
rejectedRelayCandidates += 1;
|
|
1407
|
-
retirePeer(owner, 'console-direct-relay-candidate-rejected');
|
|
1408
|
-
return;
|
|
1409
|
-
}
|
|
1410
|
-
sendSignal({
|
|
1411
|
-
type: 'rtc-ice',
|
|
1412
|
-
...ownerEnvelope(owner),
|
|
1413
|
-
candidate: candidateText,
|
|
1414
|
-
sdpMid: sdpMid === undefined || sdpMid === null ? null : String(sdpMid).slice(0, 256)
|
|
1415
|
-
});
|
|
1416
|
-
});
|
|
1417
|
-
peer.onDataChannel(channel => bindDataChannel(owner, channel));
|
|
1418
|
-
peer.onStateChange(nextState => {
|
|
1419
|
-
if (!isOwnerActive(owner)) return;
|
|
1420
|
-
const normalized = String(nextState || '').toLowerCase();
|
|
1421
|
-
owner.peerState = normalized;
|
|
1422
|
-
if (normalized === 'connected') {
|
|
1423
|
-
if (peerSelectedRelay(peer)) {
|
|
1424
|
-
rejectedRelayCandidates += 1;
|
|
1425
|
-
retirePeer(owner, 'console-direct-relay-pair-rejected');
|
|
1426
|
-
return;
|
|
1427
|
-
}
|
|
1428
|
-
} else if (normalized === 'failed' || normalized === 'closed') {
|
|
1429
|
-
retirePeer(owner, `console-direct-peer-${normalized}`);
|
|
1430
|
-
return;
|
|
1431
|
-
}
|
|
1432
|
-
refreshPeerDisconnectDeadline(owner);
|
|
1433
|
-
});
|
|
1434
|
-
peer.onIceStateChange?.(nextState => {
|
|
1435
|
-
if (!isOwnerActive(owner)) return;
|
|
1436
|
-
const normalized = String(nextState || '').toLowerCase();
|
|
1437
|
-
owner.iceState = normalized;
|
|
1438
|
-
if (normalized === 'failed' || normalized === 'closed') {
|
|
1439
|
-
retirePeer(owner, `console-direct-ice-${normalized}`);
|
|
1440
|
-
return;
|
|
1441
|
-
}
|
|
1442
|
-
refreshPeerDisconnectDeadline(owner);
|
|
1443
|
-
});
|
|
1427
|
+
peers.set(key, owner);
|
|
1428
|
+
if (parent) parent.mediaPeers.set(consoleId, owner);
|
|
1429
|
+
owner.routerMapping = routerMapping.createOwner(`${consoleId}:${connectionId}:${owner.generation}`,
|
|
1430
|
+
{ portRange: icePortRange, ...(parent ? { queue: true } : {}) });
|
|
1431
|
+
if (owner.routerMapping?.handoff) owner.iceSetupGate = createConsoleIceSetupGate();
|
|
1432
|
+
if (!parent) armOwnerAccessDeadline(owner);
|
|
1444
1433
|
owner.iceTimer = setTimeout(() => {
|
|
1445
1434
|
owner.iceTimer = null;
|
|
1446
1435
|
if (isOwnerActive(owner)
|
|
@@ -1449,14 +1438,161 @@ export function createHubConsoleDirect(options = {}) {
|
|
|
1449
1438
|
}
|
|
1450
1439
|
}, iceConnectTimeoutMs);
|
|
1451
1440
|
owner.iceTimer.unref?.();
|
|
1441
|
+
const initializePeer = reservedPort => {
|
|
1442
|
+
if (!isOwnerActive(owner)) return null;
|
|
1443
|
+
try {
|
|
1444
|
+
peer = createPeerConnection(`console-${consoleId}`, {
|
|
1445
|
+
iceServers: [...stunUrls],
|
|
1446
|
+
iceTransportPolicy: 'all',
|
|
1447
|
+
...icePortRange,
|
|
1448
|
+
...(reservedPort ? { portRangeBegin: reservedPort, portRangeEnd: reservedPort } : {}),
|
|
1449
|
+
disableAutoNegotiation: false,
|
|
1450
|
+
disableFingerprintVerification: false,
|
|
1451
|
+
// Keep the native SCTP socket's hidden buffer at the wire chunk bound.
|
|
1452
|
+
maxMessageSize: DIRECT_CONSOLE_WIRE_CHUNK_BYTES
|
|
1453
|
+
});
|
|
1454
|
+
owner.peer = peer;
|
|
1455
|
+
} catch (error) {
|
|
1456
|
+
lastError = error instanceof Error ? error.message : String(error);
|
|
1457
|
+
retirePeer(owner, 'console-direct-peer-create-failed');
|
|
1458
|
+
return null;
|
|
1459
|
+
}
|
|
1460
|
+
peer.onLocalDescription((sdp, type) => {
|
|
1461
|
+
if (!isOwnerActive(owner) || String(type).toLowerCase() !== 'answer' || byteLength(sdp) > SDP_MAX_BYTES) return;
|
|
1462
|
+
if (candidateIsRelay(sdp)) {
|
|
1463
|
+
rejectedRelayCandidates += 1;
|
|
1464
|
+
retirePeer(owner, 'console-direct-relay-candidate-rejected');
|
|
1465
|
+
return;
|
|
1466
|
+
}
|
|
1467
|
+
const sent = sendPeerSignal(owner, {
|
|
1468
|
+
type: 'rtc-answer',
|
|
1469
|
+
...ownerEnvelope(owner),
|
|
1470
|
+
description: { type: 'answer', sdp: String(sdp) }
|
|
1471
|
+
});
|
|
1472
|
+
owner.iceEvidence.localDescriptionSent = sent;
|
|
1473
|
+
recordConsoleIceDescription(owner.iceEvidence.local, sdp);
|
|
1474
|
+
if (!owner.controlOpened) {
|
|
1475
|
+
for (const line of sdp.split(/\r?\n/)) {
|
|
1476
|
+
if (line.startsWith('a=candidate:')) owner.routerMapping?.observe(line);
|
|
1477
|
+
}
|
|
1478
|
+
}
|
|
1479
|
+
});
|
|
1480
|
+
peer.onLocalCandidate((candidate, sdpMid) => {
|
|
1481
|
+
if (!isOwnerActive(owner)) return;
|
|
1482
|
+
const candidateText = String(candidate || '');
|
|
1483
|
+
if (byteLength(candidateText) > ICE_CANDIDATE_MAX_BYTES) {
|
|
1484
|
+
retirePeer(owner, 'console-direct-candidate-invalid');
|
|
1485
|
+
return;
|
|
1486
|
+
}
|
|
1487
|
+
if (candidateIsRelay(candidateText)) {
|
|
1488
|
+
rejectedRelayCandidates += 1;
|
|
1489
|
+
retirePeer(owner, 'console-direct-relay-candidate-rejected');
|
|
1490
|
+
return;
|
|
1491
|
+
}
|
|
1492
|
+
const sent = sendPeerSignal(owner, {
|
|
1493
|
+
type: 'rtc-ice',
|
|
1494
|
+
...ownerEnvelope(owner),
|
|
1495
|
+
candidate: candidateText,
|
|
1496
|
+
sdpMid: sdpMid === undefined || sdpMid === null ? null : String(sdpMid).slice(0, 256)
|
|
1497
|
+
});
|
|
1498
|
+
recordConsoleIceCandidate(owner.iceEvidence.local, candidateText);
|
|
1499
|
+
if (!owner.controlOpened) owner.routerMapping?.observe(candidateText);
|
|
1500
|
+
if (!sent) owner.iceEvidence.localCandidateSendFailures = Math.min(65_535,
|
|
1501
|
+
owner.iceEvidence.localCandidateSendFailures + 1);
|
|
1502
|
+
});
|
|
1503
|
+
peer.onGatheringStateChange?.(nextState => {
|
|
1504
|
+
if (isOwnerActive(owner)) owner.iceEvidence.gatheringState = safeIceState(String(nextState).toLowerCase());
|
|
1505
|
+
});
|
|
1506
|
+
peer.onDataChannel(channel => bindDataChannel(owner, channel));
|
|
1507
|
+
peer.onStateChange(nextState => {
|
|
1508
|
+
if (!isOwnerActive(owner)) return;
|
|
1509
|
+
const normalized = String(nextState || '').toLowerCase();
|
|
1510
|
+
owner.peerState = normalized;
|
|
1511
|
+
if (normalized === 'connected') {
|
|
1512
|
+
if (peerSelectedRelay(peer)) {
|
|
1513
|
+
rejectedRelayCandidates += 1;
|
|
1514
|
+
retirePeer(owner, 'console-direct-relay-pair-rejected');
|
|
1515
|
+
return;
|
|
1516
|
+
}
|
|
1517
|
+
} else if (normalized === 'failed' || normalized === 'closed') {
|
|
1518
|
+
retirePeer(owner, `console-direct-peer-${normalized}`);
|
|
1519
|
+
return;
|
|
1520
|
+
}
|
|
1521
|
+
refreshPeerDisconnectDeadline(owner);
|
|
1522
|
+
});
|
|
1523
|
+
peer.onIceStateChange?.(nextState => {
|
|
1524
|
+
if (!isOwnerActive(owner)) return;
|
|
1525
|
+
const normalized = String(nextState || '').toLowerCase();
|
|
1526
|
+
owner.iceState = normalized;
|
|
1527
|
+
if (normalized === 'failed' || normalized === 'closed') {
|
|
1528
|
+
retirePeer(owner, `console-direct-ice-${normalized}`);
|
|
1529
|
+
return;
|
|
1530
|
+
}
|
|
1531
|
+
refreshPeerDisconnectDeadline(owner);
|
|
1532
|
+
});
|
|
1533
|
+
try {
|
|
1534
|
+
peer.setRemoteDescription(description.sdp, 'offer');
|
|
1535
|
+
owner.iceEvidence.remoteDescriptionApplied = true;
|
|
1536
|
+
recordConsoleIceDescription(owner.iceEvidence.remote, description.sdp);
|
|
1537
|
+
owner.iceSetupGate?.flush((candidate, mid) => peer.addRemoteCandidate(candidate, mid),
|
|
1538
|
+
() => isOwnerActive(owner));
|
|
1539
|
+
} catch (error) {
|
|
1540
|
+
lastError = error instanceof Error ? error.message : String(error);
|
|
1541
|
+
retirePeer(owner, 'console-direct-description-failed');
|
|
1542
|
+
return null;
|
|
1543
|
+
}
|
|
1544
|
+
return owner;
|
|
1545
|
+
};
|
|
1546
|
+
if (owner.routerMapping?.handoff) {
|
|
1547
|
+
void owner.routerMapping.ready.then(async () => {
|
|
1548
|
+
const reservation = await owner.routerMapping.handoff();
|
|
1549
|
+
if (isOwnerActive(owner)) initializePeer(reservation?.port);
|
|
1550
|
+
}).catch(() => retirePeer(owner, 'console-direct-peer-create-failed'));
|
|
1551
|
+
} else initializePeer();
|
|
1552
|
+
return owner;
|
|
1553
|
+
};
|
|
1554
|
+
|
|
1555
|
+
const handlePeerSignal = (owner, payload) => {
|
|
1556
|
+
if (payload.type === 'rtc-close') {
|
|
1557
|
+
retirePeer(owner, String(payload.reason || 'console-direct-remote-closed').slice(0, 120), { notify: false });
|
|
1558
|
+
return;
|
|
1559
|
+
}
|
|
1560
|
+
if (payload.type !== 'rtc-ice') return;
|
|
1561
|
+
const candidate = String(payload.candidate || '');
|
|
1562
|
+
if (byteLength(candidate) > ICE_CANDIDATE_MAX_BYTES || String(payload.sdpMid || '').length > 256) {
|
|
1563
|
+
retirePeer(owner, 'console-direct-candidate-invalid');
|
|
1564
|
+
return;
|
|
1565
|
+
}
|
|
1566
|
+
if (candidateIsRelay(candidate)) {
|
|
1567
|
+
rejectedRelayCandidates += 1;
|
|
1568
|
+
retirePeer(owner, 'console-direct-relay-candidate-rejected');
|
|
1569
|
+
return;
|
|
1570
|
+
}
|
|
1452
1571
|
try {
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1572
|
+
if (candidate) {
|
|
1573
|
+
recordConsoleIceCandidate(owner.iceEvidence.remote, candidate);
|
|
1574
|
+
if (!owner.iceSetupGate?.defer(candidate, String(payload.sdpMid || ''))) {
|
|
1575
|
+
owner.peer.addRemoteCandidate(candidate, String(payload.sdpMid || ''));
|
|
1576
|
+
}
|
|
1577
|
+
}
|
|
1578
|
+
} catch { retirePeer(owner, 'console-direct-candidate-failed'); }
|
|
1579
|
+
};
|
|
1580
|
+
|
|
1581
|
+
const handleMediaSignal = (parent, envelope) => {
|
|
1582
|
+
if (parent.parent || !isOwnerActive(parent) || !lifecycleOwnerMatches(parent, envelope)) return;
|
|
1583
|
+
const mediaId = String(envelope.mediaId || '');
|
|
1584
|
+
const payload = envelope.signal;
|
|
1585
|
+
if (!UUID_PATTERN.test(mediaId) || !payload || byteLength(JSON.stringify(payload)) > SIGNAL_MESSAGE_MAX_BYTES
|
|
1586
|
+
|| !UUID_PATTERN.test(String(payload.connectionId || '')) || payload.hubEpoch !== parent.hubEpoch) return;
|
|
1587
|
+
if (payload.type === 'rtc-offer') {
|
|
1588
|
+
// Only the media lane ID and the validated RTC fields cross this boundary.
|
|
1589
|
+
createPeerOwner({ consoleId: mediaId, connectionId: payload.connectionId,
|
|
1590
|
+
hubEpoch: parent.hubEpoch, description: payload.description }, parent);
|
|
1591
|
+
return;
|
|
1458
1592
|
}
|
|
1459
|
-
|
|
1593
|
+
const child = parent.mediaPeers.get(mediaId);
|
|
1594
|
+
if (!child || child.connectionId !== payload.connectionId || !isOwnerActive(child)) return;
|
|
1595
|
+
handlePeerSignal(child, payload);
|
|
1460
1596
|
};
|
|
1461
1597
|
|
|
1462
1598
|
const handleSignalMessage = (raw, ownerSignalGeneration, socket) => {
|
|
@@ -1512,6 +1648,7 @@ export function createHubConsoleDirect(options = {}) {
|
|
|
1512
1648
|
return;
|
|
1513
1649
|
}
|
|
1514
1650
|
owner.workspaceAccess = refreshedAccess;
|
|
1651
|
+
for (const child of owner.mediaPeers.values()) child.workspaceAccess = refreshedAccess;
|
|
1515
1652
|
armOwnerAccessDeadline(owner);
|
|
1516
1653
|
return;
|
|
1517
1654
|
}
|
|
@@ -1582,27 +1719,7 @@ export function createHubConsoleDirect(options = {}) {
|
|
|
1582
1719
|
|| owner.connectionId !== connectionId
|
|
1583
1720
|
|| owner.hubEpoch !== ownerHubEpoch
|
|
1584
1721
|
|| !isOwnerActive(owner)) return;
|
|
1585
|
-
|
|
1586
|
-
const candidate = String(payload.candidate || '');
|
|
1587
|
-
if (byteLength(candidate) > ICE_CANDIDATE_MAX_BYTES) {
|
|
1588
|
-
retirePeer(owner, 'console-direct-candidate-invalid');
|
|
1589
|
-
return;
|
|
1590
|
-
}
|
|
1591
|
-
if (candidateIsRelay(candidate)) {
|
|
1592
|
-
rejectedRelayCandidates += 1;
|
|
1593
|
-
retirePeer(owner, 'console-direct-relay-candidate-rejected');
|
|
1594
|
-
return;
|
|
1595
|
-
}
|
|
1596
|
-
try {
|
|
1597
|
-
if (candidate) owner.peer.addRemoteCandidate(candidate, String(payload.sdpMid || ''));
|
|
1598
|
-
} catch {
|
|
1599
|
-
retirePeer(owner, 'console-direct-candidate-failed');
|
|
1600
|
-
}
|
|
1601
|
-
return;
|
|
1602
|
-
}
|
|
1603
|
-
if (payload.type === 'rtc-close') {
|
|
1604
|
-
retirePeer(owner, String(payload.reason || 'console-direct-remote-closed'), { notify: false });
|
|
1605
|
-
}
|
|
1722
|
+
handlePeerSignal(owner, payload);
|
|
1606
1723
|
};
|
|
1607
1724
|
|
|
1608
1725
|
const cancelAccessTokenAttempt = () => {
|
|
@@ -1951,6 +2068,8 @@ export function createHubConsoleDirect(options = {}) {
|
|
|
1951
2068
|
retryTimer = null;
|
|
1952
2069
|
nextRetryAt = '';
|
|
1953
2070
|
retireAllPeers('hub-shutdown');
|
|
2071
|
+
void routerMapping.releaseAll({ forgetHistory: true });
|
|
2072
|
+
recentPeerConnections.length = 0;
|
|
1954
2073
|
hubEpoch = '';
|
|
1955
2074
|
const socket = signalSocket;
|
|
1956
2075
|
signalSocket = null;
|
|
@@ -1961,6 +2080,8 @@ export function createHubConsoleDirect(options = {}) {
|
|
|
1961
2080
|
|
|
1962
2081
|
const invalidateWorkspaceAccess = (reason = 'console-workspace-access-invalidated') => {
|
|
1963
2082
|
retireAllPeers(String(reason || 'console-workspace-access-invalidated').slice(0, 120));
|
|
2083
|
+
void routerMapping.releaseAll({ forgetHistory: true });
|
|
2084
|
+
recentPeerConnections.length = 0;
|
|
1964
2085
|
refresh();
|
|
1965
2086
|
};
|
|
1966
2087
|
|
|
@@ -1976,6 +2097,7 @@ export function createHubConsoleDirect(options = {}) {
|
|
|
1976
2097
|
pruneRevokedMembers();
|
|
1977
2098
|
let retired = 0;
|
|
1978
2099
|
for (const owner of [...peers.values()]) {
|
|
2100
|
+
if (owner.retired) continue;
|
|
1979
2101
|
if (owner.workspaceAccess?.workspaceId !== normalizedWorkspaceId
|
|
1980
2102
|
|| owner.workspaceAccess?.userId !== normalizedUserId) continue;
|
|
1981
2103
|
retirePeer(owner, String(reason || 'workspace-member-revoked').slice(0, 120));
|
|
@@ -2010,14 +2132,18 @@ export function createHubConsoleDirect(options = {}) {
|
|
|
2010
2132
|
if (channelState.localSocket) localWebSocketChannels += 1;
|
|
2011
2133
|
if (channelState.assemblyTimer) assemblyDeadlineTimers += 1;
|
|
2012
2134
|
}
|
|
2013
|
-
retainedAssemblyBytes += Number(owner.wireBudget.inspect().retainedBytes || 0);
|
|
2135
|
+
if (!owner.parent) retainedAssemblyBytes += Number(owner.wireBudget.inspect().retainedBytes || 0);
|
|
2014
2136
|
bufferedSendBytes += ownerBufferedAmount(owner);
|
|
2015
2137
|
peerDiagnostics.push({
|
|
2016
2138
|
consoleId: owner.consoleId,
|
|
2139
|
+
transportRole: owner.parent ? 'media' : 'control',
|
|
2140
|
+
parentConsoleId: owner.parent?.consoleId || null,
|
|
2141
|
+
parentConnectionId: owner.parent?.connectionId || null,
|
|
2017
2142
|
connectionId: owner.connectionId,
|
|
2018
2143
|
generation: owner.generation,
|
|
2019
2144
|
peerState: owner.peerState,
|
|
2020
2145
|
iceState: owner.iceState,
|
|
2146
|
+
iceEvidence: snapshotConsoleIceEvidence(owner.iceEvidence),
|
|
2021
2147
|
rttMs: optionalPeerMetric(owner.peer, 'rtt'),
|
|
2022
2148
|
bytesSent: optionalPeerMetric(owner.peer, 'bytesSent'),
|
|
2023
2149
|
bytesReceived: optionalPeerMetric(owner.peer, 'bytesReceived'),
|
|
@@ -2041,12 +2167,15 @@ export function createHubConsoleDirect(options = {}) {
|
|
|
2041
2167
|
const accessTokenDeadlineActive = Boolean(accessTokenAttempt);
|
|
2042
2168
|
const signalReadyDeadlineActive = Boolean(signalReadyDeadline);
|
|
2043
2169
|
const signalHeartbeatTimerActive = Boolean(signalHeartbeat?.timer);
|
|
2170
|
+
const routerResources = routerMapping.inspect();
|
|
2044
2171
|
return Object.freeze({
|
|
2045
2172
|
enabled: signalUrl instanceof URL && Boolean(deviceId),
|
|
2046
2173
|
state,
|
|
2047
2174
|
connected: state === 'connected',
|
|
2048
2175
|
hubEpoch,
|
|
2049
2176
|
peerConnections: peers.size,
|
|
2177
|
+
consoleConnections: [...peers.values()].filter(owner => !owner.parent).length,
|
|
2178
|
+
mediaPeerConnections: [...peers.values()].filter(owner => owner.parent).length,
|
|
2050
2179
|
controlChannels,
|
|
2051
2180
|
logicalWebSocketChannels,
|
|
2052
2181
|
localWebSocketChannels,
|
|
@@ -2055,6 +2184,8 @@ export function createHubConsoleDirect(options = {}) {
|
|
|
2055
2184
|
retainedAssemblyBytes,
|
|
2056
2185
|
bufferedSendBytes,
|
|
2057
2186
|
peerDiagnostics,
|
|
2187
|
+
routerMapping: routerResources,
|
|
2188
|
+
recentPeerConnections: recentPeerConnections.map(entry => snapshotConsoleIceEvidence(entry)),
|
|
2058
2189
|
iceConnectTimers,
|
|
2059
2190
|
peerDisconnectTimers,
|
|
2060
2191
|
assemblyDeadlineTimers,
|
|
@@ -2075,7 +2206,8 @@ export function createHubConsoleDirect(options = {}) {
|
|
|
2075
2206
|
+ workspaceAccessTimers
|
|
2076
2207
|
+ Number(Boolean(workspaceReauthAttempt?.timer))
|
|
2077
2208
|
+ Number(signalReadyDeadlineActive)
|
|
2078
|
-
+ Number(signalHeartbeatTimerActive)
|
|
2209
|
+
+ Number(signalHeartbeatTimerActive)
|
|
2210
|
+
+ Number(routerResources.resourceTimers || 0),
|
|
2079
2211
|
rejectedRelayCandidates,
|
|
2080
2212
|
dataChannelBackpressureCloses,
|
|
2081
2213
|
dataChannelSendFailures,
|
|
@@ -2096,7 +2228,8 @@ export function createHubConsoleDirect(options = {}) {
|
|
|
2096
2228
|
close,
|
|
2097
2229
|
invalidateWorkspaceAccess,
|
|
2098
2230
|
revokeWorkspaceMember,
|
|
2099
|
-
inspect
|
|
2231
|
+
inspect,
|
|
2232
|
+
releaseRouterMappings: () => routerMapping.releaseAll()
|
|
2100
2233
|
});
|
|
2101
2234
|
}
|
|
2102
2235
|
|