@livedesk/hub 0.1.75 → 0.1.77

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@livedesk/hub",
3
- "version": "0.1.75",
3
+ "version": "0.1.77",
4
4
  "description": "VuvoDesk local Hub API and browser frame bridge",
5
5
  "type": "module",
6
6
  "main": "src/server.js",
@@ -481,8 +481,9 @@ export function createHubConsoleDirect(options = {}) {
481
481
 
482
482
  const isOwnerActive = owner => !owner.retired
483
483
  && owner.hubEpoch === hubEpoch
484
- && peers.get(owner.consoleId) === owner
484
+ && peers.get(owner.key) === owner
485
485
  && owner.generation > 0
486
+ && (!owner.parent || isOwnerActive(owner.parent))
486
487
  && ownerWorkspaceAccessCurrent(owner);
487
488
 
488
489
  const memberFenceKey = (workspaceId, userId) => `${String(workspaceId || '').trim()}:${String(userId || '').trim()}`;
@@ -520,6 +521,12 @@ export function createHubConsoleDirect(options = {}) {
520
521
  hubEpoch: owner.hubEpoch
521
522
  });
522
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
+
523
530
  const disposeAssemblerTimer = channelState => {
524
531
  if (channelState.assemblyTimer) clearTimeout(channelState.assemblyTimer);
525
532
  channelState.assemblyTimer = null;
@@ -575,15 +582,21 @@ export function createHubConsoleDirect(options = {}) {
575
582
  String(options.reason || 'console-direct-channel-closed').slice(0, 120)
576
583
  );
577
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
+ }
578
588
  };
579
589
 
580
590
  const retirePeer = (owner, reason = 'console-direct-peer-closed', options = {}) => {
581
591
  if (!owner || owner.retired) return;
582
592
  owner.retired = true;
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);
583
596
  owner.iceSetupGate?.close();
584
597
  void owner.routerMapping?.release();
585
598
  retainConsoleIceRetirement(recentPeerConnections, owner, reason);
586
- if (peers.get(owner.consoleId) === owner) peers.delete(owner.consoleId);
599
+ if (peers.get(owner.key) === owner) peers.delete(owner.key);
587
600
  clearTimeout(owner.iceTimer);
588
601
  owner.iceTimer = null;
589
602
  clearTimeout(owner.disconnectTimer);
@@ -609,13 +622,13 @@ export function createHubConsoleDirect(options = {}) {
609
622
  owner.control = null;
610
623
  }
611
624
  const terminalBudget = owner.wireBudget.inspect();
612
- if (terminalBudget.retainedBytes !== 0 || terminalBudget.reservationCount !== 0) {
625
+ if (!owner.parent && (terminalBudget.retainedBytes !== 0 || terminalBudget.reservationCount !== 0)) {
613
626
  wireBudgetCleanupFailures += 1;
614
627
  lastError = 'console-direct-wire-budget-not-zero-after-peer-close';
615
628
  }
616
629
  try { owner.peer.close(); } catch { /* exact peer is already closed */ }
617
630
  if (options.notify !== false) {
618
- 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) });
619
632
  }
620
633
  };
621
634
 
