@livedesk/hub 0.1.51 → 0.1.53

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.51",
3
+ "version": "0.1.53",
4
4
  "description": "VuvoDesk local Hub API and browser frame bridge",
5
5
  "type": "module",
6
6
  "main": "src/server.js",
package/src/remote-hub.js CHANGED
@@ -320,11 +320,14 @@ function serializeRemoteInputWrite(socket, payload) {
320
320
  * - Ordered transitions: keyboard and pointer-button events are never
321
321
  * coalesced. If their bounded queue cannot retain order, the input socket is
322
322
  * closed so the Agent releases native pressed state.
323
- * - High-rate input: only adjacent pointermove/mousemove/wheel samples from the
324
- * same binding may replace an older queued sample.
325
- * - Bounds: one drain listener, one drain deadline, at most 16 userland items,
326
- * 32 KiB queued bytes, and 64 KiB socket writableLength.
327
- */
323
+ * - High-rate input: only adjacent pointermove/mousemove/wheel samples from the
324
+ * same binding may replace an older queued sample.
325
+ * - Acknowledgement health: an acknowledged sample becomes a deadline
326
+ * checkpoint only after that exact message is accepted by the socket write.
327
+ * A sample replaced or dropped in the userland queue owns no timer.
328
+ * - Bounds: one drain listener, one drain deadline, at most 16 userland items,
329
+ * 32 KiB queued bytes, and 64 KiB socket writableLength.
330
+ */
328
331
  export function createBoundedRemoteInputWriter(options = {}) {
329
332
  const socket = options.socket;
330
333
  const connectionId = safeString(options.connectionId, 128);
@@ -357,9 +360,12 @@ export function createBoundedRemoteInputWriter(options = {}) {
357
360
  const isOwnerCurrent = typeof options.isOwnerCurrent === 'function'
358
361
  ? options.isOwnerCurrent
359
362
  : () => true;
360
- const onFailClosed = typeof options.onFailClosed === 'function'
361
- ? options.onFailClosed
362
- : () => {};
363
+ const onFailClosed = typeof options.onFailClosed === 'function'
364
+ ? options.onFailClosed
365
+ : () => {};
366
+ const onAcknowledgedWrite = typeof options.onAcknowledgedWrite === 'function'
367
+ ? options.onAcknowledgedWrite
368
+ : () => {};
363
369
 
364
370
  let closed = false;
365
371
  let closeReason = '';
@@ -501,16 +507,23 @@ export function createBoundedRemoteInputWriter(options = {}) {
501
507
  if (writableLength() > maxWritableBytes) {
502
508
  return failClosed('input-writable-length-exceeded');
503
509
  }
504
- if (writable === false && !armDrainOwner()) {
505
- return {
506
- ok: false,
507
- error: closeReason || 'INPUT_CHANNEL_RECONNECTING',
508
- failClosed: true
509
- };
510
- }
511
- return {
512
- ok: true,
513
- queued: false,
510
+ if (writable === false && !armDrainOwner()) {
511
+ return {
512
+ ok: false,
513
+ error: closeReason || 'INPUT_CHANNEL_RECONNECTING',
514
+ failClosed: true
515
+ };
516
+ }
517
+ if (item.ackRequested) {
518
+ try {
519
+ onAcknowledgedWrite(item.payload, item.bindingKey);
520
+ } catch {
521
+ return failClosed('input-ack-write-observer-failed');
522
+ }
523
+ }
524
+ return {
525
+ ok: true,
526
+ queued: false,
514
527
  backpressured: writable === false
515
528
  };
516
529
  }
@@ -613,12 +626,14 @@ export function createBoundedRemoteInputWriter(options = {}) {
613
626
  return failClosed('input-transition-message-too-large');
614
627
  }
615
628
 
616
- const item = {
617
- payload,
618
- bindingKey: normalizedBindingKey,
619
- serialized,
620
- coalescible
621
- };
629
+ const item = {
630
+ payload,
631
+ bindingKey: normalizedBindingKey,
632
+ serialized,
633
+ coalescible,
634
+ ackRequested: payload?.payload?.requestAck === true
635
+ || payload?.requestAck === true
636
+ };
622
637
  if (blocked || queue.length > 0 || writableLength() >= maxWritableBytes) {
623
638
  const queued = queueItem(item);
624
639
  if (queued.ok && !blocked && !armDrainOwner()) {
@@ -4288,13 +4303,14 @@ export function createRemoteHub(options = {}) {
4288
4303
  }
4289
4304
  }
4290
4305
 
4291
- function closeInputSocket(device, reason = 'input-socket-closed') {
4306
+ function closeInputSocket(device, reason = 'input-socket-closed') {
4292
4307
  const socket = device?.inputSocket;
4293
4308
  if (!socket) {
4294
4309
  return;
4295
4310
  }
4296
4311
 
4297
- const writeOwner = device.inputWriteOwner;
4312
+ const writeOwner = device.inputWriteOwner;
4313
+ clearPendingDedicatedInputAcks(device);
4298
4314
  clearClipboardOperationsForDevice(device, reason);
4299
4315
  device.inputSocket = null;
4300
4316
  device.inputWriteOwner = null;
@@ -4317,7 +4333,7 @@ export function createRemoteHub(options = {}) {
4317
4333
  }
4318
4334
  }
4319
4335
 
4320
- function detachInputSocket(socket, reason = 'input-socket-closed') {
4336
+ function detachInputSocket(socket, reason = 'input-socket-closed') {
4321
4337
  const deviceId = inputSockets.get(socket);
4322
4338
  inputSockets.delete(socket);
4323
4339
  if (!deviceId) {
@@ -4329,7 +4345,8 @@ export function createRemoteHub(options = {}) {
4329
4345
  return;
4330
4346
  }
4331
4347
 
4332
- const writeOwner = device.inputWriteOwner;
4348
+ const writeOwner = device.inputWriteOwner;
4349
+ clearPendingDedicatedInputAcks(device);
4333
4350
  clearClipboardOperationsForDevice(device, reason);
4334
4351
  device.inputSocket = null;
4335
4352
  device.inputWriteOwner = null;
@@ -4948,7 +4965,8 @@ export function createRemoteHub(options = {}) {
4948
4965
  return null;
4949
4966
  }
4950
4967
 
4951
- closeInputSocket(device, 'replaced-by-new-input-socket');
4968
+ closeInputSocket(device, 'replaced-by-new-input-socket');
4969
+ clearPendingDedicatedInputAcks(device);
4952
4970
 
4953
4971
  const now = new Date().toISOString();
4954
4972
  const inputSocketConnectionId = crypto.randomUUID();
@@ -4979,22 +4997,38 @@ export function createRemoteHub(options = {}) {
4979
4997
  && device.inputSocket === socket
4980
4998
  && device.inputSocketConnectionId === inputSocketConnectionId
4981
4999
  && device.inputWriteOwner === writeOwner,
4982
- onFailClosed: reason => {
4983
- if (device.inputWriteOwner !== writeOwner
4984
- || device.inputSocket !== socket
4985
- || device.inputSocketConnectionId !== inputSocketConnectionId) {
4986
- return;
4987
- }
4988
- device.inputWriteOwner = null;
4989
- device.inputSocket = null;
4990
- device.inputSocketConnectionId = '';
5000
+ onFailClosed: reason => {
5001
+ if (device.inputWriteOwner !== writeOwner
5002
+ || device.inputSocket !== socket
5003
+ || device.inputSocketConnectionId !== inputSocketConnectionId) {
5004
+ return;
5005
+ }
5006
+ clearPendingDedicatedInputAcks(device);
5007
+ device.inputWriteOwner = null;
5008
+ device.inputSocket = null;
5009
+ device.inputSocketConnectionId = '';
4991
5010
  device.inputOwnerConnectionId = '';
4992
5011
  device.inputOwnerBindingKey = '';
4993
5012
  inputSockets.delete(socket);
4994
- device.inputLastSeenAt = new Date().toISOString();
4995
- emitRemoteEvent('RemoteInputSocketDisconnected', device, { reason });
4996
- }
4997
- });
5013
+ device.inputLastSeenAt = new Date().toISOString();
5014
+ emitRemoteEvent('RemoteInputSocketDisconnected', device, { reason });
5015
+ },
5016
+ onAcknowledgedWrite: (message, bindingKey) => {
5017
+ if (device.inputWriteOwner !== writeOwner
5018
+ || device.inputSocket !== socket
5019
+ || device.inputSocketConnectionId !== inputSocketConnectionId) {
5020
+ return;
5021
+ }
5022
+ const pending = message?.payload && typeof message.payload === 'object'
5023
+ ? message.payload
5024
+ : message;
5025
+ schedulePendingDedicatedInputAck(device, {
5026
+ ...pending,
5027
+ inputSocketConnectionId,
5028
+ bindingKey
5029
+ });
5030
+ }
5031
+ });
4998
5032
  device.inputWriteOwner = writeOwner;
4999
5033
 
5000
5034
  emitRemoteEvent('RemoteInputSocketConnected', device);
@@ -5392,7 +5426,7 @@ export function createRemoteHub(options = {}) {
5392
5426
  return failed;
5393
5427
  }
5394
5428
 
5395
- function ensurePendingInputFallbacks(device) {
5429
+ function ensurePendingInputFallbacks(device) {
5396
5430
  if (!(device?.pendingInputFallbacks instanceof Map)) {
5397
5431
  Object.defineProperty(device, 'pendingInputFallbacks', {
5398
5432
  value: new Map(),
@@ -5409,8 +5443,151 @@ export function createRemoteHub(options = {}) {
5409
5443
  writable: true
5410
5444
  });
5411
5445
  }
5412
- return device.pendingInputFallbacks;
5413
- }
5446
+ return device.pendingInputFallbacks;
5447
+ }
5448
+
5449
+ function ensurePendingDedicatedInputAckState(device) {
5450
+ if (!device?.pendingDedicatedInputAckState) {
5451
+ Object.defineProperty(device, 'pendingDedicatedInputAckState', {
5452
+ value: { current: null, next: null, timeoutTimer: null },
5453
+ enumerable: false,
5454
+ configurable: true,
5455
+ writable: true
5456
+ });
5457
+ }
5458
+ return device.pendingDedicatedInputAckState;
5459
+ }
5460
+
5461
+ function clearPendingDedicatedInputAcks(device) {
5462
+ if (!device?.pendingDedicatedInputAckState) {
5463
+ return;
5464
+ }
5465
+ const state = device.pendingDedicatedInputAckState;
5466
+ if (state.timeoutTimer) {
5467
+ clearTimeout(state.timeoutTimer);
5468
+ }
5469
+ state.timeoutTimer = null;
5470
+ state.current = null;
5471
+ state.next = null;
5472
+ }
5473
+
5474
+ function dedicatedInputAckMatches(pending, message = {}) {
5475
+ if (!pending) {
5476
+ return false;
5477
+ }
5478
+ const result = message?.result && typeof message.result === 'object'
5479
+ ? message.result
5480
+ : {};
5481
+ const inputEventId = safeString(
5482
+ message.inputEventId
5483
+ || message.InputEventId
5484
+ || result.inputEventId
5485
+ || result.InputEventId,
5486
+ 128);
5487
+ const hubConnectionId = safeString(
5488
+ message.hubConnectionId
5489
+ || message.HubConnectionId
5490
+ || result.hubConnectionId
5491
+ || result.HubConnectionId,
5492
+ 128);
5493
+ const inputSeq = Number(
5494
+ message.inputSeq
5495
+ ?? message.InputSeq
5496
+ ?? result.inputSeq
5497
+ ?? result.InputSeq
5498
+ ?? 0) || 0;
5499
+ if (pending.inputEventId && inputEventId) {
5500
+ return pending.inputEventId === inputEventId
5501
+ && pending.hubConnectionId === hubConnectionId;
5502
+ }
5503
+ return pending.inputSeq > 0
5504
+ && pending.inputSeq === inputSeq
5505
+ && pending.hubConnectionId === hubConnectionId;
5506
+ }
5507
+
5508
+ function armPendingDedicatedInputAck(device) {
5509
+ const state = ensurePendingDedicatedInputAckState(device);
5510
+ if (state.timeoutTimer) {
5511
+ clearTimeout(state.timeoutTimer);
5512
+ state.timeoutTimer = null;
5513
+ }
5514
+ const pending = state.current;
5515
+ if (!pending) {
5516
+ return;
5517
+ }
5518
+ const remainingMs = Math.max(0, pending.deadlineAtEpochMs - Date.now());
5519
+ state.timeoutTimer = setTimeout(() => {
5520
+ state.timeoutTimer = null;
5521
+ if (state.current !== pending) {
5522
+ return;
5523
+ }
5524
+ state.current = null;
5525
+ state.next = null;
5526
+ if (device.inputSocketConnectionId !== pending.inputSocketConnectionId) {
5527
+ return;
5528
+ }
5529
+ emitRemoteInputError(
5530
+ device,
5531
+ { error: 'remote-input-timeout' },
5532
+ pending,
5533
+ 'remote-input-timeout');
5534
+ closeInputSocket(device, 'input-ack-timeout');
5535
+ }, remainingMs);
5536
+ state.timeoutTimer.unref?.();
5537
+ }
5538
+
5539
+ function schedulePendingDedicatedInputAck(device, pending) {
5540
+ if (pending?.requestAck !== true
5541
+ || !Number.isSafeInteger(pending.inputSeq)
5542
+ || pending.inputSeq <= 0) {
5543
+ return;
5544
+ }
5545
+ const state = ensurePendingDedicatedInputAckState(device);
5546
+ const record = {
5547
+ ...pending,
5548
+ deadlineAtEpochMs: Date.now() + inputAckTimeoutMs
5549
+ };
5550
+ if (!state.current) {
5551
+ state.current = record;
5552
+ state.next = null;
5553
+ armPendingDedicatedInputAck(device);
5554
+ return;
5555
+ }
5556
+ if (state.current.inputSocketConnectionId !== record.inputSocketConnectionId
5557
+ || state.current.bindingKey !== record.bindingKey) {
5558
+ clearPendingDedicatedInputAcks(device);
5559
+ state.current = record;
5560
+ armPendingDedicatedInputAck(device);
5561
+ return;
5562
+ }
5563
+ // Keep only one later checkpoint. If the current acknowledgement
5564
+ // arrives, this latest sampled input becomes the next exact deadline.
5565
+ state.next = record;
5566
+ }
5567
+
5568
+ function acknowledgePendingDedicatedInput(device, message = {}) {
5569
+ const state = device?.pendingDedicatedInputAckState;
5570
+ if (!state?.current) {
5571
+ return false;
5572
+ }
5573
+ if (dedicatedInputAckMatches(state.current, message)) {
5574
+ if (state.timeoutTimer) {
5575
+ clearTimeout(state.timeoutTimer);
5576
+ state.timeoutTimer = null;
5577
+ }
5578
+ state.current = state.next;
5579
+ state.next = null;
5580
+ armPendingDedicatedInputAck(device);
5581
+ return true;
5582
+ }
5583
+ if (dedicatedInputAckMatches(state.next, message)) {
5584
+ // A later ordered acknowledgement proves that the input reader is
5585
+ // still draining, even if an earlier diagnostic response vanished.
5586
+ clearPendingDedicatedInputAcks(device);
5587
+ return true;
5588
+ }
5589
+ return false;
5590
+ }
5414
5591
 
5415
5592
  function pruneCompletedInputFallbacks(device, now = Date.now()) {
5416
5593
  ensurePendingInputFallbacks(device);
@@ -5611,11 +5788,17 @@ export function createRemoteHub(options = {}) {
5611
5788
  });
5612
5789
  }
5613
5790
 
5614
- function emitRemoteInputError(device, message = {}, pending = null, fallbackError = 'remote-input-failed') {
5615
- const outcome = buildRemoteInputOutcome(device, message, pending);
5616
- emitEvent('RemoteInputError', {
5617
- ...outcome,
5618
- error: safeString(
5791
+ function emitRemoteInputError(
5792
+ device,
5793
+ message = {},
5794
+ pending = null,
5795
+ fallbackError = 'remote-input-failed',
5796
+ errorKind = 'route') {
5797
+ const outcome = buildRemoteInputOutcome(device, message, pending);
5798
+ emitEvent('RemoteInputError', {
5799
+ ...outcome,
5800
+ errorKind: errorKind === 'native-apply' ? 'native-apply' : 'route',
5801
+ error: safeString(
5619
5802
  message.error
5620
5803
  || message.Error
5621
5804
  || message.result?.error
@@ -5645,9 +5828,9 @@ export function createRemoteHub(options = {}) {
5645
5828
  // session cannot acknowledge input from the current control session.
5646
5829
  if (!pending && source === 'main') {
5647
5830
  return false;
5648
- }
5649
- if (message.type === 'input.error') {
5650
- emitRemoteInputError(device, message, pending);
5831
+ }
5832
+ if (message.type === 'input.error') {
5833
+ emitRemoteInputError(device, message, pending, 'remote-input-failed', 'native-apply');
5651
5834
  } else {
5652
5835
  emitRemoteInputApplied(device, message, pending);
5653
5836
  }
@@ -5667,8 +5850,13 @@ export function createRemoteHub(options = {}) {
5667
5850
  || !!error
5668
5851
  || result.ok === false
5669
5852
  || safeString(result.status, 40).toLowerCase() === 'failed';
5670
- if (failed) {
5671
- emitRemoteInputError(device, { ...message, error: error || 'remote-input-failed' }, completed);
5853
+ if (failed) {
5854
+ emitRemoteInputError(
5855
+ device,
5856
+ { ...message, error: error || 'remote-input-failed' },
5857
+ completed,
5858
+ 'remote-input-failed',
5859
+ 'native-apply');
5672
5860
  } else {
5673
5861
  emitRemoteInputApplied(device, message, completed);
5674
5862
  }
@@ -7866,10 +8054,14 @@ export function createRemoteHub(options = {}) {
7866
8054
  return;
7867
8055
  }
7868
8056
 
7869
- if (state.inputOnly) {
7870
- device.inputLastSeenAt = new Date().toISOString();
7871
- if (message.type === 'input.applied' || message.type === 'input.error') {
7872
- handleRemoteInputOutcome(device, {
8057
+ if (state.inputOnly) {
8058
+ if (device.inputSocket !== socket) {
8059
+ return;
8060
+ }
8061
+ device.inputLastSeenAt = new Date().toISOString();
8062
+ if (message.type === 'input.applied' || message.type === 'input.error') {
8063
+ acknowledgePendingDedicatedInput(device, message);
8064
+ handleRemoteInputOutcome(device, {
7873
8065
  ...message,
7874
8066
  agentApplyMs: Number(message.agentApplyMs || 0) || 0
7875
8067
  }, 'input');
@@ -9265,9 +9457,10 @@ export function createRemoteHub(options = {}) {
9265
9457
  && normalized.hubConnectionId !== previousOwnerConnectionId;
9266
9458
  const bindingChanged = previousInputBindingKey
9267
9459
  && previousInputBindingKey !== activeInputBindingKey;
9268
- const writeBindingChanged = inputWriteOwner.getBindingKey() !== activeInputBindingKey;
9269
- if (ownerChanged || bindingChanged || writeBindingChanged) {
9270
- if (ownerChanged) {
9460
+ const writeBindingChanged = inputWriteOwner.getBindingKey() !== activeInputBindingKey;
9461
+ if (ownerChanged || bindingChanged || writeBindingChanged) {
9462
+ clearPendingDedicatedInputAcks(device);
9463
+ if (ownerChanged) {
9271
9464
  clearClipboardOperationsForDevice(
9272
9465
  device,
9273
9466
  'browser-input-owner-changed',
@@ -9292,11 +9485,12 @@ export function createRemoteHub(options = {}) {
9292
9485
  return { ok: false, error: 'INPUT_CHANNEL_RECONNECTING' };
9293
9486
  }
9294
9487
  }
9295
- const sent = inputWriteOwner.enqueue({
9296
- type: 'input.control',
9297
- payload: {
9298
- ...normalized,
9299
- hubForwardedAtEpochMs: Date.now(),
9488
+ const hubForwardedAtEpochMs = Date.now();
9489
+ const sent = inputWriteOwner.enqueue({
9490
+ type: 'input.control',
9491
+ payload: {
9492
+ ...normalized,
9493
+ hubForwardedAtEpochMs,
9300
9494
  issuedAt: normalized.issuedAt || new Date().toISOString()
9301
9495
  }
9302
9496
  }, activeInputBindingKey);
@@ -9304,10 +9498,10 @@ export function createRemoteHub(options = {}) {
9304
9498
  if (normalized.hubConnectionId) {
9305
9499
  device.inputOwnerConnectionId = normalized.hubConnectionId;
9306
9500
  }
9307
- device.inputOwnerBindingKey = activeInputBindingKey;
9308
- device.counters.commandsSent += 1;
9309
- device.inputLastSeenAt = new Date().toISOString();
9310
- if (clipboardOperationKey) {
9501
+ device.inputOwnerBindingKey = activeInputBindingKey;
9502
+ device.counters.commandsSent += 1;
9503
+ device.inputLastSeenAt = new Date().toISOString();
9504
+ if (clipboardOperationKey) {
9311
9505
  const operation = clipboardOperations.get(clipboardOperationKey);
9312
9506
  if (operation) {
9313
9507
  operation.status = 'pasting';
@@ -9397,7 +9591,8 @@ export function createRemoteHub(options = {}) {
9397
9591
  return { ok: false, error: 'input-owner-not-current' };
9398
9592
  }
9399
9593
 
9400
- clearClipboardOperationsForDevice(device, reason, owner);
9594
+ clearClipboardOperationsForDevice(device, reason, owner);
9595
+ clearPendingDedicatedInputAcks(device);
9401
9596
  device.inputOwnerConnectionId = '';
9402
9597
  device.inputOwnerBindingKey = '';
9403
9598
  const inputSocket = device.inputSocket;
package/src/server.js CHANGED
@@ -5927,13 +5927,13 @@ inputWss.on('connection', ws => {
5927
5927
  try {
5928
5928
  payload = JSON.parse(Buffer.isBuffer(data) ? data.toString('utf8') : String(data || ''));
5929
5929
  } catch {
5930
- sendJson(ws, { type: 'RemoteInputError', error: 'invalid-json' });
5930
+ sendJson(ws, { type: 'RemoteInputError', errorKind: 'route', error: 'invalid-json' });
5931
5931
  return;
5932
5932
  }
5933
5933
  const deviceId = String(payload?.deviceId || '').trim();
5934
5934
  if (payload?.type === 'watch') {
5935
5935
  if (!deviceId) {
5936
- sendJson(ws, { type: 'RemoteInputError', error: 'device-id-required' });
5936
+ sendJson(ws, { type: 'RemoteInputError', errorKind: 'route', error: 'device-id-required' });
5937
5937
  return;
5938
5938
  }
5939
5939
  for (const previousDeviceId of ws.liveDeskInputDeviceIds) {
@@ -5970,7 +5970,8 @@ inputWss.on('connection', ws => {
5970
5970
  return;
5971
5971
  }
5972
5972
  sendJson(ws, {
5973
- type: result?.ok ? 'RemoteInputQueued' : 'RemoteInputError',
5973
+ type: result?.ok ? 'RemoteInputQueued' : 'RemoteInputError',
5974
+ errorKind: result?.ok ? '' : 'route',
5974
5975
  requestId: payload?.requestId || '',
5975
5976
  deviceId,
5976
5977
  inputSeq: Number(input?.inputSeq || 0) || 0,