@livedesk/hub 0.1.27 → 0.1.29

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.
@@ -3,15 +3,19 @@ import { lookup } from 'node:dns/promises';
3
3
  import dgram from 'node:dgram';
4
4
  import {
5
5
  UDP_MAX_DATAGRAM_BYTES,
6
+ UDP_FRAME_CHUNK_PROTOCOL,
6
7
  UDP_P2P_PROTOCOL,
7
8
  UdpFrameReassembler,
8
9
  UdpReplayWindow,
9
- decodeRendezvousMessage,
10
- decodeUdpPacket,
10
+ decodeRendezvousMessage,
11
+ decodeUdpPacket,
11
12
  encodeRendezvousMessage,
12
13
  encodeUdpPacket,
13
- createUdpSessionKey
14
- } from './udp-protocol.js';
14
+ createUdpSessionKey,
15
+ isUdpBinaryDatagram,
16
+ peekUdpSessionRouteKey,
17
+ udpSessionRouteKey
18
+ } from './udp-protocol.js';
15
19
 
16
20
  function isEnabled(value, fallback = false) {
17
21
  if (value === undefined || value === null || value === '') return fallback;
@@ -69,9 +73,21 @@ export function createHubUdpTransport({ env = process.env, logEvent = () => {},
69
73
  const punchTimeoutMs = numberEnv(env.LIVEDESK_UDP_PUNCH_TIMEOUT_MS, 500, 10_000, 3000);
70
74
  const frameTimeoutMs = numberEnv(env.LIVEDESK_UDP_FRAME_TIMEOUT_MS, 20, 2000, 500);
71
75
  const maxPendingFrames = numberEnv(env.LIVEDESK_UDP_MAX_PENDING_FRAMES, 1, 16, 2);
72
- const trace = isEnabled(env.LIVEDESK_UDP_TRACE, false);
73
- const socket = dgram.createSocket('udp4');
74
- const sessions = new Map();
76
+ const trace = isEnabled(env.LIVEDESK_UDP_TRACE, false);
77
+ const socket = dgram.createSocket('udp4');
78
+ const sessions = new Map();
79
+ const sessionsBySessionId = new Map();
80
+ const sessionsByRouteKey = new Map();
81
+ const resource = {
82
+ datagramsReceived: 0,
83
+ binaryDatagramsReceived: 0,
84
+ rendezvousJsonParseAttempts: 0,
85
+ binaryJsonParseAttempts: 0,
86
+ sessionLookupCount: 0,
87
+ linearSessionScanCount: 0,
88
+ authenticatedDatagrams: 0,
89
+ rejectedDatagrams: 0
90
+ };
75
91
  let frameHandler = () => {};
76
92
  let eventHandler = () => {};
77
93
  let started = false;
@@ -80,6 +96,8 @@ export function createHubUdpTransport({ env = process.env, logEvent = () => {},
80
96
  let rendezvousAddresses = new Set();
81
97
  let rendezvousRefreshTimer = null;
82
98
  let rendezvousRefreshPromise = null;
99
+ let frameExpiryTimer = null;
100
+ const peerProbeBurstDelaysMs = [0, 120, 360];
83
101
 
84
102
  function emit(type, event = {}) {
85
103
  eventHandler(type, event);
@@ -172,17 +190,31 @@ export function createHubUdpTransport({ env = process.env, logEvent = () => {},
172
190
  rendezvousRefreshTimer = null;
173
191
  }
174
192
 
193
+ function startFrameExpiryTimer() {
194
+ if (frameExpiryTimer) return;
195
+ frameExpiryTimer = setInterval(() => {
196
+ const now = Date.now();
197
+ for (const session of sessions.values()) {
198
+ session.reassembler.expire(now);
199
+ }
200
+ }, Math.max(20, Math.min(250, frameTimeoutMs)));
201
+ frameExpiryTimer.unref?.();
202
+ }
203
+
204
+ function stopFrameExpiryTimer() {
205
+ if (frameExpiryTimer) clearInterval(frameExpiryTimer);
206
+ frameExpiryTimer = null;
207
+ }
208
+
175
209
  function isTrustedRendezvousSource(rinfo) {
176
210
  return Number(rinfo?.port) === rendezvousPort
177
211
  && rendezvousAddresses.has(normalizedAddress(rinfo?.address));
178
212
  }
179
213
 
180
214
  function sessionForPacket(packet) {
181
- if (!Buffer.isBuffer(packet) || packet.length < 7) return null;
182
- const sessionLength = packet[6];
183
- if (!sessionLength || packet.length < 27 + sessionLength) return null;
184
- const sessionId = packet.subarray(27, 27 + sessionLength).toString('utf8');
185
- return [...sessions.values()].find(item => item.sessionId === sessionId) || null;
215
+ resource.sessionLookupCount += 1;
216
+ const routeKey = peekUdpSessionRouteKey(packet);
217
+ return routeKey === null ? null : sessionsByRouteKey.get(routeKey) || null;
186
218
  }
187
219
 
188
220
  function bindAuthenticatedClientEndpoint(session, rinfo) {
@@ -199,8 +231,35 @@ export function createHubUdpTransport({ env = process.env, logEvent = () => {},
199
231
  return true;
200
232
  }
201
233
 
234
+ function clearPeerProbeTimers(session) {
235
+ if (!session?.peerProbeTimers) return;
236
+ for (const timer of session.peerProbeTimers) clearTimeout(timer);
237
+ session.peerProbeTimers.clear();
238
+ }
239
+
240
+ function schedulePeerProbeBurst(session, endpoint) {
241
+ clearPeerProbeTimers(session);
242
+ for (const delayMs of peerProbeBurstDelaysMs) {
243
+ const sendProbe = () => {
244
+ if (sessions.get(session.deviceId) !== session
245
+ || !sameEndpoint(session.peerEndpoint, endpoint)) return;
246
+ sendPacket(session, 'probe', { punch: true }, Buffer.alloc(0), endpoint);
247
+ };
248
+ if (delayMs === 0) {
249
+ sendProbe();
250
+ continue;
251
+ }
252
+ const timer = setTimeout(() => {
253
+ session.peerProbeTimers.delete(timer);
254
+ sendProbe();
255
+ }, delayMs);
256
+ timer.unref?.();
257
+ session.peerProbeTimers.add(timer);
258
+ }
259
+ }
260
+
202
261
  function onRendezvousPeer(message, rinfo) {
203
- const session = [...sessions.values()].find(item => item.sessionId === safeText(message.roomId, 160));
262
+ const session = sessionsBySessionId.get(safeText(message.roomId, 160));
204
263
  if (!session || message.protocol !== UDP_P2P_PROTOCOL || !isTrustedRendezvousSource(rinfo)) return;
205
264
  const address = safeText(message.host, 128);
206
265
  const port = Number(message.port);
@@ -217,22 +276,49 @@ export function createHubUdpTransport({ env = process.env, logEvent = () => {},
217
276
  };
218
277
  session.hubRendezvousObservedAt = Date.now();
219
278
  }
220
- session.peerEndpoint = { address, port };
279
+ session.peerEndpoint = { address, port };
221
280
  session.lastPeerAt = Date.now();
222
- sendPacket(session, 'probe', { punch: true }, Buffer.alloc(0), session.peerEndpoint);
281
+ schedulePeerProbeBurst(session, session.peerEndpoint);
223
282
  emit('UdpPeerEndpointObserved', { deviceId: session.deviceId, endpoint: `${address}:${port}` });
224
283
  }
225
284
 
226
- socket.on('message', (payload, rinfo) => {
227
- const rendezvous = decodeRendezvousMessage(payload);
228
- if (rendezvous?.type === 'rendezvous.peer') {
229
- onRendezvousPeer(rendezvous, rinfo);
285
+ socket.on('message', (payload, rinfo) => {
286
+ resource.datagramsReceived += 1;
287
+ if (!isUdpBinaryDatagram(payload)) {
288
+ // Rendezvous messages are the only JSON datagrams. Binary packets
289
+ // never pay a speculative JSON parse/exception before routing.
290
+ if (payload?.[0] !== 0x7b) {
291
+ resource.rejectedDatagrams += 1;
292
+ return;
293
+ }
294
+ resource.rendezvousJsonParseAttempts += 1;
295
+ const rendezvous = decodeRendezvousMessage(payload);
296
+ if (rendezvous?.type === 'rendezvous.peer') {
297
+ onRendezvousPeer(rendezvous, rinfo);
298
+ } else {
299
+ resource.rejectedDatagrams += 1;
300
+ }
230
301
  return;
231
302
  }
303
+ resource.binaryDatagramsReceived += 1;
232
304
  const session = sessionForPacket(payload);
233
- if (!session) return;
234
- const packet = decodeUdpPacket(payload, session.key);
235
- if (!packet || packet.sessionId !== session.sessionId || !session.replay.accept(packet.sequence)) return;
305
+ if (!session) {
306
+ resource.rejectedDatagrams += 1;
307
+ return;
308
+ }
309
+ session.resource.binaryDatagramsReceived += 1;
310
+ session.resource.decryptAttempts += 1;
311
+ const packet = decodeUdpPacket(payload, session.key, {
312
+ id: session.sessionId,
313
+ bytes: session.sessionIdBytes
314
+ });
315
+ if (!packet || packet.sessionId !== session.sessionId || !session.replay.accept(packet.sequence)) {
316
+ session.resource.rejectedDatagrams += 1;
317
+ resource.rejectedDatagrams += 1;
318
+ return;
319
+ }
320
+ resource.authenticatedDatagrams += 1;
321
+ session.resource.authenticatedDatagrams += 1;
236
322
  bindAuthenticatedClientEndpoint(session, rinfo);
237
323
  session.lastPacketAt = Date.now();
238
324
  if (packet.type === 'probe') {
@@ -240,7 +326,8 @@ export function createHubUdpTransport({ env = process.env, logEvent = () => {},
240
326
  emit('UdpProbeReceived', { deviceId: session.deviceId });
241
327
  return;
242
328
  }
243
- if (packet.type === 'ready') {
329
+ if (packet.type === 'ready') {
330
+ clearPeerProbeTimers(session);
244
331
  session.ready = true;
245
332
  sendPacket(session, 'pong', { state: 'ready' });
246
333
  session.sendControl?.({
@@ -288,6 +375,7 @@ export function createHubUdpTransport({ env = process.env, logEvent = () => {},
288
375
  protocol: 'livedesk.frame-transport-proof.v1',
289
376
  streamId: safeText(complete.header?.streamId, 128),
290
377
  commandId: safeText(complete.header?.commandId, 128),
378
+ streamPurpose: safeText(complete.header?.streamPurpose, 24),
291
379
  captureGeneration: Number(complete.header?.captureGeneration || 0) || 0,
292
380
  monitorIndex: Number(complete.header?.monitorIndex ?? -1),
293
381
  error: proofAccepted ? '' : safeText(handled?.error || 'frame-proof-rejected', 160)
@@ -300,6 +388,7 @@ export function createHubUdpTransport({ env = process.env, logEvent = () => {},
300
388
  frameSeq: Number(complete.header?.frameSeq || 0) || 0,
301
389
  streamId: safeText(complete.header?.streamId, 128),
302
390
  commandId: safeText(complete.header?.commandId, 128),
391
+ streamPurpose: safeText(complete.header?.streamPurpose, 24),
303
392
  captureGeneration: Number(complete.header?.captureGeneration || 0) || 0,
304
393
  monitorIndex: Number(complete.header?.monitorIndex ?? -1)
305
394
  });
@@ -327,19 +416,36 @@ export function createHubUdpTransport({ env = process.env, logEvent = () => {},
327
416
  logEvent('udp', `UDP P2P socket listening on udp://${bindHost}:${boundPort}`);
328
417
  void refreshRendezvousRegistrations('startup');
329
418
  startRendezvousRefreshTimer();
419
+ startFrameExpiryTimer();
330
420
  return getStatus();
331
421
  }
332
422
 
333
- function registerDevice({ deviceId, sessionId: tcpSessionId, sendControl, capabilities } = {}) {
334
- if (!enabled || capabilities?.udpP2p !== true || !started) return null;
335
- const id = safeText(deviceId, 160);
336
- if (!id) return null;
337
- sessions.get(id)?.sendControl?.({ type: 'udp.session.close', reason: 'replaced' });
338
- const key = createUdpSessionKey();
339
- const session = {
423
+ function registerDevice({ deviceId, sessionId: tcpSessionId, sendControl, capabilities } = {}) {
424
+ if (!enabled || capabilities?.udpP2p !== true || !started) return null;
425
+ const id = safeText(deviceId, 160);
426
+ if (!id) return null;
427
+ const previous = sessions.get(id);
428
+ previous?.sendControl?.({ type: 'udp.session.close', reason: 'replaced' });
429
+ if (previous) {
430
+ clearPeerProbeTimers(previous);
431
+ sessions.delete(id);
432
+ sessionsBySessionId.delete(previous.sessionId);
433
+ sessionsByRouteKey.delete(previous.routeKey);
434
+ previous.reassembler.clear();
435
+ }
436
+ const key = createUdpSessionKey();
437
+ let udpSessionId;
438
+ let routeKey;
439
+ do {
440
+ udpSessionId = crypto.randomUUID();
441
+ routeKey = udpSessionRouteKey(udpSessionId);
442
+ } while (sessionsByRouteKey.has(routeKey));
443
+ const session = {
340
444
  deviceId: id,
341
445
  tcpSessionId: safeText(tcpSessionId, 160),
342
- sessionId: crypto.randomUUID(),
446
+ sessionId: udpSessionId,
447
+ sessionIdBytes: Buffer.from(udpSessionId, 'utf8'),
448
+ routeKey,
343
449
  key,
344
450
  keyBase64: key.toString('base64'),
345
451
  rendezvousToken: crypto.randomBytes(16).toString('hex'),
@@ -349,14 +455,23 @@ export function createHubUdpTransport({ env = process.env, logEvent = () => {},
349
455
  hubRendezvousEndpoint: null,
350
456
  hubRendezvousObservedAt: 0,
351
457
  replay: new UdpReplayWindow(),
352
- reassembler: new UdpFrameReassembler({ maxPending: maxPendingFrames, timeoutMs: frameTimeoutMs }),
458
+ reassembler: new UdpFrameReassembler({ maxPending: maxPendingFrames, timeoutMs: frameTimeoutMs }),
459
+ resource: {
460
+ binaryDatagramsReceived: 0,
461
+ decryptAttempts: 0,
462
+ authenticatedDatagrams: 0,
463
+ rejectedDatagrams: 0
464
+ },
353
465
  ready: false,
466
+ peerProbeTimers: new Set(),
354
467
  createdAt: Date.now(),
355
468
  lastPacketAt: 0,
356
469
  framesReceived: 0
357
- };
358
- sessions.set(id, session);
359
- sendControl?.({
470
+ };
471
+ sessions.set(id, session);
472
+ sessionsBySessionId.set(session.sessionId, session);
473
+ sessionsByRouteKey.set(session.routeKey, session);
474
+ sendControl?.({
360
475
  type: 'udp.session.offer',
361
476
  protocol: UDP_P2P_PROTOCOL,
362
477
  sessionId: session.sessionId,
@@ -365,8 +480,9 @@ export function createHubUdpTransport({ env = process.env, logEvent = () => {},
365
480
  port: boundPort,
366
481
  expiresInMs: punchTimeoutMs + 15_000,
367
482
  punchTimeoutMs,
368
- maxDatagramBytes: UDP_MAX_DATAGRAM_BYTES,
369
- frameTimeoutMs,
483
+ maxDatagramBytes: UDP_MAX_DATAGRAM_BYTES,
484
+ frameChunkHeader: UDP_FRAME_CHUNK_PROTOCOL,
485
+ frameTimeoutMs,
370
486
  maxPendingFrames,
371
487
  rendezvous: rendezvousHost
372
488
  ? { host: rendezvousHost, port: rendezvousPort, roomId: session.sessionId, token: session.rendezvousToken }
@@ -377,12 +493,16 @@ export function createHubUdpTransport({ env = process.env, logEvent = () => {},
377
493
  return { sessionId: session.sessionId, ready: false };
378
494
  }
379
495
 
380
- function unregisterDevice(deviceId, tcpSessionId = '') {
496
+ function unregisterDevice(deviceId, tcpSessionId = '') {
381
497
  const id = safeText(deviceId, 160);
382
498
  const session = sessions.get(id);
383
- if (!session || (tcpSessionId && session.tcpSessionId !== tcpSessionId)) return;
499
+ if (!session || (tcpSessionId && session.tcpSessionId !== tcpSessionId)) return;
500
+ clearPeerProbeTimers(session);
384
501
  sessions.delete(id);
385
- emit('UdpSessionClosed', { deviceId: id, reason: 'tcp-session-closed' });
502
+ sessionsBySessionId.delete(session.sessionId);
503
+ sessionsByRouteKey.delete(session.routeKey);
504
+ session.reassembler.clear();
505
+ emit('UdpSessionClosed', { deviceId: id, reason: 'tcp-session-closed' });
386
506
  }
387
507
 
388
508
  function getDeviceStatus(deviceId) {
@@ -396,6 +516,12 @@ export function createHubUdpTransport({ env = process.env, logEvent = () => {},
396
516
  sessionId: session.sessionId,
397
517
  lastPacketAt: session.lastPacketAt ? new Date(session.lastPacketAt).toISOString() : '',
398
518
  framesReceived: session.framesReceived,
519
+ resource: {
520
+ ...session.resource,
521
+ binaryJsonParseAttempts: 0,
522
+ sessionLookup: 'map-o1',
523
+ linearSessionScanCount: 0
524
+ },
399
525
  ...session.reassembler.getStats()
400
526
  };
401
527
  }
@@ -467,7 +593,14 @@ export function createHubUdpTransport({ env = process.env, logEvent = () => {},
467
593
 
468
594
  async function close() {
469
595
  stopRendezvousRefreshTimer();
596
+ stopFrameExpiryTimer();
597
+ for (const session of sessions.values()) {
598
+ clearPeerProbeTimers(session);
599
+ session.reassembler.clear();
600
+ }
470
601
  sessions.clear();
602
+ sessionsBySessionId.clear();
603
+ sessionsByRouteKey.clear();
471
604
  if (!started) return;
472
605
  started = false;
473
606
  await new Promise(resolve => socket.close(() => resolve()));
@@ -486,8 +619,45 @@ export function createHubUdpTransport({ env = process.env, logEvent = () => {},
486
619
  setEventHandler: handler => { eventHandler = typeof handler === 'function' ? handler : () => {}; }
487
620
  };
488
621
 
489
- function getStatus() {
490
- return {
622
+ function getStatus() {
623
+ let readySessionCount = 0;
624
+ const reassembly = {
625
+ pendingFrames: 0,
626
+ pendingBytes: 0,
627
+ pendingChunkBuffers: 0,
628
+ pendingBytesHighWatermark: 0,
629
+ pendingChunkBuffersHighWatermark: 0,
630
+ droppedFrames: 0,
631
+ completedFrames: 0,
632
+ completedPayloadCopyCount: 0,
633
+ completedPayloadCopyBytes: 0,
634
+ fullWireDuplicateCopyCount: 0,
635
+ binaryChunkHeaderCount: 0,
636
+ legacyChunkHeaderCount: 0
637
+ };
638
+ for (const session of sessions.values()) {
639
+ if (session.ready) readySessionCount += 1;
640
+ const stats = session.reassembler.getStats();
641
+ reassembly.pendingFrames += stats.pendingFrames;
642
+ reassembly.pendingBytes += stats.pendingBytes;
643
+ reassembly.pendingChunkBuffers += stats.pendingChunkBuffers;
644
+ reassembly.pendingBytesHighWatermark = Math.max(
645
+ reassembly.pendingBytesHighWatermark,
646
+ stats.pendingBytesHighWatermark
647
+ );
648
+ reassembly.pendingChunkBuffersHighWatermark = Math.max(
649
+ reassembly.pendingChunkBuffersHighWatermark,
650
+ stats.pendingChunkBuffersHighWatermark
651
+ );
652
+ reassembly.droppedFrames += stats.droppedFrames;
653
+ reassembly.completedFrames += stats.completedFrames;
654
+ reassembly.completedPayloadCopyCount += stats.completedPayloadCopyCount;
655
+ reassembly.completedPayloadCopyBytes += stats.completedPayloadCopyBytes;
656
+ reassembly.fullWireDuplicateCopyCount += stats.fullWireDuplicateCopyCount;
657
+ reassembly.binaryChunkHeaderCount += stats.binaryChunkHeaderCount;
658
+ reassembly.legacyChunkHeaderCount += stats.legacyChunkHeaderCount;
659
+ }
660
+ return {
491
661
  enabled,
492
662
  preferred: preferP2p,
493
663
  started,
@@ -498,9 +668,15 @@ export function createHubUdpTransport({ env = process.env, logEvent = () => {},
498
668
  rendezvousPort,
499
669
  rendezvousRefreshMs,
500
670
  rendezvousResolvedAddressCount: rendezvousAddresses.size,
501
- maxDatagramBytes: UDP_MAX_DATAGRAM_BYTES,
502
- sessionCount: sessions.size,
503
- readySessionCount: [...sessions.values()].filter(session => session.ready).length
504
- };
671
+ maxDatagramBytes: UDP_MAX_DATAGRAM_BYTES,
672
+ sessionCount: sessions.size,
673
+ readySessionCount,
674
+ resource: {
675
+ ...resource,
676
+ indexedSessionCount: sessionsBySessionId.size,
677
+ indexedRouteCount: sessionsByRouteKey.size,
678
+ ...reassembly
679
+ }
680
+ };
505
681
  }
506
682
  }