@@ -631,7 +644,7 @@ export function createHubConsoleDirect(options = {}) {
631
644
  const generation = owner.generation;
632
645
  owner.accessTimer = setTimeout(() => {
633
646
  owner.accessTimer = null;
634
- 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;
635
648
  retirePeer(owner, 'console-workspace-access-expired');
636
649
  }, delay);
637
650
  owner.accessTimer.unref?.();
@@ -643,6 +656,7 @@ export function createHubConsoleDirect(options = {}) {
643
656
 
644
657
  const retireNegotiatingPeers = reason => {
645
658
  for (const owner of [...peers.values()]) {
659
+ if (owner.parent) continue; // Child negotiation uses the healthy direct parent, not the Worker.
646
660
  if (!owner.control || !channelIsOpen(owner.control.channel)) {
647
661
  retirePeer(owner, reason, { notify: false });
648
662
  }
@@ -690,6 +704,13 @@ export function createHubConsoleDirect(options = {}) {
690
704
  }
691
705
  };
692
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
+
693
714
  const releaseLogicalBacklogForControl = (owner, requiredBytes) => {
694
715
  const purposePriority = { frame: 0, atlas: 1, audio: 2, input: 3 };
695
716
  const candidates = [...owner.channels.values()]
@@ -743,7 +764,7 @@ export function createHubConsoleDirect(options = {}) {
743
764
  const partialMedia = channelState.purpose === 'frame' || channelState.purpose === 'atlas';
744
765
  if (partialMedia
745
766
  && (wireBytes > MAX_DATA_CHANNEL_BUFFERED_BYTES
746
- || ownerBufferedAmount(owner) + wireBytes > MAX_DATA_CHANNEL_BUFFERED_BYTES)) {
767
+ || familyBufferedAmount(owner) + wireBytes > MAX_DATA_CHANNEL_BUFFERED_BYTES)) {
747
768
  dataChannelBackpressureCloses += 1;
748
769
  if (options.closeOnFailure !== false) {
749
770
  closeLogicalChannel(owner, channelState, {
@@ -776,7 +797,7 @@ export function createHubConsoleDirect(options = {}) {
776
797
  releaseLogicalBacklogForControl(owner, wireBytes);
777
798
  }
778
799
  if (wireBytes > MAX_DATA_CHANNEL_BUFFERED_BYTES
779
- || ownerBufferedAmount(owner) + wireBytes > MAX_DATA_CHANNEL_BUFFERED_BYTES) {
800
+ || familyBufferedAmount(owner) + wireBytes > MAX_DATA_CHANNEL_BUFFERED_BYTES) {
780
801
  dataChannelBackpressureCloses += 1;
781
802
  if (options.closeOnFailure !== false) {
782
803
  if (channelState === owner.control) retirePeer(owner, 'console-direct-control-backpressure');
@@ -1105,7 +1126,12 @@ export function createHubConsoleDirect(options = {}) {
1105
1126
  const handleControlPayload = (owner, text) => {
1106
1127
  const payload = parseJson(text);
1107
1128
  if (!payload?.type) throw new Error('console-direct-control-invalid');
1129
+ if (payload.type === 'media-signal') {
1130
+ handleMediaSignal(owner, payload);
1131
+ return;
1132
+ }
1108
1133
  if (payload.type === 'http-request') {
1134
+ if (owner.parent) throw new Error('console-direct-media-http-forbidden');
1109
1135
  void handleHttpRequest(owner, payload);
1110
1136
  return;
1111
1137
  }
@@ -1218,7 +1244,9 @@ export function createHubConsoleDirect(options = {}) {
1218
1244
  } else {
1219
1245
  const identity = parseLogicalWebSocketLabel(label);
1220
1246
  if (!identity
1221
- || 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)))
1222
1250
  || owner.channels.has(identity?.channelId)) {
1223
1251
  try { channel.close(); } catch {}
1224
1252
  return;
@@ -1276,7 +1304,8 @@ export function createHubConsoleDirect(options = {}) {
1276
1304
  sendControl(owner, {
1277
1305
  type: 'direct-ready',
1278
1306
  connectionId: owner.connectionId,
1279
- hubEpoch: owner.hubEpoch
1307
+ hubEpoch: owner.hubEpoch,
1308
+ ...(!owner.parent ? { mediaPeerVersion: 1 } : {})
1280
1309
  });
1281
1310
  } else {
1282
1311
  consumePendingLogicalControl(owner, channelState);
@@ -1284,12 +1313,16 @@ export function createHubConsoleDirect(options = {}) {
1284
1313
  });
1285
1314
  };
1286
1315
 
1287
- const createPeerOwner = payload => {
1316
+ const createPeerOwner = (payload, parent = null) => {
1288
1317
  const consoleId = String(payload?.consoleId || '');
1289
1318
  const connectionId = String(payload?.connectionId || '');
1290
1319
  const ownerHubEpoch = String(payload?.hubEpoch || '');
1291
1320
  const description = payload?.description;
1292
- 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;
1293
1326
  if (!UUID_PATTERN.test(consoleId)
1294
1327
  || !UUID_PATTERN.test(connectionId)
1295
1328
  || ownerHubEpoch !== hubEpoch
@@ -1298,7 +1331,7 @@ export function createHubConsoleDirect(options = {}) {
1298
1331
  || typeof description.sdp !== 'string'
1299
1332
  || byteLength(description.sdp) > SDP_MAX_BYTES) {
1300
1333
  if (UUID_PATTERN.test(consoleId) && UUID_PATTERN.test(connectionId)) {
1301
- sendSignal({
1334
+ reply({
1302
1335
  type: 'rtc-close',
1303
1336
  consoleId,
1304
1337
  connectionId,
@@ -1309,7 +1342,7 @@ export function createHubConsoleDirect(options = {}) {
1309
1342
  return null;
1310
1343
  }
1311
1344
  if (memberIsFenced(workspaceAccess)) {
1312
- sendSignal({
1345
+ reply({
1313
1346
  type: 'rtc-close',
1314
1347
  consoleId,
1315
1348
  connectionId,
@@ -1320,7 +1353,7 @@ export function createHubConsoleDirect(options = {}) {
1320
1353
  }
1321
1354
  if (candidateIsRelay(description.sdp)) {
1322
1355
  rejectedRelayCandidates += 1;
1323
- sendSignal({
1356
+ reply({
1324
1357
  type: 'rtc-close',
1325
1358
  consoleId,
1326
1359
  connectionId,
@@ -1329,14 +1362,14 @@ export function createHubConsoleDirect(options = {}) {
1329
1362
  });
1330
1363
  return null;
1331
1364
  }
1332
- const existing = peers.get(consoleId);
1365
+ const existing = peers.get(key);
1333
1366
  if (existing
1334
1367
  && existing.connectionId === connectionId
1335
1368
  && existing.hubEpoch === ownerHubEpoch
1336
1369
  && !existing.retired) {
1337
1370
  const current = existing.peer?.localDescription?.();
1338
1371
  if (current?.type === 'answer' && typeof current.sdp === 'string') {
1339
- sendSignal({
1372
+ reply({
1340
1373
  type: 'rtc-answer',
1341
1374
  ...ownerEnvelope(existing),
1342
1375
  description: { type: 'answer', sdp: current.sdp }
@@ -1344,8 +1377,16 @@ export function createHubConsoleDirect(options = {}) {
1344
1377
  }
1345
1378
  return existing;
1346
1379
  }
1347
- if (!existing && peers.size >= MAX_PEERS) {
1348
- 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({
1349
1390
  type: 'rtc-close',
1350
1391
  consoleId,
1351
1392
  connectionId,
@@ -1357,6 +1398,9 @@ export function createHubConsoleDirect(options = {}) {
1357
1398
  if (existing) retirePeer(existing, 'console-direct-peer-replaced');
1358
1399
  let peer;
1359
1400
  const owner = {
1401
+ key,
1402
+ parent,
1403
+ mediaPeers: new Map(),
1360
1404
  consoleId,
1361
1405
  connectionId,
1362
1406
  hubEpoch: ownerHubEpoch,
@@ -1367,7 +1411,7 @@ export function createHubConsoleDirect(options = {}) {
1367
1411
  channels: new Map(),
1368
1412
  pendingLogicalControl: new Map(),
1369
1413
  pendingHttp: new Map(),
1370
- wireBudget: createDirectConsoleWireBudget({ maxBytes: MAX_SHARED_ASSEMBLY_BYTES }),
1414
+ wireBudget: parent?.wireBudget || createDirectConsoleWireBudget({ maxBytes: MAX_SHARED_ASSEMBLY_BYTES }),
1371
1415
  iceTimer: null,
1372
1416
  disconnectTimer: null,
1373
1417
  accessTimer: null,
@@ -1380,11 +1424,12 @@ export function createHubConsoleDirect(options = {}) {
1380
1424
  retired: false,
1381
1425
  createdAt: Date.now()
1382
1426
  };
1383
- peers.set(consoleId, owner);
1427
+ peers.set(key, owner);
1428
+ if (parent) parent.mediaPeers.set(consoleId, owner);
1384
1429
  owner.routerMapping = routerMapping.createOwner(`${consoleId}:${connectionId}:${owner.generation}`,
1385
- { portRange: icePortRange });
1430
+ { portRange: icePortRange, ...(parent ? { queue: true } : {}) });
1386
1431
  if (owner.routerMapping?.handoff) owner.iceSetupGate = createConsoleIceSetupGate();
1387
- armOwnerAccessDeadline(owner);
1432
+ if (!parent) armOwnerAccessDeadline(owner);
1388
1433
  owner.iceTimer = setTimeout(() => {
1389
1434
  owner.iceTimer = null;
1390
1435
  if (isOwnerActive(owner)
@@ -1419,7 +1464,7 @@ export function createHubConsoleDirect(options = {}) {
1419
1464
  retirePeer(owner, 'console-direct-relay-candidate-rejected');
1420
1465
  return;
1421
1466
  }
1422
- const sent = sendSignal({
1467
+ const sent = sendPeerSignal(owner, {
1423
1468
  type: 'rtc-answer',
1424
1469
  ...ownerEnvelope(owner),
1425
1470
  description: { type: 'answer', sdp: String(sdp) }
@@ -1444,7 +1489,7 @@ export function createHubConsoleDirect(options = {}) {
1444
1489
  retirePeer(owner, 'console-direct-relay-candidate-rejected');
1445
1490
  return;
1446
1491
  }
1447
- const sent = sendSignal({
1492
+ const sent = sendPeerSignal(owner, {
1448
1493
  type: 'rtc-ice',
1449
1494
  ...ownerEnvelope(owner),
1450
1495
  candidate: candidateText,
@@ -1507,6 +1552,49 @@ export function createHubConsoleDirect(options = {}) {
1507
1552
  return owner;
1508
1553
  };
1509
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
+ }
1571
+ try {
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;
1592
+ }
1593
+ const child = parent.mediaPeers.get(mediaId);
1594
+ if (!child || child.connectionId !== payload.connectionId || !isOwnerActive(child)) return;
1595
+ handlePeerSignal(child, payload);
1596
+ };
1597
+
1510
1598
  const handleSignalMessage = (raw, ownerSignalGeneration, socket) => {
1511
1599
  if (typeof raw !== 'string' && !Buffer.isBuffer(raw)) return;
1512
1600
  const text = Buffer.isBuffer(raw) ? raw.toString('utf8') : raw;
@@ -1560,6 +1648,7 @@ export function createHubConsoleDirect(options = {}) {
1560
1648
  return;
1561
1649
  }
1562
1650
  owner.workspaceAccess = refreshedAccess;
1651
+ for (const child of owner.mediaPeers.values()) child.workspaceAccess = refreshedAccess;
1563
1652
  armOwnerAccessDeadline(owner);
1564
1653
  return;
1565
1654
  }
@@ -1630,32 +1719,7 @@ export function createHubConsoleDirect(options = {}) {
1630
1719
  || owner.connectionId !== connectionId
1631
1720
  || owner.hubEpoch !== ownerHubEpoch
1632
1721
  || !isOwnerActive(owner)) return;
1633
- if (payload.type === 'rtc-ice') {
1634
- const candidate = String(payload.candidate || '');
1635
- if (byteLength(candidate) > ICE_CANDIDATE_MAX_BYTES) {
1636
- retirePeer(owner, 'console-direct-candidate-invalid');
1637
- return;
1638
- }
1639
- if (candidateIsRelay(candidate)) {
1640
- rejectedRelayCandidates += 1;
1641
- retirePeer(owner, 'console-direct-relay-candidate-rejected');
1642
- return;
1643
- }
1644
- try {
1645
- if (candidate) {
1646
- recordConsoleIceCandidate(owner.iceEvidence.remote, candidate);
1647
- if (!owner.iceSetupGate?.defer(candidate, String(payload.sdpMid || ''))) {
1648
- owner.peer.addRemoteCandidate(candidate, String(payload.sdpMid || ''));
1649
- }
1650
- }
1651
- } catch {
1652
- retirePeer(owner, 'console-direct-candidate-failed');
1653
- }
1654
- return;
1655
- }
1656
- if (payload.type === 'rtc-close') {
1657
- retirePeer(owner, String(payload.reason || 'console-direct-remote-closed'), { notify: false });
1658
- }
1722
+ handlePeerSignal(owner, payload);
1659
1723
  };
1660
1724
 
1661
1725
  const cancelAccessTokenAttempt = () => {
@@ -2033,6 +2097,7 @@ export function createHubConsoleDirect(options = {}) {
2033
2097
  pruneRevokedMembers();
2034
2098
  let retired = 0;
2035
2099
  for (const owner of [...peers.values()]) {
2100
+ if (owner.retired) continue;
2036
2101
  if (owner.workspaceAccess?.workspaceId !== normalizedWorkspaceId
2037
2102
  || owner.workspaceAccess?.userId !== normalizedUserId) continue;
2038
2103
  retirePeer(owner, String(reason || 'workspace-member-revoked').slice(0, 120));
@@ -2067,10 +2132,13 @@ export function createHubConsoleDirect(options = {}) {
2067
2132
  if (channelState.localSocket) localWebSocketChannels += 1;
2068
2133
  if (channelState.assemblyTimer) assemblyDeadlineTimers += 1;
2069
2134
  }
2070
- retainedAssemblyBytes += Number(owner.wireBudget.inspect().retainedBytes || 0);
2135
+ if (!owner.parent) retainedAssemblyBytes += Number(owner.wireBudget.inspect().retainedBytes || 0);
2071
2136
  bufferedSendBytes += ownerBufferedAmount(owner);
2072
2137
  peerDiagnostics.push({
2073
2138
  consoleId: owner.consoleId,
2139
+ transportRole: owner.parent ? 'media' : 'control',
2140
+ parentConsoleId: owner.parent?.consoleId || null,
2141
+ parentConnectionId: owner.parent?.connectionId || null,
2074
2142
  connectionId: owner.connectionId,
2075
2143
  generation: owner.generation,
2076
2144
  peerState: owner.peerState,
@@ -2106,6 +2174,8 @@ export function createHubConsoleDirect(options = {}) {
2106
2174
  connected: state === 'connected',
2107
2175
  hubEpoch,
2108
2176
  peerConnections: peers.size,
2177
+ consoleConnections: [...peers.values()].filter(owner => !owner.parent).length,
2178
+ mediaPeerConnections: [...peers.values()].filter(owner => owner.parent).length,
2109
2179
  controlChannels,
2110
2180
  logicalWebSocketChannels,
2111
2181
  localWebSocketChannels,
@@ -298,6 +298,120 @@ function offer(signal, connectionId = CONNECTION_ONE, sdp = 'v=0\r\na=fake-offer
298
298
  return FakePeerConnection.instances.at(-1);
299
299
  }
300
300
 
301
+ function openPeerControl(peer) {
302
+ const control = new FakeDataChannel('livedesk-control-v1');
303
+ peer.callbacks.dataChannel(control);
304
+ control.open();
305
+ return control;
306
+ }
307
+
308
+ function mediaSignal(control, mediaId, signal, parentConnectionId = CONNECTION_ONE) {
309
+ sendWire(control, { type: 'media-signal', connectionId: parentConnectionId,
310
+ hubEpoch: HUB_EPOCH, mediaId, signal: { hubEpoch: HUB_EPOCH, ...signal } });
311
+ }
312
+
313
+ test('dedicated media peers isolate congestion and retirement from HTTP, siblings and other Consoles', async () => {
314
+ const { direct, signal } = await connectedDirect();
315
+ try {
316
+ const root = offer(signal), control = openPeerControl(root);
317
+ const externalSignals = signal.sent.length;
318
+ mediaSignal(control, CHANNEL_ID, { type: 'rtc-offer', connectionId: CONNECTION_TWO,
319
+ description: { type: 'offer', sdp: 'v=0\r\n' } });
320
+ const first = FakePeerConnection.instances.at(-1), firstControl = openPeerControl(first);
321
+ mediaSignal(control, CHANNEL_ID_TWO, { type: 'rtc-offer', connectionId: REQUEST_ID,
322
+ description: { type: 'offer', sdp: 'v=0\r\n' } });
323
+ const second = FakePeerConnection.instances.at(-1); openPeerControl(second);
324
+ assert.notEqual(first, root);
325
+ assert.notEqual(first, second);
326
+ assert.equal(signal.sent.length, externalSignals, 'media SDP/ICE never goes through the Worker');
327
+ assert.equal(direct.inspect().consoleConnections, 1);
328
+ assert.equal(direct.inspect().mediaPeerConnections, 2);
329
+ const replies = decodeWireMessages(control.sent).map(m => JSON.parse(m.data));
330
+ assert.ok(replies.some(m => m.type === 'media-signal' && m.mediaId === CHANNEL_ID
331
+ && m.signal.type === 'rtc-answer' && m.connectionId === CONNECTION_ONE));
332
+ firstControl.buffered = 900_000;
333
+ sendWire(control, { type: 'http-request', requestId: REQUEST_ID, method: 'GET', path: '/api/remote/status' });
334
+ await settle();
335
+ assert.ok(decodeWireMessages(control.sent).map(m => JSON.parse(m.data)).some(m => m.type === 'http-response'));
336
+ first.emitState('failed');
337
+ assert.equal(first.closed, true);
338
+ assert.equal(root.closed, false);
339
+ assert.equal(second.closed, false);
340
+ assert.equal(direct.inspect().mediaPeerConnections, 1);
341
+ mediaSignal(control, CHANNEL_ID, { type: 'rtc-offer', connectionId: CONNECTION_ONE,
342
+ description: { type: 'offer', sdp: 'v=0\r\n' } });
343
+ const replacement = FakePeerConnection.instances.at(-1); openPeerControl(replacement);
344
+ mediaSignal(control, CHANNEL_ID, { type: 'rtc-close', connectionId: CONNECTION_TWO });
345
+ assert.equal(replacement.closed, false, 'old media close cannot retire a replacement');
346
+ mediaSignal(control, CHANNEL_ID, { type: 'rtc-offer', connectionId: CONNECTION_TWO,
347
+ description: { type: 'offer', sdp: 'v=0\r\n' } });
348
+ assert.equal(replacement.closed, false, 'an older offer cannot replace the active media owner');
349
+ assert.equal(FakePeerConnection.instances.at(-1), replacement);
350
+ root.emitState('closed');
351
+ assert.equal(second.closed, true);
352
+ assert.equal(replacement.closed, true);
353
+ assert.equal(direct.inspect().peerConnections, 0);
354
+ assert.equal(direct.inspect().retainedAssemblyBytes, 0);
355
+ assert.equal(direct.inspect().wireBudgetCleanupFailures, 0);
356
+ } finally { direct.close(); }
357
+ });
358
+
359
+ test('media offers cannot cross parent authority, nest children or consume the four Console slots', async () => {
360
+ const { direct, signal } = await connectedDirect();
361
+ try {
362
+ const root = offer(signal), control = openPeerControl(root);
363
+ const description = { type: 'offer', sdp: 'v=0\r\n' };
364
+ mediaSignal(control, CHANNEL_ID, { type: 'rtc-offer', connectionId: CONNECTION_TWO, description }, CONNECTION_TWO);
365
+ assert.equal(direct.inspect().peerConnections, 1, 'stale parent rejected');
366
+ let childControl;
367
+ for (let n = 0; n < 16; n++) {
368
+ const id = `bbbbbbbb-bbbb-4bbb-8bbb-${String(n).padStart(12, '0')}`;
369
+ mediaSignal(control, id, { type: 'rtc-offer', connectionId: CONNECTION_TWO, description });
370
+ childControl = openPeerControl(FakePeerConnection.instances.at(-1));
371
+ }
372
+ const child = FakePeerConnection.instances.at(-1);
373
+ mediaSignal(childControl, CHANNEL_ID, { type: 'rtc-offer', connectionId: REQUEST_ID, description }, CONNECTION_TWO);
374
+ assert.equal(direct.inspect().mediaPeerConnections, 16, 'child cannot create a grandchild');
375
+ const forbiddenInput = new FakeDataChannel(`livedesk-ws-v1:${CHANNEL_ID}:input`);
376
+ child.callbacks.dataChannel(forbiddenInput);
377
+ assert.equal(forbiddenInput.closed, true, 'media child cannot acquire input');
378
+ mediaSignal(control, CHANNEL_ID, { type: 'rtc-offer', connectionId: CONNECTION_TWO, description });
379
+ assert.equal(direct.inspect().mediaPeerConnections, 16);
380
+ for (let n = 1; n <= 3; n++) {
381
+ offer(signal, CONNECTION_TWO, 'v=0\r\n', { consoleId: `aaaaaaaa-aaaa-4aaa-8aaa-${String(n).padStart(12, '0')}` });
382
+ }
383
+ assert.equal(direct.inspect().consoleConnections, 4);
384
+ assert.equal(direct.inspect().peerConnections, 20);
385
+ offer(signal, CONNECTION_TWO, 'v=0\r\n', { consoleId: CHANNEL_ID });
386
+ assert.equal(direct.inspect().consoleConnections, 4);
387
+ } finally { direct.close(); }
388
+ assert.equal(direct.inspect().peerConnections, 0);
389
+ });
390
+
391
+ test('media assembly shares one Console budget and closing a child releases only its reservations', async () => {
392
+ const { direct, signal } = await connectedDirect();
393
+ try {
394
+ const root = offer(signal), control = openPeerControl(root);
395
+ mediaSignal(control, CHANNEL_ID, { type: 'rtc-offer', connectionId: CONNECTION_TWO,
396
+ description: { type: 'offer', sdp: 'v=0\r\n' } });
397
+ const child = FakePeerConnection.instances.at(-1), childControl = openPeerControl(child);
398
+ const partial = encodeDirectConsoleWireMessage('x'.repeat(40_000), { messageId: 77, kind: 'control' })[0];
399
+ childControl.receive(partial);
400
+ const childBytes = direct.inspect().retainedAssemblyBytes;
401
+ assert.ok(childBytes > 0);
402
+ control.receive(partial);
403
+ assert.equal(direct.inspect().retainedAssemblyBytes, childBytes * 2,
404
+ 'a shared budget is counted once, not once per transport');
405
+ child.emitState('failed');
406
+ assert.equal(direct.inspect().retainedAssemblyBytes, childBytes);
407
+ assert.equal(direct.inspect().wireBudgetCleanupFailures, 0);
408
+ assert.equal(root.closed, false);
409
+ } finally { direct.close(); }
410
+ assert.equal(direct.inspect().retainedAssemblyBytes, 0);
411
+ assert.equal(direct.inspect().resourceTimers, 0);
412
+ assert.equal(direct.inspect().wireBudgetCleanupFailures, 0);
413
+ });
414
+
301
415
  test('native buffered sends complete each media message without closing input or control', async () => {
302
416
  const { direct, signal } = await connectedDirect();
303
417
  try {
@@ -792,7 +906,8 @@ test('reliable control channel bridges bounded HTTP over fragmented wire message
792
906
  assert.deepEqual(JSON.parse(messages[0].data), {
793
907
  type: 'direct-ready',
794
908
  connectionId: CONNECTION_ONE,
795
- hubEpoch: HUB_EPOCH
909
+ hubEpoch: HUB_EPOCH,
910
+ mediaPeerVersion: 1
796
911
  });
797
912
 
798
913
  sendWire(control, {
@@ -2,7 +2,7 @@ import { randomUUID } from 'node:crypto';
2
2
  import { discoverConsoleUpnpGateway, reserveConsoleIceSocket, isPrivateRouterIPv4, isPublicRouterIPv4 } from './console-upnp-gateway.mjs';
3
3
 
4
4
  export const CONSOLE_ROUTER_MAPPING_BOUNDS = Object.freeze({
5
- owners: 4, candidates: 8, setupMs: 8000, cleanupMs: 1800, leaseSeconds: 120, history: 8
5
+ owners: 4, queuedOwners: 64, candidates: 8, setupMs: 8000, cleanupMs: 1800, leaseSeconds: 120, history: 8
6
6
  });
7
7
 
8
8
  export function parseRouterMappingCandidate(value) {
@@ -34,6 +34,7 @@ export function createConsoleRouterMapping(options = {}) {
34
34
  const setupMs = Math.min(bounds.setupMs, Math.max(1, Number(options.setupMs) || bounds.setupMs));
35
35
  const cleanupMs = Math.min(bounds.cleanupMs, Math.max(1, Number(options.cleanupMs) || bounds.cleanupMs));
36
36
  const owners = new Set();
37
+ const waiting = new Set();
37
38
  const reservedPorts = new Map();
38
39
  const history = [];
39
40
  let historyEpoch = 0;
@@ -48,6 +49,19 @@ export function createConsoleRouterMapping(options = {}) {
48
49
  owner.gateway = null;
49
50
  owner.candidates.length = 0;
50
51
  owner.finished = true;
52
+ owner.resolveDone();
53
+ for (const next of waiting) {
54
+ if (owners.size >= bounds.owners) break;
55
+ waiting.delete(next);
56
+ if (!next.controller.signal.aborted && enabled()) start(next);
57
+ else next.release();
58
+ }
59
+ }
60
+
61
+ function start(owner) {
62
+ owner.state = 'discovering';
63
+ owners.add(owner);
64
+ void run(owner);
51
65
  }
52
66
 
53
67
  const own = (entry, owner) => entry && entry.localAddress === owner.gateway.localAddress
@@ -128,25 +142,26 @@ export function createConsoleRouterMapping(options = {}) {
128
142
  }
129
143
  }
130
144
 
131
- function createOwner(key, { portRange } = {}) {
132
- if (!enabled() || owners.size >= bounds.owners || typeof key !== 'string'
145
+ function createOwner(key, { portRange, queue = false } = {}) {
146
+ if (!enabled() || (owners.size >= bounds.owners && (!queue || waiting.size >= bounds.queuedOwners)) || typeof key !== 'string'
133
147
  || !/^[a-f0-9-]{36}:[a-f0-9-]{36}:\d{1,12}$/i.test(key)) return null;
134
148
  const owner = { key, historyEpoch, portRange, socket: null, state: 'discovering', cleanup: 'not-needed', finished: false,
135
149
  controller: new AbortController(), description: `VuvoDesk-ICE-${randomUUID()}`,
136
150
  candidates: [], gateway: null, port: 0, reservation: '', attempted: false,
137
151
  setupTimer: null, cleanupTimer: null, promise: null, startedAt: Date.now() };
138
152
  const ready = new Promise(resolve => { owner.resolveReady = resolve; });
139
- owners.add(owner);
153
+ owner.promise = new Promise(resolve => { owner.resolveDone = resolve; });
140
154
  const release = () => {
141
155
  if (owner.finished) return owner.promise || Promise.resolve();
142
156
  owner.state = 'released';
143
157
  owner.controller.abort();
144
158
  owner.resolveReady();
145
- if (!owner.promise) finish(owner);
159
+ if (!owners.has(owner)) { waiting.delete(owner); finish(owner); }
146
160
  return owner.promise || Promise.resolve();
147
161
  };
148
162
  owner.release = release;
149
- owner.promise = run(owner);
163
+ if (owners.size < bounds.owners) start(owner);
164
+ else { owner.state = 'queued'; waiting.add(owner); }
150
165
  return Object.freeze({
151
166
  ready,
152
167
  async handoff() {
@@ -175,9 +190,13 @@ export function createConsoleRouterMapping(options = {}) {
175
190
  createOwner,
176
191
  releaseAll: ({ forgetHistory = false } = {}) => {
177
192
  if (forgetHistory) { historyEpoch += 1; history.length = 0; }
178
- return Promise.all([...owners].map(owner => owner.release()));
193
+ // Remove waiting owners before releasing active slots: a shutdown must
194
+ // never start a queued router operation while draining the active ones.
195
+ const pending = [...waiting];
196
+ waiting.clear();
197
+ return Promise.all([...pending, ...owners].map(owner => owner.release()));
179
198
  },
180
- inspect: () => ({ enabled: Boolean(enabled()), activeOwners: owners.size,
199
+ inspect: () => ({ enabled: Boolean(enabled()), activeOwners: owners.size, queuedOwners: waiting.size,
181
200
  activeMappings: [...owners].filter(owner => owner.state === 'mapped').length,
182
201
  pendingOperations: [...owners].filter(owner => owner.promise).length,
183
202
  resourceTimers: [...owners].reduce((n, owner) => n + Number(!!owner.setupTimer) + Number(!!owner.cleanupTimer), 0),
@@ -63,6 +63,42 @@ test('exact concurrent owners release only their own actual candidate ports, wit
63
63
  assert.doesNotMatch(JSON.stringify(manager.inspect()), /192\.168|VuvoDesk-ICE-/);
64
64
  });
65
65
 
66
+ test('media setup waits for one of four router slots and cancellation cannot start stale work', async () => {
67
+ const { manager, calls } = fixture();
68
+ const active = Array.from({length:4}, (_,n)=>manager.createOwner(key(n+1)));
69
+ try {
70
+ await Promise.all(active.map(owner=>owner.ready));
71
+ const queued = manager.createOwner(key(5), {queue:true});
72
+ const cancelled = manager.createOwner(key(6), {queue:true});
73
+ assert.equal(manager.inspect().activeOwners,4);
74
+ assert.equal(manager.inspect().queuedOwners,2);
75
+ assert.equal(manager.inspect().resourceSockets,4);
76
+ assert.equal(manager.createOwner(key(7)),null, 'legacy caller still has four immediate slots');
77
+ await cancelled.release();
78
+ await active[0].release();
79
+ await queued.ready;
80
+ assert.equal(manager.inspect().activeOwners,4);
81
+ assert.equal(manager.inspect().queuedOwners,0);
82
+ assert.equal(calls.filter(call=>call[0]==='add').length,5);
83
+ } finally { await manager.releaseAll(); }
84
+ assert.equal(manager.inspect().activeOwners,0);
85
+ assert.equal(manager.inspect().resourceSockets,0);
86
+ assert.equal(manager.inspect().resourceTimers,0);
87
+ });
88
+
89
+ test('queued setup is bounded and closing the family starts no queued router operations', async () => {
90
+ const { manager, calls } = fixture();
91
+ const active=Array.from({length:4},(_,n)=>manager.createOwner(key(n+1)));
92
+ await Promise.all(active.map(owner=>owner.ready));
93
+ for(let n=0;n<64;n++) assert.ok(manager.createOwner(key(n+5),{queue:true}));
94
+ assert.equal(manager.createOwner(key(100),{queue:true}),null);
95
+ await manager.releaseAll();
96
+ assert.equal(calls.filter(call=>call[0]==='add').length,4);
97
+ assert.equal(manager.inspect().queuedOwners,0);
98
+ assert.equal(manager.inspect().activeOwners,0);
99
+ assert.equal(manager.inspect().resourceTimers,0);
100
+ });
101
+
66
102
  test('existing mappings and changed ownership are never overwritten or deleted', async () => {
67
103
  const { entries, calls, manager } = fixture();
68
104
  const foreign = { port: 54443, localAddress: '192.168.0.9', description: 'another-program', leaseSeconds: 0 };
@@ -15,15 +15,15 @@ const clients = [6, 3, 1, 5, 2, 4].map(slotNumber => ({
15
15
  }));
16
16
 
17
17
  test('Hub and browser choose the same stable Free, Plus, Pro, and Team device owners', () => {
18
- for (const limit of [5, 20, 50, Number.POSITIVE_INFINITY]) {
18
+ for (const limit of [3, 20, 50, Number.POSITIVE_INFINITY]) {
19
19
  assert.deepEqual(
20
20
  stableHubPlanAllowedDeviceIds(clients, limit),
21
21
  stableBrowserPlanAllowedDeviceIds(clients, limit)
22
22
  );
23
23
  }
24
24
  assert.deepEqual(
25
- stableHubPlanAllowedDeviceIds(clients, 5),
26
- ['client-1', 'client-2', 'client-3', 'client-4', 'client-5']
25
+ stableHubPlanAllowedDeviceIds(clients, 3),
26
+ ['client-1', 'client-2', 'client-3']
27
27
  );
28
28
  });
29
29
 
@@ -66,10 +66,10 @@ test('finite plans reject the exact excess Client instead of counting request wi
66
66
 
67
67
  test('a Team to Free downgrade changes one bounded snapshot and preserves stable owners', () => {
68
68
  const unlimited = createPlanDeviceAccessSnapshot(clients, Number.POSITIVE_INFINITY, 1);
69
- const downgraded = createPlanDeviceAccessSnapshot(clients, 5, 2);
69
+ const downgraded = createPlanDeviceAccessSnapshot(clients, 3, 2);
70
70
  assert.equal(planDeviceAccessSnapshotChanged(unlimited, downgraded), true);
71
- assert.deepEqual(downgraded.allowedDeviceIds, ['client-1', 'client-2', 'client-3', 'client-4', 'client-5']);
72
- assert.deepEqual(downgraded.blockedDeviceIds, ['client-6']);
71
+ assert.deepEqual(downgraded.allowedDeviceIds, ['client-1', 'client-2', 'client-3']);
72
+ assert.deepEqual(downgraded.blockedDeviceIds, ['client-6', 'client-5', 'client-4']);
73
73
  assert.equal(planDeviceAccessSnapshotChanged(downgraded, {
74
74
  ...downgraded,
75
75
  generation: 3,
@@ -90,12 +90,12 @@ test('the exact Hub manager stays controllable without consuming a Client slot',
90
90
  const snapshot = createPlanDeviceAccessSnapshot([
91
91
  ...clients,
92
92
  { deviceId: 'hub-manager', slotNumber: 0, connected: true }
93
- ], 5, 8, { exemptDeviceIds: ['hub-manager'] });
93
+ ], 3, 8, { exemptDeviceIds: ['hub-manager'] });
94
94
 
95
95
  assert.deepEqual(snapshot.connectedDeviceIds, clients.map(client => client.deviceId));
96
96
  assert.deepEqual(snapshot.exemptConnectedDeviceIds, ['hub-manager']);
97
97
  assert.equal(snapshot.allowedDeviceIdSet.has('hub-manager'), true);
98
- assert.deepEqual(snapshot.blockedDeviceIds, ['client-6']);
98
+ assert.deepEqual(snapshot.blockedDeviceIds, ['client-6', 'client-5', 'client-4']);
99
99
  assert.deepEqual(partitionPlanDeviceIds(['hub-manager', 'client-6'], snapshot), {
100
100
  allowedDeviceIds: ['hub-manager'],
101
101
  blockedDeviceIds: ['client-6']
package/src/remote-hub.js CHANGED
@@ -11621,9 +11621,18 @@ export function createRemoteHub(options = {}) {
11621
11621
  }
11622
11622
 
11623
11623
  function startLiveStream(deviceId, options = {}) {
11624
- const device = devices.get(String(deviceId || ''));
11625
- const denied = policyError(device);
11626
- if (denied) return { ok: false, error: denied };
11624
+ const device = devices.get(String(deviceId || ''));
11625
+ const denied = policyError(device);
11626
+ if (denied) return { ok: false, error: denied };
11627
+ const expectedSessionId = safeString(options.expectedSessionId, 160);
11628
+ if (expectedSessionId && device?.sessionId && expectedSessionId !== device.sessionId) {
11629
+ // A delayed Wall restoration cannot create capture on a replacement
11630
+ // Client session. This is a retired intent, not a successful start.
11631
+ return {
11632
+ ok: true, skipped: true, staleOwner: true,
11633
+ retainedNewerOwner: true, reason: 'wall-restore-session-replaced'
11634
+ };
11635
+ }
11627
11636
  if (device?.synthetic !== true
11628
11637
  && device?.connected
11629
11638
  && !isDeviceConnectionFresh(device)) {
package/src/server.js CHANGED
@@ -177,7 +177,7 @@ const atlasPool = new Mode4AtlasPool({
177
177
  });
178
178
  const inputClients = new Set();
179
179
  const audioClients = new Set();
180
- const FREE_DEVICE_LIMIT = 5;
180
+ const FREE_DEVICE_LIMIT = 3;
181
181
  const PLUS_DEVICE_LIMIT = 20;
182
182
  const PRO_DEVICE_LIMIT = 50;
183
183
  const LICENSE_VERIFY_MAX_AGE_MS = 6 * 60 * 60 * 1000;