@livedesk/hub 0.1.27 → 0.1.28

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,7 @@ 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;
83
100
 
84
101
  function emit(type, event = {}) {
85
102
  eventHandler(type, event);
@@ -172,17 +189,31 @@ export function createHubUdpTransport({ env = process.env, logEvent = () => {},
172
189
  rendezvousRefreshTimer = null;
173
190
  }
174
191
 
192
+ function startFrameExpiryTimer() {
193
+ if (frameExpiryTimer) return;
194
+ frameExpiryTimer = setInterval(() => {
195
+ const now = Date.now();
196
+ for (const session of sessions.values()) {
197
+ session.reassembler.expire(now);
198
+ }
199
+ }, Math.max(20, Math.min(250, frameTimeoutMs)));
200
+ frameExpiryTimer.unref?.();
201
+ }
202
+
203
+ function stopFrameExpiryTimer() {
204
+ if (frameExpiryTimer) clearInterval(frameExpiryTimer);
205
+ frameExpiryTimer = null;
206
+ }
207
+
175
208
  function isTrustedRendezvousSource(rinfo) {
176
209
  return Number(rinfo?.port) === rendezvousPort
177
210
  && rendezvousAddresses.has(normalizedAddress(rinfo?.address));
178
211
  }
179
212
 
180
213
  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;
214
+ resource.sessionLookupCount += 1;
215
+ const routeKey = peekUdpSessionRouteKey(packet);
216
+ return routeKey === null ? null : sessionsByRouteKey.get(routeKey) || null;
186
217
  }
187
218
 
188
219
  function bindAuthenticatedClientEndpoint(session, rinfo) {
@@ -200,7 +231,7 @@ export function createHubUdpTransport({ env = process.env, logEvent = () => {},
200
231
  }
201
232
 
202
233
  function onRendezvousPeer(message, rinfo) {
203
- const session = [...sessions.values()].find(item => item.sessionId === safeText(message.roomId, 160));
234
+ const session = sessionsBySessionId.get(safeText(message.roomId, 160));
204
235
  if (!session || message.protocol !== UDP_P2P_PROTOCOL || !isTrustedRendezvousSource(rinfo)) return;
205
236
  const address = safeText(message.host, 128);
206
237
  const port = Number(message.port);
@@ -223,16 +254,43 @@ export function createHubUdpTransport({ env = process.env, logEvent = () => {},
223
254
  emit('UdpPeerEndpointObserved', { deviceId: session.deviceId, endpoint: `${address}:${port}` });
224
255
  }
225
256
 
226
- socket.on('message', (payload, rinfo) => {
227
- const rendezvous = decodeRendezvousMessage(payload);
228
- if (rendezvous?.type === 'rendezvous.peer') {
229
- onRendezvousPeer(rendezvous, rinfo);
257
+ socket.on('message', (payload, rinfo) => {
258
+ resource.datagramsReceived += 1;
259
+ if (!isUdpBinaryDatagram(payload)) {
260
+ // Rendezvous messages are the only JSON datagrams. Binary packets
261
+ // never pay a speculative JSON parse/exception before routing.
262
+ if (payload?.[0] !== 0x7b) {
263
+ resource.rejectedDatagrams += 1;
264
+ return;
265
+ }
266
+ resource.rendezvousJsonParseAttempts += 1;
267
+ const rendezvous = decodeRendezvousMessage(payload);
268
+ if (rendezvous?.type === 'rendezvous.peer') {
269
+ onRendezvousPeer(rendezvous, rinfo);
270
+ } else {
271
+ resource.rejectedDatagrams += 1;
272
+ }
230
273
  return;
231
274
  }
275
+ resource.binaryDatagramsReceived += 1;
232
276
  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;
277
+ if (!session) {
278
+ resource.rejectedDatagrams += 1;
279
+ return;
280
+ }
281
+ session.resource.binaryDatagramsReceived += 1;
282
+ session.resource.decryptAttempts += 1;
283
+ const packet = decodeUdpPacket(payload, session.key, {
284
+ id: session.sessionId,
285
+ bytes: session.sessionIdBytes
286
+ });
287
+ if (!packet || packet.sessionId !== session.sessionId || !session.replay.accept(packet.sequence)) {
288
+ session.resource.rejectedDatagrams += 1;
289
+ resource.rejectedDatagrams += 1;
290
+ return;
291
+ }
292
+ resource.authenticatedDatagrams += 1;
293
+ session.resource.authenticatedDatagrams += 1;
236
294
  bindAuthenticatedClientEndpoint(session, rinfo);
237
295
  session.lastPacketAt = Date.now();
238
296
  if (packet.type === 'probe') {
@@ -288,6 +346,7 @@ export function createHubUdpTransport({ env = process.env, logEvent = () => {},
288
346
  protocol: 'livedesk.frame-transport-proof.v1',
289
347
  streamId: safeText(complete.header?.streamId, 128),
290
348
  commandId: safeText(complete.header?.commandId, 128),
349
+ streamPurpose: safeText(complete.header?.streamPurpose, 24),
291
350
  captureGeneration: Number(complete.header?.captureGeneration || 0) || 0,
292
351
  monitorIndex: Number(complete.header?.monitorIndex ?? -1),
293
352
  error: proofAccepted ? '' : safeText(handled?.error || 'frame-proof-rejected', 160)
@@ -300,6 +359,7 @@ export function createHubUdpTransport({ env = process.env, logEvent = () => {},
300
359
  frameSeq: Number(complete.header?.frameSeq || 0) || 0,
301
360
  streamId: safeText(complete.header?.streamId, 128),
302
361
  commandId: safeText(complete.header?.commandId, 128),
362
+ streamPurpose: safeText(complete.header?.streamPurpose, 24),
303
363
  captureGeneration: Number(complete.header?.captureGeneration || 0) || 0,
304
364
  monitorIndex: Number(complete.header?.monitorIndex ?? -1)
305
365
  });
@@ -327,19 +387,35 @@ export function createHubUdpTransport({ env = process.env, logEvent = () => {},
327
387
  logEvent('udp', `UDP P2P socket listening on udp://${bindHost}:${boundPort}`);
328
388
  void refreshRendezvousRegistrations('startup');
329
389
  startRendezvousRefreshTimer();
390
+ startFrameExpiryTimer();
330
391
  return getStatus();
331
392
  }
332
393
 
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 = {
394
+ function registerDevice({ deviceId, sessionId: tcpSessionId, sendControl, capabilities } = {}) {
395
+ if (!enabled || capabilities?.udpP2p !== true || !started) return null;
396
+ const id = safeText(deviceId, 160);
397
+ if (!id) return null;
398
+ const previous = sessions.get(id);
399
+ previous?.sendControl?.({ type: 'udp.session.close', reason: 'replaced' });
400
+ if (previous) {
401
+ sessions.delete(id);
402
+ sessionsBySessionId.delete(previous.sessionId);
403
+ sessionsByRouteKey.delete(previous.routeKey);
404
+ previous.reassembler.clear();
405
+ }
406
+ const key = createUdpSessionKey();
407
+ let udpSessionId;
408
+ let routeKey;
409
+ do {
410
+ udpSessionId = crypto.randomUUID();
411
+ routeKey = udpSessionRouteKey(udpSessionId);
412
+ } while (sessionsByRouteKey.has(routeKey));
413
+ const session = {
340
414
  deviceId: id,
341
415
  tcpSessionId: safeText(tcpSessionId, 160),
342
- sessionId: crypto.randomUUID(),
416
+ sessionId: udpSessionId,
417
+ sessionIdBytes: Buffer.from(udpSessionId, 'utf8'),
418
+ routeKey,
343
419
  key,
344
420
  keyBase64: key.toString('base64'),
345
421
  rendezvousToken: crypto.randomBytes(16).toString('hex'),
@@ -349,14 +425,22 @@ export function createHubUdpTransport({ env = process.env, logEvent = () => {},
349
425
  hubRendezvousEndpoint: null,
350
426
  hubRendezvousObservedAt: 0,
351
427
  replay: new UdpReplayWindow(),
352
- reassembler: new UdpFrameReassembler({ maxPending: maxPendingFrames, timeoutMs: frameTimeoutMs }),
353
- ready: false,
428
+ reassembler: new UdpFrameReassembler({ maxPending: maxPendingFrames, timeoutMs: frameTimeoutMs }),
429
+ resource: {
430
+ binaryDatagramsReceived: 0,
431
+ decryptAttempts: 0,
432
+ authenticatedDatagrams: 0,
433
+ rejectedDatagrams: 0
434
+ },
435
+ ready: false,
354
436
  createdAt: Date.now(),
355
437
  lastPacketAt: 0,
356
438
  framesReceived: 0
357
- };
358
- sessions.set(id, session);
359
- sendControl?.({
439
+ };
440
+ sessions.set(id, session);
441
+ sessionsBySessionId.set(session.sessionId, session);
442
+ sessionsByRouteKey.set(session.routeKey, session);
443
+ sendControl?.({
360
444
  type: 'udp.session.offer',
361
445
  protocol: UDP_P2P_PROTOCOL,
362
446
  sessionId: session.sessionId,
@@ -365,8 +449,9 @@ export function createHubUdpTransport({ env = process.env, logEvent = () => {},
365
449
  port: boundPort,
366
450
  expiresInMs: punchTimeoutMs + 15_000,
367
451
  punchTimeoutMs,
368
- maxDatagramBytes: UDP_MAX_DATAGRAM_BYTES,
369
- frameTimeoutMs,
452
+ maxDatagramBytes: UDP_MAX_DATAGRAM_BYTES,
453
+ frameChunkHeader: UDP_FRAME_CHUNK_PROTOCOL,
454
+ frameTimeoutMs,
370
455
  maxPendingFrames,
371
456
  rendezvous: rendezvousHost
372
457
  ? { host: rendezvousHost, port: rendezvousPort, roomId: session.sessionId, token: session.rendezvousToken }
@@ -377,12 +462,15 @@ export function createHubUdpTransport({ env = process.env, logEvent = () => {},
377
462
  return { sessionId: session.sessionId, ready: false };
378
463
  }
379
464
 
380
- function unregisterDevice(deviceId, tcpSessionId = '') {
465
+ function unregisterDevice(deviceId, tcpSessionId = '') {
381
466
  const id = safeText(deviceId, 160);
382
467
  const session = sessions.get(id);
383
- if (!session || (tcpSessionId && session.tcpSessionId !== tcpSessionId)) return;
384
- sessions.delete(id);
385
- emit('UdpSessionClosed', { deviceId: id, reason: 'tcp-session-closed' });
468
+ if (!session || (tcpSessionId && session.tcpSessionId !== tcpSessionId)) return;
469
+ sessions.delete(id);
470
+ sessionsBySessionId.delete(session.sessionId);
471
+ sessionsByRouteKey.delete(session.routeKey);
472
+ session.reassembler.clear();
473
+ emit('UdpSessionClosed', { deviceId: id, reason: 'tcp-session-closed' });
386
474
  }
387
475
 
388
476
  function getDeviceStatus(deviceId) {
@@ -396,6 +484,12 @@ export function createHubUdpTransport({ env = process.env, logEvent = () => {},
396
484
  sessionId: session.sessionId,
397
485
  lastPacketAt: session.lastPacketAt ? new Date(session.lastPacketAt).toISOString() : '',
398
486
  framesReceived: session.framesReceived,
487
+ resource: {
488
+ ...session.resource,
489
+ binaryJsonParseAttempts: 0,
490
+ sessionLookup: 'map-o1',
491
+ linearSessionScanCount: 0
492
+ },
399
493
  ...session.reassembler.getStats()
400
494
  };
401
495
  }
@@ -467,7 +561,11 @@ export function createHubUdpTransport({ env = process.env, logEvent = () => {},
467
561
 
468
562
  async function close() {
469
563
  stopRendezvousRefreshTimer();
564
+ stopFrameExpiryTimer();
565
+ for (const session of sessions.values()) session.reassembler.clear();
470
566
  sessions.clear();
567
+ sessionsBySessionId.clear();
568
+ sessionsByRouteKey.clear();
471
569
  if (!started) return;
472
570
  started = false;
473
571
  await new Promise(resolve => socket.close(() => resolve()));
@@ -486,8 +584,45 @@ export function createHubUdpTransport({ env = process.env, logEvent = () => {},
486
584
  setEventHandler: handler => { eventHandler = typeof handler === 'function' ? handler : () => {}; }
487
585
  };
488
586
 
489
- function getStatus() {
490
- return {
587
+ function getStatus() {
588
+ let readySessionCount = 0;
589
+ const reassembly = {
590
+ pendingFrames: 0,
591
+ pendingBytes: 0,
592
+ pendingChunkBuffers: 0,
593
+ pendingBytesHighWatermark: 0,
594
+ pendingChunkBuffersHighWatermark: 0,
595
+ droppedFrames: 0,
596
+ completedFrames: 0,
597
+ completedPayloadCopyCount: 0,
598
+ completedPayloadCopyBytes: 0,
599
+ fullWireDuplicateCopyCount: 0,
600
+ binaryChunkHeaderCount: 0,
601
+ legacyChunkHeaderCount: 0
602
+ };
603
+ for (const session of sessions.values()) {
604
+ if (session.ready) readySessionCount += 1;
605
+ const stats = session.reassembler.getStats();
606
+ reassembly.pendingFrames += stats.pendingFrames;
607
+ reassembly.pendingBytes += stats.pendingBytes;
608
+ reassembly.pendingChunkBuffers += stats.pendingChunkBuffers;
609
+ reassembly.pendingBytesHighWatermark = Math.max(
610
+ reassembly.pendingBytesHighWatermark,
611
+ stats.pendingBytesHighWatermark
612
+ );
613
+ reassembly.pendingChunkBuffersHighWatermark = Math.max(
614
+ reassembly.pendingChunkBuffersHighWatermark,
615
+ stats.pendingChunkBuffersHighWatermark
616
+ );
617
+ reassembly.droppedFrames += stats.droppedFrames;
618
+ reassembly.completedFrames += stats.completedFrames;
619
+ reassembly.completedPayloadCopyCount += stats.completedPayloadCopyCount;
620
+ reassembly.completedPayloadCopyBytes += stats.completedPayloadCopyBytes;
621
+ reassembly.fullWireDuplicateCopyCount += stats.fullWireDuplicateCopyCount;
622
+ reassembly.binaryChunkHeaderCount += stats.binaryChunkHeaderCount;
623
+ reassembly.legacyChunkHeaderCount += stats.legacyChunkHeaderCount;
624
+ }
625
+ return {
491
626
  enabled,
492
627
  preferred: preferP2p,
493
628
  started,
@@ -498,9 +633,15 @@ export function createHubUdpTransport({ env = process.env, logEvent = () => {},
498
633
  rendezvousPort,
499
634
  rendezvousRefreshMs,
500
635
  rendezvousResolvedAddressCount: rendezvousAddresses.size,
501
- maxDatagramBytes: UDP_MAX_DATAGRAM_BYTES,
502
- sessionCount: sessions.size,
503
- readySessionCount: [...sessions.values()].filter(session => session.ready).length
504
- };
636
+ maxDatagramBytes: UDP_MAX_DATAGRAM_BYTES,
637
+ sessionCount: sessions.size,
638
+ readySessionCount,
639
+ resource: {
640
+ ...resource,
641
+ indexedSessionCount: sessionsBySessionId.size,
642
+ indexedRouteCount: sessionsByRouteKey.size,
643
+ ...reassembly
644
+ }
645
+ };
505
646
  }
506
647
  }