@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.
@@ -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.consoleId) === 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
- if (peers.get(owner.consoleId) === owner) peers.delete(owner.consoleId);
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
- sendSignal({ type: 'rtc-close', ...ownerEnvelope(owner), reason: String(reason).slice(0, 120) });
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.consoleId) !== owner || owner.generation !== generation || owner.retired) return;
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
- || ownerBufferedAmount(owner) + wireBytes > MAX_DATA_CHANNEL_BUFFERED_BYTES)) {
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
- || ownerBufferedAmount(owner) + wireBytes > MAX_DATA_CHANNEL_BUFFERED_BYTES) {
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
- const workspaceAccess = consoleWorkspaceAccess(payload);
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
- sendSignal({
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
- sendSignal({
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
- sendSignal({
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(consoleId);
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.localDescription?.();
1370
+ const current = existing.peer?.localDescription?.();
1319
1371
  if (current?.type === 'answer' && typeof current.sdp === 'string') {
1320
- sendSignal({
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 (!existing && peers.size >= MAX_PEERS) {
1329
- sendSignal({
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(consoleId, owner);
1384
- armOwnerAccessDeadline(owner);
1385
- peer.onLocalDescription((sdp, type) => {
1386
- if (!isOwnerActive(owner) || String(type).toLowerCase() !== 'answer' || byteLength(sdp) > SDP_MAX_BYTES) return;
1387
- if (candidateIsRelay(sdp)) {
1388
- rejectedRelayCandidates += 1;
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
- peer.setRemoteDescription(description.sdp, 'offer');
1454
- } catch (error) {
1455
- lastError = error instanceof Error ? error.message : String(error);
1456
- retirePeer(owner, 'console-direct-description-failed');
1457
- return null;
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
- return owner;
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
- if (payload.type === 'rtc-ice') {
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