@livedesk/hub 0.1.26 → 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') {
@@ -263,7 +321,7 @@ export function createHubUdpTransport({ env = process.env, logEvent = () => {},
263
321
  if (!complete) return;
264
322
  session.framesReceived += 1;
265
323
  const reassembly = session.reassembler.getStats();
266
- frameHandler({
324
+ const handled = frameHandler({
267
325
  deviceId: session.deviceId,
268
326
  sessionId: session.sessionId,
269
327
  header: {
@@ -276,11 +334,36 @@ export function createHubUdpTransport({ env = process.env, logEvent = () => {},
276
334
  },
277
335
  payload: complete.payload,
278
336
  transport: 'udp-p2p'
279
- });
280
- sendPacket(session, 'pong', {
281
- state: 'frame-ack',
282
- frameSeq: Number(complete.header?.frameSeq || 0) || 0
283
- });
337
+ });
338
+ const accepted = handled === true || handled?.accepted === true;
339
+ const proofId = safeText(complete.header?.frameTransportProofId, 96);
340
+ if (proofId) {
341
+ if (handled?.suppressProofResponse === true) return;
342
+ const proofAccepted = accepted && handled?.proofAccepted === true;
343
+ sendPacket(session, 'pong', {
344
+ state: proofAccepted ? 'frame-proof-ack' : 'frame-proof-reject',
345
+ proofId,
346
+ protocol: 'livedesk.frame-transport-proof.v1',
347
+ streamId: safeText(complete.header?.streamId, 128),
348
+ commandId: safeText(complete.header?.commandId, 128),
349
+ streamPurpose: safeText(complete.header?.streamPurpose, 24),
350
+ captureGeneration: Number(complete.header?.captureGeneration || 0) || 0,
351
+ monitorIndex: Number(complete.header?.monitorIndex ?? -1),
352
+ error: proofAccepted ? '' : safeText(handled?.error || 'frame-proof-rejected', 160)
353
+ });
354
+ return;
355
+ }
356
+ if (accepted) {
357
+ sendPacket(session, 'pong', {
358
+ state: 'frame-ack',
359
+ frameSeq: Number(complete.header?.frameSeq || 0) || 0,
360
+ streamId: safeText(complete.header?.streamId, 128),
361
+ commandId: safeText(complete.header?.commandId, 128),
362
+ streamPurpose: safeText(complete.header?.streamPurpose, 24),
363
+ captureGeneration: Number(complete.header?.captureGeneration || 0) || 0,
364
+ monitorIndex: Number(complete.header?.monitorIndex ?? -1)
365
+ });
366
+ }
284
367
  });
285
368
  socket.on('error', error => logWarn('udp', `UDP socket error: ${error?.message || error}`));
286
369
 
@@ -304,19 +387,35 @@ export function createHubUdpTransport({ env = process.env, logEvent = () => {},
304
387
  logEvent('udp', `UDP P2P socket listening on udp://${bindHost}:${boundPort}`);
305
388
  void refreshRendezvousRegistrations('startup');
306
389
  startRendezvousRefreshTimer();
390
+ startFrameExpiryTimer();
307
391
  return getStatus();
308
392
  }
309
393
 
310
- function registerDevice({ deviceId, sessionId: tcpSessionId, sendControl, capabilities } = {}) {
311
- if (!enabled || capabilities?.udpP2p !== true || !started) return null;
312
- const id = safeText(deviceId, 160);
313
- if (!id) return null;
314
- sessions.get(id)?.sendControl?.({ type: 'udp.session.close', reason: 'replaced' });
315
- const key = createUdpSessionKey();
316
- 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 = {
317
414
  deviceId: id,
318
415
  tcpSessionId: safeText(tcpSessionId, 160),
319
- sessionId: crypto.randomUUID(),
416
+ sessionId: udpSessionId,
417
+ sessionIdBytes: Buffer.from(udpSessionId, 'utf8'),
418
+ routeKey,
320
419
  key,
321
420
  keyBase64: key.toString('base64'),
322
421
  rendezvousToken: crypto.randomBytes(16).toString('hex'),
@@ -326,14 +425,22 @@ export function createHubUdpTransport({ env = process.env, logEvent = () => {},
326
425
  hubRendezvousEndpoint: null,
327
426
  hubRendezvousObservedAt: 0,
328
427
  replay: new UdpReplayWindow(),
329
- reassembler: new UdpFrameReassembler({ maxPending: maxPendingFrames, timeoutMs: frameTimeoutMs }),
330
- 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,
331
436
  createdAt: Date.now(),
332
437
  lastPacketAt: 0,
333
438
  framesReceived: 0
334
- };
335
- sessions.set(id, session);
336
- sendControl?.({
439
+ };
440
+ sessions.set(id, session);
441
+ sessionsBySessionId.set(session.sessionId, session);
442
+ sessionsByRouteKey.set(session.routeKey, session);
443
+ sendControl?.({
337
444
  type: 'udp.session.offer',
338
445
  protocol: UDP_P2P_PROTOCOL,
339
446
  sessionId: session.sessionId,
@@ -342,8 +449,9 @@ export function createHubUdpTransport({ env = process.env, logEvent = () => {},
342
449
  port: boundPort,
343
450
  expiresInMs: punchTimeoutMs + 15_000,
344
451
  punchTimeoutMs,
345
- maxDatagramBytes: UDP_MAX_DATAGRAM_BYTES,
346
- frameTimeoutMs,
452
+ maxDatagramBytes: UDP_MAX_DATAGRAM_BYTES,
453
+ frameChunkHeader: UDP_FRAME_CHUNK_PROTOCOL,
454
+ frameTimeoutMs,
347
455
  maxPendingFrames,
348
456
  rendezvous: rendezvousHost
349
457
  ? { host: rendezvousHost, port: rendezvousPort, roomId: session.sessionId, token: session.rendezvousToken }
@@ -354,12 +462,15 @@ export function createHubUdpTransport({ env = process.env, logEvent = () => {},
354
462
  return { sessionId: session.sessionId, ready: false };
355
463
  }
356
464
 
357
- function unregisterDevice(deviceId, tcpSessionId = '') {
465
+ function unregisterDevice(deviceId, tcpSessionId = '') {
358
466
  const id = safeText(deviceId, 160);
359
467
  const session = sessions.get(id);
360
- if (!session || (tcpSessionId && session.tcpSessionId !== tcpSessionId)) return;
361
- sessions.delete(id);
362
- 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' });
363
474
  }
364
475
 
365
476
  function getDeviceStatus(deviceId) {
@@ -373,6 +484,12 @@ export function createHubUdpTransport({ env = process.env, logEvent = () => {},
373
484
  sessionId: session.sessionId,
374
485
  lastPacketAt: session.lastPacketAt ? new Date(session.lastPacketAt).toISOString() : '',
375
486
  framesReceived: session.framesReceived,
487
+ resource: {
488
+ ...session.resource,
489
+ binaryJsonParseAttempts: 0,
490
+ sessionLookup: 'map-o1',
491
+ linearSessionScanCount: 0
492
+ },
376
493
  ...session.reassembler.getStats()
377
494
  };
378
495
  }
@@ -444,7 +561,11 @@ export function createHubUdpTransport({ env = process.env, logEvent = () => {},
444
561
 
445
562
  async function close() {
446
563
  stopRendezvousRefreshTimer();
564
+ stopFrameExpiryTimer();
565
+ for (const session of sessions.values()) session.reassembler.clear();
447
566
  sessions.clear();
567
+ sessionsBySessionId.clear();
568
+ sessionsByRouteKey.clear();
448
569
  if (!started) return;
449
570
  started = false;
450
571
  await new Promise(resolve => socket.close(() => resolve()));
@@ -463,8 +584,45 @@ export function createHubUdpTransport({ env = process.env, logEvent = () => {},
463
584
  setEventHandler: handler => { eventHandler = typeof handler === 'function' ? handler : () => {}; }
464
585
  };
465
586
 
466
- function getStatus() {
467
- 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 {
468
626
  enabled,
469
627
  preferred: preferP2p,
470
628
  started,
@@ -475,9 +633,15 @@ export function createHubUdpTransport({ env = process.env, logEvent = () => {},
475
633
  rendezvousPort,
476
634
  rendezvousRefreshMs,
477
635
  rendezvousResolvedAddressCount: rendezvousAddresses.size,
478
- maxDatagramBytes: UDP_MAX_DATAGRAM_BYTES,
479
- sessionCount: sessions.size,
480
- readySessionCount: [...sessions.values()].filter(session => session.ready).length
481
- };
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
+ };
482
646
  }
483
647
  }