@livedesk/hub 0.1.57 → 0.1.58

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.57",
3
+ "version": "0.1.58",
4
4
  "description": "VuvoDesk local Hub API and browser frame bridge",
5
5
  "type": "module",
6
6
  "main": "src/server.js",
@@ -11,12 +11,12 @@
11
11
  "scripts": {
12
12
  "dev": "node src/server.js",
13
13
  "start": "node src/server.js",
14
- "check": "node --check src/server.js && node --check src/remote-hub.js && node --check src/remote-clipboard-contract.mjs && node --check src/console-direct.js",
14
+ "check": "node --check src/server.js && node --check src/remote-hub.js && node --check src/remote-clipboard-contract.mjs && node --check src/console-direct.js",
15
15
  "prepublishOnly": "node ../../scripts/livedesk-release-git-gate.mjs"
16
16
  },
17
17
  "dependencies": {
18
18
  "@ffmpeg-installer/ffmpeg": "^1.1.0",
19
- "@livedesk/runtime-core": "0.1.8",
19
+ "@livedesk/runtime-core": "0.1.8",
20
20
  "@openai/codex-sdk": "0.145.0",
21
21
  "cors": "^2.8.5",
22
22
  "express": "^4.21.2",
@@ -34,6 +34,8 @@ const ICE_CONNECT_TIMEOUT_MS = 10_000;
34
34
  const PEER_DISCONNECTED_TIMEOUT_MS = 5_000;
35
35
  const ACCESS_TOKEN_TIMEOUT_MS = 8_000;
36
36
  const SIGNAL_READY_TIMEOUT_MS = 10_000;
37
+ const SIGNAL_HEARTBEAT_INTERVAL_MS = 15_000;
38
+ const SIGNAL_HEARTBEAT_TIMEOUT_MS = 10_000;
37
39
  const CONTROL_ASSEMBLY_TIMEOUT_MS = 15_000;
38
40
  const MEDIA_ASSEMBLY_TIMEOUT_MS = 500;
39
41
  const LOGICAL_BIND_TIMEOUT_MS = 5_000;
@@ -302,6 +304,20 @@ export function createHubConsoleDirect(options = {}) {
302
304
  1,
303
305
  Number(options.signalReadyTimeoutMs) || SIGNAL_READY_TIMEOUT_MS
304
306
  );
307
+ const signalHeartbeatIntervalMs = Math.max(
308
+ 1,
309
+ Number(options.signalHeartbeatIntervalMs) || SIGNAL_HEARTBEAT_INTERVAL_MS
310
+ );
311
+ const signalHeartbeatTimeoutMs = Math.max(
312
+ 1,
313
+ Number(options.signalHeartbeatTimeoutMs) || SIGNAL_HEARTBEAT_TIMEOUT_MS
314
+ );
315
+ const scheduleSignalHeartbeatTimer = typeof options.scheduleSignalHeartbeatTimer === 'function'
316
+ ? options.scheduleSignalHeartbeatTimer
317
+ : setTimeout;
318
+ const cancelSignalHeartbeatTimer = typeof options.cancelSignalHeartbeatTimer === 'function'
319
+ ? options.cancelSignalHeartbeatTimer
320
+ : clearTimeout;
305
321
  const mediaAssemblyTimeoutMs = Math.max(
306
322
  1,
307
323
  Number(options.mediaAssemblyTimeoutMs) || MEDIA_ASSEMBLY_TIMEOUT_MS
@@ -317,6 +333,7 @@ export function createHubConsoleDirect(options = {}) {
317
333
  let retryTimer = null;
318
334
  let accessTokenAttempt = null;
319
335
  let signalReadyDeadline = null;
336
+ let signalHeartbeat = null;
320
337
  let retryAttempt = 0;
321
338
  let retryNotBeforeAt = 0;
322
339
  let nextRetryAt = '';
@@ -1174,6 +1191,7 @@ export function createHubConsoleDirect(options = {}) {
1174
1191
  retryAttempt = 0;
1175
1192
  retryNotBeforeAt = 0;
1176
1193
  nextRetryAt = '';
1194
+ armSignalHeartbeat(ownerSignalGeneration, socket, connect);
1177
1195
  return;
1178
1196
  }
1179
1197
  const consoleId = String(payload.consoleId || '');
@@ -1284,6 +1302,114 @@ export function createHubConsoleDirect(options = {}) {
1284
1302
  signalReadyDeadline = deadline;
1285
1303
  };
1286
1304
 
1305
+ const clearSignalHeartbeat = (ownerSignalGeneration, socket) => {
1306
+ const heartbeat = signalHeartbeat;
1307
+ if (!heartbeat
1308
+ || (ownerSignalGeneration !== undefined && heartbeat.generation !== ownerSignalGeneration)
1309
+ || (socket !== undefined && heartbeat.socket !== socket)) {
1310
+ return false;
1311
+ }
1312
+ signalHeartbeat = null;
1313
+ cancelSignalHeartbeatTimer(heartbeat.timer);
1314
+ return true;
1315
+ };
1316
+
1317
+ const failSignalHeartbeat = (heartbeat, connect, reason) => {
1318
+ if (signalHeartbeat !== heartbeat
1319
+ || stopped
1320
+ || heartbeat.generation !== signalGeneration
1321
+ || signalSocket !== heartbeat.socket) {
1322
+ return;
1323
+ }
1324
+ clearSignalHeartbeat(heartbeat.generation, heartbeat.socket);
1325
+ signalSocket = null;
1326
+ lastHttpStatus = 0;
1327
+ lastError = reason;
1328
+ retireNegotiatingPeers(reason);
1329
+ try { heartbeat.socket.terminate?.(); } catch {}
1330
+ scheduleReconnect(connect);
1331
+ };
1332
+
1333
+ const scheduleSignalHeartbeatPing = (heartbeat, connect) => {
1334
+ if (signalHeartbeat !== heartbeat
1335
+ || stopped
1336
+ || heartbeat.generation !== signalGeneration
1337
+ || signalSocket !== heartbeat.socket) {
1338
+ return;
1339
+ }
1340
+ heartbeat.awaitingPong = false;
1341
+ let pingTimer = null;
1342
+ pingTimer = scheduleSignalHeartbeatTimer(() => {
1343
+ if (signalHeartbeat !== heartbeat
1344
+ || heartbeat.timer !== pingTimer
1345
+ || stopped
1346
+ || heartbeat.generation !== signalGeneration
1347
+ || signalSocket !== heartbeat.socket) {
1348
+ return;
1349
+ }
1350
+ heartbeat.awaitingPong = true;
1351
+ let timeoutTimer = null;
1352
+ timeoutTimer = scheduleSignalHeartbeatTimer(() => {
1353
+ if (signalHeartbeat !== heartbeat
1354
+ || heartbeat.timer !== timeoutTimer
1355
+ || !heartbeat.awaitingPong
1356
+ || stopped
1357
+ || heartbeat.generation !== signalGeneration
1358
+ || signalSocket !== heartbeat.socket) {
1359
+ return;
1360
+ }
1361
+ failSignalHeartbeat(
1362
+ heartbeat,
1363
+ connect,
1364
+ 'console-direct-signal-heartbeat-timeout'
1365
+ );
1366
+ }, signalHeartbeatTimeoutMs);
1367
+ heartbeat.timer = timeoutTimer;
1368
+ heartbeat.timer.unref?.();
1369
+ try {
1370
+ if (heartbeat.socket.readyState !== WebSocket.OPEN
1371
+ || typeof heartbeat.socket.ping !== 'function') {
1372
+ throw new Error('console-direct-signal-heartbeat-unavailable');
1373
+ }
1374
+ heartbeat.socket.ping();
1375
+ } catch {
1376
+ failSignalHeartbeat(
1377
+ heartbeat,
1378
+ connect,
1379
+ 'console-direct-signal-heartbeat-failed'
1380
+ );
1381
+ }
1382
+ }, signalHeartbeatIntervalMs);
1383
+ heartbeat.timer = pingTimer;
1384
+ heartbeat.timer.unref?.();
1385
+ };
1386
+
1387
+ const armSignalHeartbeat = (ownerSignalGeneration, socket, connect) => {
1388
+ clearSignalHeartbeat();
1389
+ const heartbeat = {
1390
+ generation: ownerSignalGeneration,
1391
+ socket,
1392
+ timer: null,
1393
+ awaitingPong: false
1394
+ };
1395
+ signalHeartbeat = heartbeat;
1396
+ scheduleSignalHeartbeatPing(heartbeat, connect);
1397
+ };
1398
+
1399
+ const acceptSignalHeartbeatPong = (ownerSignalGeneration, socket, connect) => {
1400
+ const heartbeat = signalHeartbeat;
1401
+ if (!heartbeat
1402
+ || heartbeat.generation !== ownerSignalGeneration
1403
+ || heartbeat.socket !== socket
1404
+ || !heartbeat.awaitingPong) {
1405
+ return false;
1406
+ }
1407
+ cancelSignalHeartbeatTimer(heartbeat.timer);
1408
+ heartbeat.timer = null;
1409
+ scheduleSignalHeartbeatPing(heartbeat, connect);
1410
+ return true;
1411
+ };
1412
+
1287
1413
  const scheduleReconnect = connect => {
1288
1414
  if (stopped || retryTimer || !(signalUrl instanceof URL)) return;
1289
1415
  const sequenceDelay = retryDelaysMs[Math.min(retryAttempt, retryDelaysMs.length - 1)];
@@ -1374,12 +1500,16 @@ export function createHubConsoleDirect(options = {}) {
1374
1500
  handleSignalMessage(raw, ownerSignalGeneration, socket);
1375
1501
  }
1376
1502
  });
1503
+ socket.on('pong', () => {
1504
+ acceptSignalHeartbeatPong(ownerSignalGeneration, socket, connect);
1505
+ });
1377
1506
  socket.once('close', () => {
1378
1507
  const wasOwner = signalSocket === socket
1379
1508
  && ownerSignalGeneration === signalGeneration
1380
1509
  && !stopped;
1381
1510
  if (signalSocket === socket) signalSocket = null;
1382
1511
  clearSignalReadyDeadline(ownerSignalGeneration, socket);
1512
+ clearSignalHeartbeat(ownerSignalGeneration, socket);
1383
1513
  if (!wasOwner) return;
1384
1514
  retireNegotiatingPeers('console-direct-signaling-closed');
1385
1515
  if (!retryTimer) state = 'disconnected';
@@ -1393,6 +1523,7 @@ export function createHubConsoleDirect(options = {}) {
1393
1523
  }
1394
1524
  signalSocket = null;
1395
1525
  clearSignalReadyDeadline(ownerSignalGeneration, socket);
1526
+ clearSignalHeartbeat(ownerSignalGeneration, socket);
1396
1527
  retireNegotiatingPeers('console-direct-signaling-error');
1397
1528
  try { socket.terminate?.(); } catch {}
1398
1529
  scheduleReconnect(connect);
@@ -1413,6 +1544,7 @@ export function createHubConsoleDirect(options = {}) {
1413
1544
  signalGeneration += 1;
1414
1545
  cancelAccessTokenAttempt();
1415
1546
  clearSignalReadyDeadline();
1547
+ clearSignalHeartbeat();
1416
1548
  if (retryTimer) clearTimeout(retryTimer);
1417
1549
  retryTimer = null;
1418
1550
  retryAttempt = 0;
@@ -1431,6 +1563,7 @@ export function createHubConsoleDirect(options = {}) {
1431
1563
  signalGeneration += 1;
1432
1564
  cancelAccessTokenAttempt();
1433
1565
  clearSignalReadyDeadline();
1566
+ clearSignalHeartbeat();
1434
1567
  if (retryTimer) clearTimeout(retryTimer);
1435
1568
  retryTimer = null;
1436
1569
  nextRetryAt = '';
@@ -1472,6 +1605,7 @@ export function createHubConsoleDirect(options = {}) {
1472
1605
  const reconnectTimerActive = Boolean(retryTimer);
1473
1606
  const accessTokenDeadlineActive = Boolean(accessTokenAttempt);
1474
1607
  const signalReadyDeadlineActive = Boolean(signalReadyDeadline);
1608
+ const signalHeartbeatTimerActive = Boolean(signalHeartbeat?.timer);
1475
1609
  return Object.freeze({
1476
1610
  enabled: signalUrl instanceof URL && Boolean(deviceId),
1477
1611
  state,
@@ -1491,6 +1625,8 @@ export function createHubConsoleDirect(options = {}) {
1491
1625
  reconnectTimerActive,
1492
1626
  accessTokenDeadlineActive,
1493
1627
  signalReadyDeadlineActive,
1628
+ signalHeartbeatTimerActive,
1629
+ signalHeartbeatAwaitingPong: signalHeartbeat?.awaitingPong === true,
1494
1630
  resourceTimers: iceConnectTimers
1495
1631
  + peerDisconnectTimers
1496
1632
  + assemblyDeadlineTimers
@@ -1498,7 +1634,8 @@ export function createHubConsoleDirect(options = {}) {
1498
1634
  + pendingLogicalControlRequests
1499
1635
  + Number(reconnectTimerActive)
1500
1636
  + Number(accessTokenDeadlineActive)
1501
- + Number(signalReadyDeadlineActive),
1637
+ + Number(signalReadyDeadlineActive)
1638
+ + Number(signalHeartbeatTimerActive),
1502
1639
  rejectedRelayCandidates,
1503
1640
  dataChannelBackpressureCloses,
1504
1641
  malformedWireCloses,
@@ -1530,6 +1667,8 @@ export const consoleDirectContract = Object.freeze({
1530
1667
  maxDefaultRetryDelayMs: DEFAULT_RETRY_DELAYS_MS.at(-1),
1531
1668
  accessTokenTimeoutMs: ACCESS_TOKEN_TIMEOUT_MS,
1532
1669
  signalReadyTimeoutMs: SIGNAL_READY_TIMEOUT_MS,
1670
+ signalHeartbeatIntervalMs: SIGNAL_HEARTBEAT_INTERVAL_MS,
1671
+ signalHeartbeatTimeoutMs: SIGNAL_HEARTBEAT_TIMEOUT_MS,
1533
1672
  logicalBindTimeoutMs: LOGICAL_BIND_TIMEOUT_MS,
1534
1673
  iceConnectTimeoutMs: ICE_CONNECT_TIMEOUT_MS,
1535
1674
  peerDisconnectedTimeoutMs: PEER_DISCONNECTED_TIMEOUT_MS,
@@ -13,6 +13,8 @@ assert.equal(consoleDirectContract.maxDefaultRetryDelayMs, 20_000);
13
13
  assert.equal(consoleDirectContract.peerDisconnectedTimeoutMs, 5_000);
14
14
  assert.equal(consoleDirectContract.maxPendingMediaWireMessages, 8);
15
15
  assert.equal(consoleDirectContract.reliableAssemblyTimeoutMs, 15_000);
16
+ assert.equal(consoleDirectContract.signalHeartbeatIntervalMs, 15_000);
17
+ assert.equal(consoleDirectContract.signalHeartbeatTimeoutMs, 10_000);
16
18
 
17
19
  const HUB_EPOCH = '11111111-1111-4111-8111-111111111111';
18
20
  const HUB_EPOCH_TWO = '77777777-7777-4777-8777-777777777777';
@@ -38,6 +40,7 @@ class FakeSocket extends EventEmitter {
38
40
  this.sent = [];
39
41
  this.closeCode = 0;
40
42
  this.closeReason = '';
43
+ this.pingCount = 0;
41
44
  this.constructor.instances.push(this);
42
45
  }
43
46
 
@@ -55,6 +58,15 @@ class FakeSocket extends EventEmitter {
55
58
  this.emit('message', value, binary);
56
59
  }
57
60
 
61
+ ping() {
62
+ if (this.readyState !== FakeSocket.OPEN) throw new Error('fake-socket-not-open');
63
+ this.pingCount += 1;
64
+ }
65
+
66
+ pong() {
67
+ this.emit('pong');
68
+ }
69
+
58
70
  close(code = 1000, reason = '') {
59
71
  if (this.readyState === FakeSocket.CLOSED) return;
60
72
  this.closeCode = code;
@@ -222,6 +234,18 @@ async function connectedDirect(options = {}) {
222
234
  ? { peerDisconnectedTimeoutMs: options.peerDisconnectedTimeoutMs }
223
235
  : {}),
224
236
  ...(options.mediaAssemblyTimeoutMs ? { mediaAssemblyTimeoutMs: options.mediaAssemblyTimeoutMs } : {}),
237
+ ...(options.signalHeartbeatIntervalMs
238
+ ? { signalHeartbeatIntervalMs: options.signalHeartbeatIntervalMs }
239
+ : {}),
240
+ ...(options.signalHeartbeatTimeoutMs
241
+ ? { signalHeartbeatTimeoutMs: options.signalHeartbeatTimeoutMs }
242
+ : {}),
243
+ ...(options.scheduleSignalHeartbeatTimer
244
+ ? { scheduleSignalHeartbeatTimer: options.scheduleSignalHeartbeatTimer }
245
+ : {}),
246
+ ...(options.cancelSignalHeartbeatTimer
247
+ ? { cancelSignalHeartbeatTimer: options.cancelSignalHeartbeatTimer }
248
+ : {}),
225
249
  fetchImpl: options.fetchImpl || (async () => new Response('{}', {
226
250
  status: 200,
227
251
  headers: { 'Content-Type': 'application/json' }
@@ -319,6 +343,89 @@ test('a signaling reconnect preserves a healthy direct peer until the Hub epoch
319
343
  direct.close();
320
344
  });
321
345
 
346
+ test('a silent half-open signaling socket is replaced while a healthy direct peer stays alive', async () => {
347
+ const { direct, signal } = await connectedDirect({
348
+ retryDelaysMs: [0],
349
+ signalHeartbeatIntervalMs: 5,
350
+ signalHeartbeatTimeoutMs: 20
351
+ });
352
+ const peer = offer(signal);
353
+ const control = new FakeDataChannel('livedesk-control-v1');
354
+ peer.emitDataChannel(control);
355
+ control.open();
356
+
357
+ assert.equal(direct.inspect().signalHeartbeatTimerActive, true);
358
+ await waitFor(() => signal.pingCount === 1);
359
+ assert.equal(direct.inspect().signalHeartbeatAwaitingPong, true);
360
+ await waitFor(() => FakeSignalSocket.instances.length === 2);
361
+
362
+ const replacementSignal = FakeSignalSocket.instances[1];
363
+ assert.equal(signal.readyState, FakeSignalSocket.CLOSED);
364
+ assert.equal(peer.closed, false);
365
+ assert.equal(direct.inspect().peerConnections, 1);
366
+ assert.equal(direct.inspect().lastError, 'console-direct-signal-heartbeat-timeout');
367
+ replacementSignal.open();
368
+ replacementSignal.receive(JSON.stringify({
369
+ type: 'signal-ready',
370
+ role: 'hub',
371
+ deviceId: 'hub-device',
372
+ hubEpoch: HUB_EPOCH
373
+ }), false);
374
+ assert.equal(peer.closed, false);
375
+ assert.equal(direct.inspect().signalHeartbeatTimerActive, true);
376
+
377
+ await waitFor(() => replacementSignal.pingCount === 1);
378
+ replacementSignal.pong();
379
+ assert.equal(direct.inspect().signalHeartbeatAwaitingPong, false);
380
+ assert.equal(direct.inspect().signalHeartbeatTimerActive, true);
381
+ direct.close();
382
+ assert.equal(direct.inspect().signalHeartbeatTimerActive, false);
383
+ assert.equal(direct.inspect().resourceTimers, 0);
384
+ });
385
+
386
+ test('a cancelled heartbeat timeout cannot retire a healthy signal after pong re-arms it', async () => {
387
+ let nextTimerId = 0;
388
+ const callbacks = new Map();
389
+ const cancelledTimers = new Set();
390
+ const { direct, signal } = await connectedDirect({
391
+ scheduleSignalHeartbeatTimer(callback) {
392
+ const timerId = ++nextTimerId;
393
+ callbacks.set(timerId, callback);
394
+ return timerId;
395
+ },
396
+ cancelSignalHeartbeatTimer(timerId) {
397
+ cancelledTimers.add(timerId);
398
+ }
399
+ });
400
+ const peer = offer(signal);
401
+ const control = new FakeDataChannel('livedesk-control-v1');
402
+ peer.emitDataChannel(control);
403
+ control.open();
404
+
405
+ assert.equal(nextTimerId, 1);
406
+ callbacks.get(1)();
407
+ assert.equal(signal.pingCount, 1);
408
+ assert.equal(direct.inspect().signalHeartbeatAwaitingPong, true);
409
+ assert.equal(nextTimerId, 2);
410
+
411
+ signal.pong();
412
+ assert.equal(cancelledTimers.has(2), true);
413
+ assert.equal(nextTimerId, 3);
414
+ assert.equal(direct.inspect().signalHeartbeatAwaitingPong, false);
415
+
416
+ callbacks.get(2)();
417
+ assert.equal(signal.readyState, FakeSignalSocket.OPEN);
418
+ assert.equal(FakeSignalSocket.instances.length, 1);
419
+ assert.equal(peer.closed, false);
420
+ assert.equal(direct.inspect().peerConnections, 1);
421
+ assert.equal(direct.inspect().signalHeartbeatTimerActive, true);
422
+ assert.equal(direct.inspect().signalHeartbeatAwaitingPong, false);
423
+
424
+ direct.close();
425
+ assert.equal(cancelledTimers.has(3), true);
426
+ assert.equal(direct.inspect().resourceTimers, 0);
427
+ });
428
+
322
429
  test('a same-account token refresh reconnects signaling without dropping a healthy direct peer', async () => {
323
430
  const { direct, signal } = await connectedDirect();
324
431
  const peer = offer(signal);
@@ -812,8 +919,10 @@ test('a transient disconnected state recovers or retires the exact peer on one b
812
919
  assert.equal(peer.closed, true);
813
920
  assert.equal(direct.inspect().peerConnections, 0);
814
921
  assert.equal(direct.inspect().peerDisconnectTimers, 0);
815
- assert.equal(direct.inspect().resourceTimers, 0);
922
+ assert.equal(direct.inspect().signalHeartbeatTimerActive, true);
923
+ assert.equal(direct.inspect().resourceTimers, 1);
816
924
  direct.close();
925
+ assert.equal(direct.inspect().resourceTimers, 0);
817
926
  });
818
927
 
819
928
  test('an oversized local frame closes only its logical media lane', async () => {
@@ -851,8 +960,11 @@ test('an unopened control channel cannot suppress the bounded ICE owner timeout'
851
960
  await new Promise(resolve => setTimeout(resolve, 15));
852
961
  assert.equal(peer.closed, true);
853
962
  assert.equal(direct.inspect().peerConnections, 0);
854
- assert.equal(direct.inspect().resourceTimers, 0);
963
+ assert.equal(direct.inspect().iceConnectTimers, 0);
964
+ assert.equal(direct.inspect().signalHeartbeatTimerActive, true);
965
+ assert.equal(direct.inspect().resourceTimers, 1);
855
966
  direct.close();
967
+ assert.equal(direct.inspect().resourceTimers, 0);
856
968
  });
857
969
 
858
970
  test('an incomplete partial-reliable media message owns one deadline and close returns terminal zero', async () => {