livedesk 0.1.284 → 0.1.285

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.
@@ -960,8 +960,9 @@ function serializeDevice(device, options = {}) {
960
960
  byteLength: device.latestAudioFrame.byteLength
961
961
  }
962
962
  : null,
963
- latestAudioStatus: device.latestAudioStatus ? { ...device.latestAudioStatus } : null,
964
- latestTask: device.latestTask ? { ...device.latestTask } : null,
963
+ latestAudioStatus: device.latestAudioStatus ? { ...device.latestAudioStatus } : null,
964
+ udp: device.udp ? { ...device.udp } : { enabled: false, state: 'tcp-only', ready: false },
965
+ latestTask: device.latestTask ? { ...device.latestTask } : null,
965
966
  synthetic: device.synthetic === true,
966
967
  recentTasks: Array.isArray(device.recentTasks)
967
968
  ? device.recentTasks.map(task => ({ ...task }))
@@ -1395,6 +1396,9 @@ export function createRemoteHub(options = {}) {
1395
1396
  const getWelcomeDevicePolicy = typeof options.getWelcomeDevicePolicy === 'function'
1396
1397
  ? options.getWelcomeDevicePolicy
1397
1398
  : getEffectiveDevicePolicy;
1399
+ const udpTransport = options.udpTransport && typeof options.udpTransport === 'object'
1400
+ ? options.udpTransport
1401
+ : null;
1398
1402
 
1399
1403
  function getDevicePolicy(device) {
1400
1404
  try {
@@ -1746,7 +1750,7 @@ export function createRemoteHub(options = {}) {
1746
1750
  };
1747
1751
  }
1748
1752
 
1749
- function getStatus({ includeSecrets = false } = {}) {
1753
+ function getStatus({ includeSecrets = false } = {}) {
1750
1754
  const connectedDevices = [...devices.values()].filter(device => device.connected).length;
1751
1755
  const activeHostTarget = serializeHostTarget();
1752
1756
  const routeInfo = getAgentEndpointRouteInfo();
@@ -1791,10 +1795,18 @@ export function createRemoteHub(options = {}) {
1791
1795
  hostTargetExpiresAt: activeHostTarget.expiresAt,
1792
1796
  publicIPv4Address: publicIPv4Cache.address,
1793
1797
  publicIPv4UpdatedAt: publicIPv4Cache.updatedAt ? new Date(publicIPv4Cache.updatedAt).toISOString() : '',
1794
- publicIPv4Error: publicIPv4Cache.error,
1795
- externalExposure: isWildcardHost(host) || !isLoopbackHost(host),
1796
- lastError
1797
- };
1798
+ publicIPv4Error: publicIPv4Cache.error,
1799
+ udp: udpTransport?.getStatus?.() || {
1800
+ enabled: false,
1801
+ preferred: false,
1802
+ started: false,
1803
+ port: 0,
1804
+ sessionCount: 0,
1805
+ readySessionCount: 0
1806
+ },
1807
+ externalExposure: isWildcardHost(host) || !isLoopbackHost(host),
1808
+ lastError
1809
+ };
1798
1810
  }
1799
1811
 
1800
1812
  function rotatePairingPin() {
@@ -2650,9 +2662,15 @@ export function createRemoteHub(options = {}) {
2650
2662
  },
2651
2663
  activeLiveStream: null,
2652
2664
  activeAudioStream: null,
2653
- latestAudioFrame: null,
2654
- latestAudioStatus: null,
2655
- latestTask: null,
2665
+ latestAudioFrame: null,
2666
+ latestAudioStatus: null,
2667
+ udp: {
2668
+ enabled: capabilities.udpP2p === true,
2669
+ state: 'tcp-only',
2670
+ ready: false,
2671
+ sessionId: ''
2672
+ },
2673
+ latestTask: null,
2656
2674
  recentTasks: [],
2657
2675
  pendingTaskCommands: new Map(),
2658
2676
  pendingTaskTimers: new Map(),
@@ -2662,7 +2680,7 @@ export function createRemoteHub(options = {}) {
2662
2680
 
2663
2681
  devices.set(deviceId, device);
2664
2682
  sockets.set(socket, deviceId);
2665
- writeJsonLine(socket, {
2683
+ writeJsonLine(socket, {
2666
2684
  type: 'welcome',
2667
2685
  protocol: REMOTE_AGENT_PROTOCOL,
2668
2686
  protocolVersion: REMOTE_PROTOCOL_VERSION,
@@ -2672,8 +2690,22 @@ export function createRemoteHub(options = {}) {
2672
2690
  frameProtocol: buildRemoteFrameProtocolDescriptor(),
2673
2691
  frameModes: getSupportedRemoteFrameModeProfiles(),
2674
2692
  effectivePolicy: getWelcomeDevicePolicy({ deviceId, capabilities }),
2675
- serverTime: now
2676
- });
2693
+ serverTime: now
2694
+ });
2695
+
2696
+ const udpSession = udpTransport?.registerDevice?.({
2697
+ deviceId,
2698
+ sessionId,
2699
+ sendControl: payload => writeJsonLine(socket, payload),
2700
+ capabilities
2701
+ });
2702
+ if (udpSession?.sessionId) {
2703
+ device.udp = {
2704
+ ...device.udp,
2705
+ state: 'udp-session-created',
2706
+ sessionId: udpSession.sessionId
2707
+ };
2708
+ }
2677
2709
 
2678
2710
  logEvent('remote', `device connected ${device.deviceName} (${deviceId})`, 'success');
2679
2711
  emitRemoteEvent('RemoteDeviceConnected', device);
@@ -2812,8 +2844,9 @@ export function createRemoteHub(options = {}) {
2812
2844
  return;
2813
2845
  }
2814
2846
 
2815
- device.connected = false;
2816
- device.socket = null;
2847
+ device.connected = false;
2848
+ device.socket = null;
2849
+ udpTransport?.unregisterDevice?.(deviceId, device.sessionId);
2817
2850
  closeInputSocket(device, reason);
2818
2851
  closeFrameSocket(device, reason);
2819
2852
  device.disconnectedAt = new Date().toISOString();
@@ -3687,7 +3720,7 @@ export function createRemoteHub(options = {}) {
3687
3720
  return true;
3688
3721
  }
3689
3722
 
3690
- function handleAgentBinaryFrame(socket, state, header, framePayload) {
3723
+ function handleAgentBinaryFrame(socket, state, header, framePayload) {
3691
3724
  if (!state.authenticated || !state.device) {
3692
3725
  writeJsonLine(socket, { type: 'error', error: 'hello-required' });
3693
3726
  socket.destroy();
@@ -3734,11 +3767,37 @@ export function createRemoteHub(options = {}) {
3734
3767
  return;
3735
3768
  }
3736
3769
 
3737
- emitRemoteEvent('RemoteAgentMessageIgnored', device, {
3738
- messageType: safeString(header.type, 80),
3739
- frameKind
3740
- });
3741
- }
3770
+ emitRemoteEvent('RemoteAgentMessageIgnored', device, {
3771
+ messageType: safeString(header.type, 80),
3772
+ frameKind
3773
+ });
3774
+ }
3775
+
3776
+ function handleUdpFrame({ deviceId, sessionId, header, payload } = {}) {
3777
+ const device = devices.get(safeString(deviceId, 160));
3778
+ if (!device || !device.connected) {
3779
+ return false;
3780
+ }
3781
+ if (device.udp?.sessionId && device.udp.sessionId !== safeString(sessionId, 160)) {
3782
+ return false;
3783
+ }
3784
+ const frameMode = safeString(header?.frameMode || header?.mode, 40).toLowerCase();
3785
+ if (frameMode !== 'mode3-h264-hw') {
3786
+ emitRemoteEvent('RemoteFrameDropped', device, { reason: 'udp-frame-mode-not-supported', transport: 'udp-p2p', frameMode });
3787
+ return false;
3788
+ }
3789
+ device.udp = {
3790
+ ...device.udp,
3791
+ state: 'udp-active',
3792
+ ready: true,
3793
+ lastFrameAt: new Date().toISOString(),
3794
+ framesReceived: Number(device.udp?.framesReceived || 0) + 1
3795
+ };
3796
+ return applyLiveFrame(device, {
3797
+ ...header,
3798
+ transport: 'udp-p2p'
3799
+ }, payload, 'udp-p2p');
3800
+ }
3742
3801
 
3743
3802
  function dropQueuedAgentBinaryFrame(state) {
3744
3803
  if (!Array.isArray(state.binaryQueue) || state.binaryQueue.length === 0) {
@@ -4399,13 +4458,14 @@ export function createRemoteHub(options = {}) {
4399
4458
  });
4400
4459
  }
4401
4460
 
4402
- async function start() {
4461
+ async function start() {
4403
4462
  if (!enabled || started) {
4404
4463
  return getStatus({ includeSecrets: false });
4405
4464
  }
4406
4465
 
4407
- try {
4408
- await listenOnPort(requestedPort);
4466
+ try {
4467
+ await udpTransport?.start?.();
4468
+ await listenOnPort(requestedPort);
4409
4469
  } catch (error) {
4410
4470
  if (error?.code === 'EADDRINUSE') {
4411
4471
  const conflict = new Error(
@@ -4422,7 +4482,7 @@ export function createRemoteHub(options = {}) {
4422
4482
  return getStatus({ includeSecrets: false });
4423
4483
  }
4424
4484
 
4425
- async function close() {
4485
+ async function close() {
4426
4486
  for (const device of devices.values()) {
4427
4487
  failAllPendingTasks(device, 'hub-shutdown');
4428
4488
  if (device.socket && !device.socket.destroyed) {
@@ -4437,8 +4497,9 @@ export function createRemoteHub(options = {}) {
4437
4497
  }
4438
4498
  }
4439
4499
 
4440
- sockets.clear();
4441
- if (!server) {
4500
+ sockets.clear();
4501
+ await udpTransport?.close?.();
4502
+ if (!server) {
4442
4503
  started = false;
4443
4504
  return;
4444
4505
  }
@@ -5488,7 +5549,7 @@ export function createRemoteHub(options = {}) {
5488
5549
  });
5489
5550
  }
5490
5551
 
5491
- function getFramePayload(deviceId, frameKind, options = {}) {
5552
+ function getFramePayload(deviceId, frameKind, options = {}) {
5492
5553
  const device = devices.get(String(deviceId || ''));
5493
5554
  if (!device) {
5494
5555
  return null;
@@ -5505,10 +5566,25 @@ export function createRemoteHub(options = {}) {
5505
5566
  payload: frame.payload,
5506
5567
  mimeType: safeString(frame.mimeType || frame.format || 'application/octet-stream', 120) || 'application/octet-stream',
5507
5568
  byteLength: frame.payload.length
5508
- };
5509
- }
5510
-
5511
- return {
5569
+ };
5570
+ }
5571
+
5572
+ udpTransport?.setFrameHandler?.(handleUdpFrame);
5573
+ udpTransport?.setEventHandler?.((type, event = {}) => {
5574
+ const device = devices.get(safeString(event.deviceId, 160));
5575
+ if (device?.udp) {
5576
+ if (type === 'UdpSessionReady') {
5577
+ device.udp = { ...device.udp, state: 'udp-ready', ready: true };
5578
+ } else if (type === 'UdpSessionClosed') {
5579
+ device.udp = { ...device.udp, state: 'tcp-fallback', ready: false };
5580
+ }
5581
+ }
5582
+ if (device) {
5583
+ emitRemoteEvent(`Remote${type}`, device, { ...event });
5584
+ }
5585
+ });
5586
+
5587
+ return {
5512
5588
  start,
5513
5589
  close,
5514
5590
  getStatus,
@@ -5534,8 +5610,9 @@ export function createRemoteHub(options = {}) {
5534
5610
  stopAudioStream,
5535
5611
  getDeviceLiveFrame,
5536
5612
  getDeviceThumbnail,
5537
- getFramePayload,
5538
- seedSyntheticFleet,
5613
+ getFramePayload,
5614
+ handleUdpFrame,
5615
+ seedSyntheticFleet,
5539
5616
  clearSyntheticFleet,
5540
5617
  getPairToken: () => pairToken,
5541
5618
  getPairingPin: () => pairingPin
package/hub/src/server.js CHANGED
@@ -27,6 +27,7 @@ import { effectiveDevicePolicy } from './settings/settings-schema.js';
27
27
  import { buildEffectiveDevicePolicy } from './settings/effective-device-policy.js';
28
28
  import { CaptureStore } from './captures/capture-store.js';
29
29
  import { createLiveDeskUpdateManager } from './live-desk-update.js';
30
+ import { createHubUdpTransport } from './transport/udp-hub-transport.js';
30
31
 
31
32
  const __dirname = dirname(fileURLToPath(import.meta.url));
32
33
  const webDistCandidates = [
@@ -250,14 +251,21 @@ void liveDeskSettingsStore.getRecord().catch(error => {
250
251
  console.warn(`[LiveDesk Hub] settings load failed: ${error instanceof Error ? error.message : String(error)}`);
251
252
  });
252
253
 
254
+ const udpTransport = createHubUdpTransport({
255
+ env: process.env,
256
+ logEvent: (_scope, message) => console.log(`[LiveDesk Hub] ${message}`),
257
+ logWarn: (_scope, message) => console.warn(`[LiveDesk Hub] ${message}`)
258
+ });
259
+
253
260
  const remoteHub = createRemoteHub({
254
261
  managerPackage: '@livedesk/hub',
255
262
  managerVersion: packageInfo.version,
256
- env: {
263
+ env: {
257
264
  ...process.env,
258
265
  MINDEXEC_MANAGER_PACKAGE: '@livedesk/hub',
259
266
  MINDEXEC_MANAGER_VERSION: packageInfo.version
260
- },
267
+ },
268
+ udpTransport,
261
269
  logEvent: (_scope, message) => console.log(`[LiveDesk Hub] ${message}`),
262
270
  logWarn: (_scope, message) => console.warn(`[LiveDesk Hub] ${message}`),
263
271
  emitEvent: handleRemoteHubEvent,
@@ -0,0 +1,281 @@
1
+ import crypto from 'node:crypto';
2
+ import dgram from 'node:dgram';
3
+ import {
4
+ UDP_MAX_DATAGRAM_BYTES,
5
+ UDP_P2P_PROTOCOL,
6
+ UdpFrameReassembler,
7
+ UdpReplayWindow,
8
+ decodeRendezvousMessage,
9
+ decodeUdpPacket,
10
+ encodeRendezvousMessage,
11
+ encodeUdpPacket,
12
+ createUdpSessionKey
13
+ } from './udp-protocol.js';
14
+
15
+ function isEnabled(value, fallback = false) {
16
+ if (value === undefined || value === null || value === '') return fallback;
17
+ return /^(1|true|yes|on)$/i.test(String(value).trim());
18
+ }
19
+
20
+ function safeText(value, max = 160) {
21
+ return String(value || '').replace(/[\r\n\t]/g, ' ').trim().slice(0, max);
22
+ }
23
+
24
+ function numberEnv(value, min, max, fallback) {
25
+ const number = Number(value);
26
+ return Number.isFinite(number) ? Math.max(min, Math.min(max, Math.floor(number))) : fallback;
27
+ }
28
+
29
+ function sameEndpoint(left, right) {
30
+ return !!left && !!right && String(left.address) === String(right.address) && Number(left.port) === Number(right.port);
31
+ }
32
+
33
+ export function createHubUdpTransport({ env = process.env, logEvent = () => {}, logWarn = () => {} } = {}) {
34
+ const enabled = isEnabled(env.LIVEDESK_UDP_ENABLED, false);
35
+ const preferP2p = isEnabled(env.LIVEDESK_UDP_PREFER_P2P, true);
36
+ const bindHost = safeText(env.LIVEDESK_UDP_BIND_HOST || '0.0.0.0', 128) || '0.0.0.0';
37
+ const requestedPort = numberEnv(env.LIVEDESK_UDP_PORT, 0, 65535, 0);
38
+ const advertiseHost = safeText(env.LIVEDESK_UDP_ADVERTISE_HOST || env.LIVEDESK_REMOTE_PUBLIC_HOST || '', 128);
39
+ const rendezvousHost = safeText(env.LIVEDESK_UDP_RENDEZVOUS_HOST || '', 128);
40
+ const rendezvousPort = numberEnv(env.LIVEDESK_UDP_RENDEZVOUS_PORT, 1, 65535, 5199);
41
+ const punchTimeoutMs = numberEnv(env.LIVEDESK_UDP_PUNCH_TIMEOUT_MS, 500, 10_000, 3000);
42
+ const frameTimeoutMs = numberEnv(env.LIVEDESK_UDP_FRAME_TIMEOUT_MS, 20, 2000, 80);
43
+ const maxPendingFrames = numberEnv(env.LIVEDESK_UDP_MAX_PENDING_FRAMES, 1, 16, 2);
44
+ const trace = isEnabled(env.LIVEDESK_UDP_TRACE, false);
45
+ const socket = dgram.createSocket('udp4');
46
+ const sessions = new Map();
47
+ let frameHandler = () => {};
48
+ let eventHandler = () => {};
49
+ let started = false;
50
+ let boundPort = requestedPort;
51
+ let sequence = crypto.randomInt(1, 0x7fffffff);
52
+
53
+ function emit(type, event = {}) {
54
+ eventHandler(type, event);
55
+ if (trace) logEvent('udp', `${type} ${JSON.stringify({ deviceId: event.deviceId || '', state: event.state || '', reason: event.reason || '' })}`);
56
+ }
57
+
58
+ function nextSequence() {
59
+ sequence = (sequence + 1) >>> 0;
60
+ return sequence;
61
+ }
62
+
63
+ function sendPacket(session, type, header = {}, payload = Buffer.alloc(0), address = session.peerEndpoint || session.clientEndpoint) {
64
+ if (!address || !session) return false;
65
+ let packet;
66
+ try {
67
+ packet = encodeUdpPacket({ type, sessionId: session.sessionId, key: session.key, sequence: nextSequence(), header, payload });
68
+ } catch (error) {
69
+ logWarn('udp', `UDP packet rejected device=${session.deviceId}: ${error?.message || error}`);
70
+ return false;
71
+ }
72
+ socket.send(packet, address.port, address.address);
73
+ return true;
74
+ }
75
+
76
+ function sendRendezvousRegister(session) {
77
+ if (!rendezvousHost) return;
78
+ const message = encodeRendezvousMessage({
79
+ type: 'rendezvous.register',
80
+ protocol: UDP_P2P_PROTOCOL,
81
+ roomId: session.sessionId,
82
+ role: 'hub',
83
+ token: session.rendezvousToken
84
+ });
85
+ socket.send(message, rendezvousPort, rendezvousHost);
86
+ }
87
+
88
+ function sessionForPacket(packet, rinfo) {
89
+ if (!Buffer.isBuffer(packet) || packet.length < 7) return null;
90
+ const sessionLength = packet[6];
91
+ if (!sessionLength || packet.length < 27 + sessionLength) return null;
92
+ const sessionId = packet.subarray(27, 27 + sessionLength).toString('utf8');
93
+ const session = [...sessions.values()].find(item => item.sessionId === sessionId);
94
+ if (!session) return null;
95
+ if (!session.clientEndpoint) session.clientEndpoint = { address: rinfo.address, port: rinfo.port };
96
+ if (!sameEndpoint(session.clientEndpoint, rinfo)) return null;
97
+ return session;
98
+ }
99
+
100
+ function onRendezvousPeer(message, rinfo) {
101
+ const session = [...sessions.values()].find(item => item.sessionId === safeText(message.roomId, 160));
102
+ if (!session || !rendezvousHost || rinfo.address !== rendezvousHost) return;
103
+ const address = safeText(message.host, 128);
104
+ const port = Number(message.port);
105
+ if (!address || !Number.isInteger(port) || port < 1 || port > 65535) return;
106
+ session.peerEndpoint = { address, port };
107
+ session.lastPeerAt = Date.now();
108
+ sendPacket(session, 'probe', { punch: true }, Buffer.alloc(0), session.peerEndpoint);
109
+ emit('UdpPeerEndpointObserved', { deviceId: session.deviceId, endpoint: `${address}:${port}` });
110
+ }
111
+
112
+ socket.on('message', (payload, rinfo) => {
113
+ const rendezvous = decodeRendezvousMessage(payload);
114
+ if (rendezvous?.type === 'rendezvous.peer') {
115
+ onRendezvousPeer(rendezvous, rinfo);
116
+ return;
117
+ }
118
+ const session = sessionForPacket(payload, rinfo);
119
+ if (!session) return;
120
+ const packet = decodeUdpPacket(payload, session.key);
121
+ if (!packet || packet.sessionId !== session.sessionId || !session.replay.accept(packet.sequence)) return;
122
+ session.lastPacketAt = Date.now();
123
+ if (packet.type === 'probe') {
124
+ session.clientEndpoint = { address: rinfo.address, port: rinfo.port };
125
+ sendPacket(session, 'probe-ack', { observedHost: rinfo.address, observedPort: rinfo.port });
126
+ emit('UdpProbeReceived', { deviceId: session.deviceId });
127
+ return;
128
+ }
129
+ if (packet.type === 'ready') {
130
+ session.ready = true;
131
+ session.clientEndpoint = { address: rinfo.address, port: rinfo.port };
132
+ sendPacket(session, 'pong', { state: 'ready' });
133
+ session.sendControl?.({
134
+ type: 'udp.session.ready',
135
+ protocol: UDP_P2P_PROTOCOL,
136
+ sessionId: session.sessionId,
137
+ transport: 'udp-p2p',
138
+ ready: true,
139
+ endpoint: { host: advertiseHost, port: boundPort }
140
+ });
141
+ emit('UdpSessionReady', { deviceId: session.deviceId, state: 'udp-ready' });
142
+ return;
143
+ }
144
+ if (packet.type === 'ping') {
145
+ sendPacket(session, 'pong', { now: Date.now() });
146
+ return;
147
+ }
148
+ if (packet.type !== 'frame') return;
149
+ const complete = session.reassembler.ingest(packet);
150
+ if (!complete) return;
151
+ session.framesReceived += 1;
152
+ frameHandler({
153
+ deviceId: session.deviceId,
154
+ sessionId: session.sessionId,
155
+ header: complete.header,
156
+ payload: complete.payload,
157
+ transport: 'udp-p2p'
158
+ });
159
+ sendPacket(session, 'pong', {
160
+ state: 'frame-ack',
161
+ frameSeq: Number(complete.header?.frameSeq || 0) || 0
162
+ });
163
+ });
164
+ socket.on('error', error => logWarn('udp', `UDP socket error: ${error?.message || error}`));
165
+
166
+ async function start() {
167
+ if (!enabled || started) return getStatus();
168
+ await new Promise((resolve, reject) => {
169
+ const onError = error => { socket.off('listening', onListening); reject(error); };
170
+ const onListening = () => { socket.off('error', onError); resolve(); };
171
+ socket.once('error', onError);
172
+ socket.once('listening', onListening);
173
+ socket.bind(requestedPort, bindHost);
174
+ });
175
+ started = true;
176
+ boundPort = socket.address().port;
177
+ logEvent('udp', `UDP P2P socket listening on udp://${bindHost}:${boundPort}`);
178
+ return getStatus();
179
+ }
180
+
181
+ function registerDevice({ deviceId, sessionId: tcpSessionId, sendControl, capabilities } = {}) {
182
+ if (!enabled || capabilities?.udpP2p !== true || !started) return null;
183
+ const id = safeText(deviceId, 160);
184
+ if (!id) return null;
185
+ sessions.get(id)?.sendControl?.({ type: 'udp.session.close', reason: 'replaced' });
186
+ const key = createUdpSessionKey();
187
+ const session = {
188
+ deviceId: id,
189
+ tcpSessionId: safeText(tcpSessionId, 160),
190
+ sessionId: crypto.randomUUID(),
191
+ key,
192
+ keyBase64: key.toString('base64'),
193
+ rendezvousToken: crypto.randomBytes(16).toString('hex'),
194
+ sendControl,
195
+ clientEndpoint: null,
196
+ peerEndpoint: null,
197
+ replay: new UdpReplayWindow(),
198
+ reassembler: new UdpFrameReassembler({ maxPending: maxPendingFrames, timeoutMs: frameTimeoutMs }),
199
+ ready: false,
200
+ createdAt: Date.now(),
201
+ lastPacketAt: 0,
202
+ framesReceived: 0
203
+ };
204
+ sessions.set(id, session);
205
+ sendControl?.({
206
+ type: 'udp.session.offer',
207
+ protocol: UDP_P2P_PROTOCOL,
208
+ sessionId: session.sessionId,
209
+ key: session.keyBase64,
210
+ host: advertiseHost,
211
+ port: boundPort,
212
+ expiresInMs: punchTimeoutMs + 15_000,
213
+ punchTimeoutMs,
214
+ maxDatagramBytes: UDP_MAX_DATAGRAM_BYTES,
215
+ frameTimeoutMs,
216
+ maxPendingFrames,
217
+ rendezvous: rendezvousHost
218
+ ? { host: rendezvousHost, port: rendezvousPort, roomId: session.sessionId, token: session.rendezvousToken }
219
+ : null
220
+ });
221
+ sendRendezvousRegister(session);
222
+ emit('UdpSessionCreated', { deviceId: id, state: 'udp-session-created' });
223
+ return { sessionId: session.sessionId, ready: false };
224
+ }
225
+
226
+ function unregisterDevice(deviceId, tcpSessionId = '') {
227
+ const id = safeText(deviceId, 160);
228
+ const session = sessions.get(id);
229
+ if (!session || (tcpSessionId && session.tcpSessionId !== tcpSessionId)) return;
230
+ sessions.delete(id);
231
+ emit('UdpSessionClosed', { deviceId: id, reason: 'tcp-session-closed' });
232
+ }
233
+
234
+ function getDeviceStatus(deviceId) {
235
+ const session = sessions.get(safeText(deviceId, 160));
236
+ if (!session) return { enabled, state: enabled ? 'disabled' : 'tcp-only', ready: false };
237
+ return {
238
+ enabled,
239
+ preferred: preferP2p,
240
+ state: session.ready ? 'udp-ready' : 'udp-session-created',
241
+ ready: session.ready,
242
+ sessionId: session.sessionId,
243
+ lastPacketAt: session.lastPacketAt ? new Date(session.lastPacketAt).toISOString() : '',
244
+ framesReceived: session.framesReceived
245
+ };
246
+ }
247
+
248
+ async function close() {
249
+ sessions.clear();
250
+ if (!started) return;
251
+ await new Promise(resolve => socket.close(() => resolve()));
252
+ started = false;
253
+ }
254
+
255
+ return {
256
+ start,
257
+ close,
258
+ registerDevice,
259
+ unregisterDevice,
260
+ getDeviceStatus,
261
+ getStatus,
262
+ setFrameHandler: handler => { frameHandler = typeof handler === 'function' ? handler : () => {}; },
263
+ setEventHandler: handler => { eventHandler = typeof handler === 'function' ? handler : () => {}; }
264
+ };
265
+
266
+ function getStatus() {
267
+ return {
268
+ enabled,
269
+ preferred: preferP2p,
270
+ started,
271
+ bindHost,
272
+ port: boundPort,
273
+ advertiseHost,
274
+ rendezvousHost,
275
+ rendezvousPort,
276
+ maxDatagramBytes: UDP_MAX_DATAGRAM_BYTES,
277
+ sessionCount: sessions.size,
278
+ readySessionCount: [...sessions.values()].filter(session => session.ready).length
279
+ };
280
+ }
281
+ }
@@ -0,0 +1,216 @@
1
+ import crypto from 'node:crypto';
2
+
3
+ export const UDP_P2P_PROTOCOL = 'livedesk.udp.p2p.v1';
4
+ export const UDP_P2P_VERSION = 1;
5
+ export const UDP_MAX_DATAGRAM_BYTES = 1200;
6
+ export const UDP_PACKET_TYPES = Object.freeze({
7
+ probe: 1,
8
+ 'probe-ack': 2,
9
+ ready: 3,
10
+ ping: 4,
11
+ pong: 5,
12
+ frame: 6,
13
+ close: 7
14
+ });
15
+ const UDP_PACKET_TYPE_NAMES = new Map(Object.entries(UDP_PACKET_TYPES).map(([name, value]) => [value, name]));
16
+ const UDP_FIXED_HEADER_BYTES = 15;
17
+ const UDP_NONCE_BYTES = 12;
18
+ const UDP_TAG_BYTES = 16;
19
+ const UDP_MAX_SESSION_ID_BYTES = 96;
20
+ const UDP_MAX_HEADER_BYTES = 4096;
21
+ const UDP_MAX_PAYLOAD_BYTES = UDP_MAX_DATAGRAM_BYTES - UDP_FIXED_HEADER_BYTES - UDP_NONCE_BYTES - UDP_TAG_BYTES - 1;
22
+
23
+ function asBuffer(value) {
24
+ if (Buffer.isBuffer(value)) return value;
25
+ if (value instanceof Uint8Array) return Buffer.from(value);
26
+ if (typeof value === 'string') return Buffer.from(value, 'utf8');
27
+ return Buffer.alloc(0);
28
+ }
29
+
30
+ function normalizeKey(key) {
31
+ const value = asBuffer(key);
32
+ return value.length === 32 ? value : crypto.createHash('sha256').update(value).digest();
33
+ }
34
+
35
+ function normalizeSessionId(value) {
36
+ const sessionId = String(value || '').trim();
37
+ if (!sessionId || Buffer.byteLength(sessionId, 'utf8') > UDP_MAX_SESSION_ID_BYTES) {
38
+ throw new Error('invalid-udp-session-id');
39
+ }
40
+ return sessionId;
41
+ }
42
+
43
+ function packetTypeCode(type) {
44
+ const code = typeof type === 'number' ? type : UDP_PACKET_TYPES[String(type || '').toLowerCase()];
45
+ if (!code || !UDP_PACKET_TYPE_NAMES.has(code)) throw new Error('invalid-udp-packet-type');
46
+ return code;
47
+ }
48
+
49
+ function encodeHeader(header) {
50
+ const bytes = Buffer.from(JSON.stringify(header && typeof header === 'object' ? header : {}), 'utf8');
51
+ if (bytes.length > UDP_MAX_HEADER_BYTES) throw new Error('udp-packet-header-too-large');
52
+ return bytes;
53
+ }
54
+
55
+ export function deriveUdpSessionKey(keyMaterial) {
56
+ return normalizeKey(keyMaterial);
57
+ }
58
+
59
+ export function createUdpSessionKey() {
60
+ return crypto.randomBytes(32);
61
+ }
62
+
63
+ export function encodeUdpPacket({ type, sessionId, key, sequence = 0, header = {}, payload = Buffer.alloc(0) }) {
64
+ const normalizedSessionId = normalizeSessionId(sessionId);
65
+ const sessionBytes = Buffer.from(normalizedSessionId, 'utf8');
66
+ const headerBytes = encodeHeader(header);
67
+ const payloadBytes = asBuffer(payload);
68
+ if (payloadBytes.length > UDP_MAX_PAYLOAD_BYTES) throw new Error('udp-packet-payload-too-large');
69
+
70
+ const preamble = Buffer.allocUnsafe(UDP_FIXED_HEADER_BYTES);
71
+ UDP_P2P_PROTOCOL.startsWith('livedesk') && Buffer.from('LDU1', 'ascii').copy(preamble, 0);
72
+ preamble[4] = UDP_P2P_VERSION;
73
+ preamble[5] = packetTypeCode(type);
74
+ preamble[6] = sessionBytes.length;
75
+ preamble.writeUInt16BE(headerBytes.length, 7);
76
+ preamble.writeUInt16BE(payloadBytes.length, 9);
77
+ preamble.writeUInt32BE((Number(sequence) >>> 0), 11);
78
+ const nonce = crypto.randomBytes(UDP_NONCE_BYTES);
79
+ const associatedData = Buffer.concat([preamble, nonce, sessionBytes, headerBytes]);
80
+ const cipher = crypto.createCipheriv('aes-256-gcm', normalizeKey(key), nonce);
81
+ cipher.setAAD(associatedData);
82
+ const encrypted = Buffer.concat([cipher.update(payloadBytes), cipher.final()]);
83
+ const tag = cipher.getAuthTag();
84
+ const packet = Buffer.concat([associatedData, encrypted, tag]);
85
+ if (packet.length > UDP_MAX_DATAGRAM_BYTES) throw new Error('udp-datagram-too-large');
86
+ return packet;
87
+ }
88
+
89
+ export function decodeUdpPacket(packet, key) {
90
+ const bytes = asBuffer(packet);
91
+ if (bytes.length < UDP_FIXED_HEADER_BYTES + UDP_NONCE_BYTES + UDP_TAG_BYTES) return null;
92
+ if (!bytes.subarray(0, 4).equals(Buffer.from('LDU1', 'ascii')) || bytes[4] !== UDP_P2P_VERSION) return null;
93
+ const type = UDP_PACKET_TYPE_NAMES.get(bytes[5]);
94
+ const sessionLength = bytes[6];
95
+ const headerLength = bytes.readUInt16BE(7);
96
+ const payloadLength = bytes.readUInt16BE(9);
97
+ const sequence = bytes.readUInt32BE(11);
98
+ const associatedLength = UDP_FIXED_HEADER_BYTES + UDP_NONCE_BYTES + sessionLength + headerLength;
99
+ const expectedLength = associatedLength + payloadLength + UDP_TAG_BYTES;
100
+ if (!type || sessionLength < 1 || sessionLength > UDP_MAX_SESSION_ID_BYTES
101
+ || headerLength > UDP_MAX_HEADER_BYTES || payloadLength > UDP_MAX_PAYLOAD_BYTES
102
+ || expectedLength !== bytes.length) return null;
103
+
104
+ const associatedData = bytes.subarray(0, associatedLength);
105
+ const sessionStart = UDP_FIXED_HEADER_BYTES + UDP_NONCE_BYTES;
106
+ const sessionId = bytes.subarray(sessionStart, sessionStart + sessionLength).toString('utf8');
107
+ const headerStart = sessionStart + sessionLength;
108
+ let header;
109
+ try {
110
+ header = JSON.parse(bytes.subarray(headerStart, headerStart + headerLength).toString('utf8'));
111
+ } catch {
112
+ return null;
113
+ }
114
+ const nonce = bytes.subarray(UDP_FIXED_HEADER_BYTES, UDP_FIXED_HEADER_BYTES + UDP_NONCE_BYTES);
115
+ const ciphertextStart = associatedLength;
116
+ const ciphertext = bytes.subarray(ciphertextStart, ciphertextStart + payloadLength);
117
+ const tag = bytes.subarray(ciphertextStart + payloadLength);
118
+ try {
119
+ const decipher = crypto.createDecipheriv('aes-256-gcm', normalizeKey(key), nonce);
120
+ decipher.setAAD(associatedData);
121
+ decipher.setAuthTag(tag);
122
+ const payload = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
123
+ return { type, sessionId, sequence, header, payload };
124
+ } catch {
125
+ return null;
126
+ }
127
+ }
128
+
129
+ export class UdpReplayWindow {
130
+ constructor(size = 512) {
131
+ this.size = Math.max(32, Math.min(4096, Math.floor(Number(size) || 512)));
132
+ this.highest = -1;
133
+ this.seen = new Set();
134
+ }
135
+
136
+ accept(sequence) {
137
+ const value = Number(sequence) >>> 0;
138
+ if (this.highest >= 0 && value + this.size < this.highest) return false;
139
+ if (this.seen.has(value)) return false;
140
+ this.seen.add(value);
141
+ if (this.highest < 0 || value > this.highest) this.highest = value;
142
+ const minimum = Math.max(0, this.highest - this.size);
143
+ for (const item of this.seen) {
144
+ if (item < minimum) this.seen.delete(item);
145
+ }
146
+ return true;
147
+ }
148
+ }
149
+
150
+ export class UdpFrameReassembler {
151
+ constructor({ maxPending = 2, timeoutMs = 80, maxFrameBytes = 8 * 1024 * 1024 } = {}) {
152
+ this.maxPending = Math.max(1, Math.min(16, Math.floor(Number(maxPending) || 2)));
153
+ this.timeoutMs = Math.max(20, Math.min(2000, Math.floor(Number(timeoutMs) || 80)));
154
+ this.maxFrameBytes = Math.max(64 * 1024, Math.min(16 * 1024 * 1024, Math.floor(Number(maxFrameBytes) || 8 * 1024 * 1024)));
155
+ this.pending = new Map();
156
+ }
157
+
158
+ ingest(packet, now = Date.now()) {
159
+ if (!packet || packet.type !== 'frame') return null;
160
+ this.expire(now);
161
+ const header = packet.header && typeof packet.header === 'object' ? packet.header : {};
162
+ const frameId = String(header.frameId || '').slice(0, 160);
163
+ const chunkIndex = Number(header.chunkIndex);
164
+ const chunkCount = Number(header.chunkCount);
165
+ if (!frameId || !Number.isInteger(chunkIndex) || !Number.isInteger(chunkCount)
166
+ || chunkIndex < 0 || chunkCount < 1 || chunkCount > 4096 || chunkIndex >= chunkCount) return null;
167
+ let state = this.pending.get(frameId);
168
+ if (!state) {
169
+ while (this.pending.size >= this.maxPending) {
170
+ const oldest = this.pending.keys().next().value;
171
+ if (oldest === undefined) break;
172
+ this.pending.delete(oldest);
173
+ }
174
+ state = { createdAt: now, chunkCount, chunks: new Array(chunkCount), received: 0, totalBytes: 0, frameHeader: null };
175
+ this.pending.set(frameId, state);
176
+ }
177
+ if (state.chunkCount !== chunkCount || state.chunks[chunkIndex]) return null;
178
+ const payload = asBuffer(packet.payload);
179
+ state.chunks[chunkIndex] = payload;
180
+ state.received += 1;
181
+ state.totalBytes += payload.length;
182
+ if (header.frameHeader && typeof header.frameHeader === 'object') state.frameHeader = header.frameHeader;
183
+ if (state.totalBytes > this.maxFrameBytes) {
184
+ this.pending.delete(frameId);
185
+ return null;
186
+ }
187
+ if (state.received !== state.chunkCount) return null;
188
+ this.pending.delete(frameId);
189
+ return {
190
+ frameId,
191
+ header: state.frameHeader || header.frameHeader || {},
192
+ payload: Buffer.concat(state.chunks, state.totalBytes)
193
+ };
194
+ }
195
+
196
+ expire(now = Date.now()) {
197
+ for (const [frameId, state] of this.pending) {
198
+ if (now - state.createdAt > this.timeoutMs) this.pending.delete(frameId);
199
+ }
200
+ }
201
+ }
202
+
203
+ export function encodeRendezvousMessage(message) {
204
+ const payload = Buffer.from(JSON.stringify(message && typeof message === 'object' ? message : {}), 'utf8');
205
+ if (payload.length > UDP_MAX_DATAGRAM_BYTES) throw new Error('rendezvous-message-too-large');
206
+ return payload;
207
+ }
208
+
209
+ export function decodeRendezvousMessage(payload) {
210
+ try {
211
+ const value = JSON.parse(asBuffer(payload).toString('utf8'));
212
+ return value && typeof value === 'object' ? value : null;
213
+ } catch {
214
+ return null;
215
+ }
216
+ }
@@ -0,0 +1,78 @@
1
+ import dgram from 'node:dgram';
2
+ import { decodeRendezvousMessage, encodeRendezvousMessage, UDP_MAX_DATAGRAM_BYTES } from './udp-protocol.js';
3
+
4
+ function safeText(value, max = 160) {
5
+ return String(value || '').replace(/[\r\n\t]/g, ' ').trim().slice(0, max);
6
+ }
7
+
8
+ function normalizePort(value, fallback) {
9
+ const port = Number(value);
10
+ return Number.isInteger(port) && port >= 0 && port <= 65535 ? port : fallback;
11
+ }
12
+
13
+ export function createUdpRendezvousServer({ host = '0.0.0.0', port = 5199, log = () => {} } = {}) {
14
+ const socket = dgram.createSocket('udp4');
15
+ const rooms = new Map();
16
+ let started = false;
17
+ let boundPort = normalizePort(port, 5199);
18
+
19
+ function send(message, address, targetPort) {
20
+ const payload = encodeRendezvousMessage(message);
21
+ socket.send(payload, targetPort, address);
22
+ }
23
+
24
+ function onMessage(payload, rinfo) {
25
+ if (!Buffer.isBuffer(payload) || payload.length > UDP_MAX_DATAGRAM_BYTES) return;
26
+ const message = decodeRendezvousMessage(payload);
27
+ if (message?.type !== 'rendezvous.register') return;
28
+ const roomId = safeText(message.roomId, 160);
29
+ const role = safeText(message.role, 16).toLowerCase();
30
+ const token = safeText(message.token, 256);
31
+ if (!roomId || !token || !['hub', 'client'].includes(role)) return;
32
+ let room = rooms.get(roomId);
33
+ if (!room || room.token !== token || Date.now() - room.updatedAt > 60_000) {
34
+ room = { token, peers: new Map(), updatedAt: Date.now() };
35
+ rooms.set(roomId, room);
36
+ }
37
+ room.updatedAt = Date.now();
38
+ room.peers.set(role, { address: rinfo.address, port: rinfo.port });
39
+ const peers = [...room.peers.entries()];
40
+ if (peers.length < 2) return;
41
+ for (const [peerRole, peer] of peers) {
42
+ const other = peers.find(([candidateRole]) => candidateRole !== peerRole)?.[1];
43
+ if (!other) continue;
44
+ send({ type: 'rendezvous.peer', roomId, role: peerRole === 'hub' ? 'client' : 'hub', host: other.address, port: other.port }, peer.address, peer.port);
45
+ }
46
+ }
47
+
48
+ socket.on('message', onMessage);
49
+ socket.on('error', error => log(`UDP rendezvous error: ${error?.message || error}`));
50
+
51
+ async function start() {
52
+ if (started) return { host, port: boundPort };
53
+ await new Promise((resolve, reject) => {
54
+ const onError = error => { socket.off('listening', onListening); reject(error); };
55
+ const onListening = () => { socket.off('error', onError); resolve(); };
56
+ socket.once('error', onError);
57
+ socket.once('listening', onListening);
58
+ socket.bind(boundPort, host);
59
+ });
60
+ started = true;
61
+ boundPort = socket.address().port;
62
+ log(`UDP rendezvous listening on udp://${host}:${boundPort}`);
63
+ return { host, port: boundPort };
64
+ }
65
+
66
+ async function close() {
67
+ rooms.clear();
68
+ if (!started) return;
69
+ await new Promise(resolve => socket.close(() => resolve()));
70
+ started = false;
71
+ }
72
+
73
+ return {
74
+ start,
75
+ close,
76
+ getStatus: () => ({ started, host, port: boundPort, rooms: rooms.size })
77
+ };
78
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "livedesk",
3
- "version": "0.1.284",
3
+ "version": "0.1.285",
4
4
  "description": "LiveDesk Hub and client launcher",
5
5
  "type": "module",
6
6
  "bin": {
@@ -31,7 +31,7 @@
31
31
  },
32
32
  "dependencies": {
33
33
  "@ffmpeg-installer/ffmpeg": "^1.1.0",
34
- "@livedesk/client": "0.1.142",
34
+ "@livedesk/client": "0.1.143",
35
35
  "@openai/codex-sdk": "0.144.5",
36
36
  "cors": "^2.8.5",
37
37
  "express": "^4.21.2",