@livedesk/hub 0.1.11 → 0.1.13

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.11",
3
+ "version": "0.1.13",
4
4
  "description": "LiveDesk local Hub API and browser frame bridge",
5
5
  "type": "module",
6
6
  "main": "src/server.js",
package/src/remote-hub.js CHANGED
@@ -3881,6 +3881,9 @@ export function createRemoteHub(options = {}) {
3881
3881
  nativeEncodableFrameCount: Number.isFinite(Number(message.nativeEncodableFrameCount)) ? Number(message.nativeEncodableFrameCount) : 0,
3882
3882
  nativeSkippedFrameCount: Number.isFinite(Number(message.nativeSkippedFrameCount)) ? Number(message.nativeSkippedFrameCount) : 0,
3883
3883
  nativeEncodedFrameCount: Number.isFinite(Number(message.nativeEncodedFrameCount)) ? Number(message.nativeEncodedFrameCount) : 0,
3884
+ nativeEncodeFramesInFlight: Number.isFinite(Number(message.nativeEncodeFramesInFlight)) ? Number(message.nativeEncodeFramesInFlight) : 0,
3885
+ nativeBackpressureSkippedFrameCount: Number.isFinite(Number(message.nativeBackpressureSkippedFrameCount)) ? Number(message.nativeBackpressureSkippedFrameCount) : 0,
3886
+ nativeIdleSkippedFrameCount: Number.isFinite(Number(message.nativeIdleSkippedFrameCount)) ? Number(message.nativeIdleSkippedFrameCount) : 0,
3884
3887
  nativeCaptureTelemetryAt: safeString(message.nativeCaptureTelemetryAt, 80),
3885
3888
  droppedByAgent: Number.isFinite(Number(message.droppedByAgent)) ? Number(message.droppedByAgent) : 0,
3886
3889
  hardwareEncoder: safeString(message.hardwareEncoder, 80),
@@ -4258,6 +4261,9 @@ export function createRemoteHub(options = {}) {
4258
4261
  nativeEncodableFrameCount: Number.isFinite(Number(message.nativeEncodableFrameCount)) ? Number(message.nativeEncodableFrameCount) : 0,
4259
4262
  nativeSkippedFrameCount: Number.isFinite(Number(message.nativeSkippedFrameCount)) ? Number(message.nativeSkippedFrameCount) : 0,
4260
4263
  nativeEncodedFrameCount: Number.isFinite(Number(message.nativeEncodedFrameCount)) ? Number(message.nativeEncodedFrameCount) : 0,
4264
+ nativeEncodeFramesInFlight: Number.isFinite(Number(message.nativeEncodeFramesInFlight)) ? Number(message.nativeEncodeFramesInFlight) : 0,
4265
+ nativeBackpressureSkippedFrameCount: Number.isFinite(Number(message.nativeBackpressureSkippedFrameCount)) ? Number(message.nativeBackpressureSkippedFrameCount) : 0,
4266
+ nativeIdleSkippedFrameCount: Number.isFinite(Number(message.nativeIdleSkippedFrameCount)) ? Number(message.nativeIdleSkippedFrameCount) : 0,
4261
4267
  nativeCaptureTelemetryAt: safeString(message.nativeCaptureTelemetryAt, 80),
4262
4268
  droppedByAgent: Number.isFinite(Number(message.droppedByAgent)) ? Number(message.droppedByAgent) : 0,
4263
4269
  hubIngressDropped: Number.isFinite(Number(message.hubIngressDropped)) ? Number(message.hubIngressDropped) : 0,
package/src/server.js CHANGED
@@ -2070,6 +2070,9 @@ function buildRemoteFrameBinaryPacket(frameEvent, diagnostics = {}) {
2070
2070
  nativeEncodableFrameCount: Number(frame.nativeEncodableFrameCount || 0) || 0,
2071
2071
  nativeSkippedFrameCount: Number(frame.nativeSkippedFrameCount || 0) || 0,
2072
2072
  nativeEncodedFrameCount: Number(frame.nativeEncodedFrameCount || 0) || 0,
2073
+ nativeEncodeFramesInFlight: Number(frame.nativeEncodeFramesInFlight || 0) || 0,
2074
+ nativeBackpressureSkippedFrameCount: Number(frame.nativeBackpressureSkippedFrameCount || 0) || 0,
2075
+ nativeIdleSkippedFrameCount: Number(frame.nativeIdleSkippedFrameCount || 0) || 0,
2073
2076
  nativeCaptureTelemetryAt: frame.nativeCaptureTelemetryAt || '',
2074
2077
  droppedByAgent: Number(frame.droppedByAgent || 0) || 0,
2075
2078
  hubIngressDropped: Number(frame.hubIngressDropped || 0) || 0,
@@ -1,5 +1,6 @@
1
- import crypto from 'node:crypto';
2
- import dgram from 'node:dgram';
1
+ import crypto from 'node:crypto';
2
+ import { lookup } from 'node:dns/promises';
3
+ import dgram from 'node:dgram';
3
4
  import {
4
5
  UDP_MAX_DATAGRAM_BYTES,
5
6
  UDP_P2P_PROTOCOL,
@@ -26,10 +27,15 @@ function numberEnv(value, min, max, fallback) {
26
27
  return Number.isFinite(number) ? Math.max(min, Math.min(max, Math.floor(number))) : fallback;
27
28
  }
28
29
 
29
- function sameEndpoint(left, right) {
30
- return !!left && !!right && String(left.address) === String(right.address) && Number(left.port) === Number(right.port);
31
- }
32
-
30
+ function sameEndpoint(left, right) {
31
+ return !!left && !!right && String(left.address) === String(right.address) && Number(left.port) === Number(right.port);
32
+ }
33
+
34
+ function normalizedAddress(value) {
35
+ const address = safeText(value, 128).toLowerCase();
36
+ return address.startsWith('::ffff:') ? address.slice(7) : address;
37
+ }
38
+
33
39
  export function createHubUdpTransport({ env = process.env, logEvent = () => {}, logWarn = () => {} } = {}) {
34
40
  const enabled = isEnabled(env.LIVEDESK_UDP_ENABLED, true);
35
41
  const preferP2p = isEnabled(env.LIVEDESK_UDP_PREFER_P2P, false);
@@ -37,7 +43,8 @@ export function createHubUdpTransport({ env = process.env, logEvent = () => {},
37
43
  const requestedPort = numberEnv(env.LIVEDESK_UDP_PORT, 0, 65535, 0);
38
44
  const advertiseHost = safeText(env.LIVEDESK_UDP_ADVERTISE_HOST || env.LIVEDESK_REMOTE_PUBLIC_HOST || '', 128);
39
45
  const rendezvousHost = safeText(env.LIVEDESK_UDP_RENDEZVOUS_HOST || 'www.gnsoftech.com', 128);
40
- const rendezvousPort = numberEnv(env.LIVEDESK_UDP_RENDEZVOUS_PORT, 1, 65535, 5199);
46
+ const rendezvousPort = numberEnv(env.LIVEDESK_UDP_RENDEZVOUS_PORT, 1, 65535, 5199);
47
+ const rendezvousRefreshMs = numberEnv(env.LIVEDESK_UDP_RENDEZVOUS_REFRESH_MS, 1000, 50_000, 25_000);
41
48
  const punchTimeoutMs = numberEnv(env.LIVEDESK_UDP_PUNCH_TIMEOUT_MS, 500, 10_000, 3000);
42
49
  const frameTimeoutMs = numberEnv(env.LIVEDESK_UDP_FRAME_TIMEOUT_MS, 20, 2000, 500);
43
50
  const maxPendingFrames = numberEnv(env.LIVEDESK_UDP_MAX_PENDING_FRAMES, 1, 16, 2);
@@ -46,9 +53,12 @@ export function createHubUdpTransport({ env = process.env, logEvent = () => {},
46
53
  const sessions = new Map();
47
54
  let frameHandler = () => {};
48
55
  let eventHandler = () => {};
49
- let started = false;
50
- let boundPort = requestedPort;
51
- let sequence = crypto.randomInt(1, 0x7fffffff);
56
+ let started = false;
57
+ let boundPort = requestedPort;
58
+ let sequence = crypto.randomInt(1, 0x7fffffff);
59
+ let rendezvousAddresses = new Set();
60
+ let rendezvousRefreshTimer = null;
61
+ let rendezvousRefreshPromise = null;
52
62
 
53
63
  function emit(type, event = {}) {
54
64
  eventHandler(type, event);
@@ -60,8 +70,8 @@ export function createHubUdpTransport({ env = process.env, logEvent = () => {},
60
70
  return sequence;
61
71
  }
62
72
 
63
- function sendPacket(session, type, header = {}, payload = Buffer.alloc(0), address = session.peerEndpoint || session.clientEndpoint) {
64
- if (!address || !session) return false;
73
+ function sendPacket(session, type, header = {}, payload = Buffer.alloc(0), address = session.clientEndpoint || session.peerEndpoint) {
74
+ if (!address || !session) return false;
65
75
  let packet;
66
76
  try {
67
77
  packet = encodeUdpPacket({ type, sessionId: session.sessionId, key: session.key, sequence: nextSequence(), header, payload });
@@ -73,34 +83,105 @@ export function createHubUdpTransport({ env = process.env, logEvent = () => {},
73
83
  return true;
74
84
  }
75
85
 
76
- function sendRendezvousRegister(session) {
77
- if (!rendezvousHost) return;
78
- const message = encodeRendezvousMessage({
79
- type: 'rendezvous.register',
86
+ function sendRendezvousRegister(session) {
87
+ if (!rendezvousHost || rendezvousAddresses.size === 0 || !started) return false;
88
+ const message = encodeRendezvousMessage({
89
+ type: 'rendezvous.register',
80
90
  protocol: UDP_P2P_PROTOCOL,
81
91
  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 || Number(rinfo.port) !== rendezvousPort) return;
103
- const address = safeText(message.host, 128);
92
+ role: 'hub',
93
+ token: session.rendezvousToken
94
+ });
95
+ let sent = false;
96
+ for (const address of rendezvousAddresses) {
97
+ try {
98
+ socket.send(message, rendezvousPort, address, error => {
99
+ if (error) logWarn('udp', `UDP rendezvous registration failed: ${error?.message || error}`);
100
+ });
101
+ sent = true;
102
+ } catch (error) {
103
+ logWarn('udp', `UDP rendezvous registration failed: ${error?.message || error}`);
104
+ }
105
+ }
106
+ return sent;
107
+ }
108
+
109
+ async function resolveRendezvousAddresses() {
110
+ if (!rendezvousHost) return false;
111
+ try {
112
+ const records = await lookup(rendezvousHost, { all: true, family: 4, verbatim: true });
113
+ const resolved = new Set(records.map(record => normalizedAddress(record.address)).filter(Boolean));
114
+ if (resolved.size === 0) throw new Error('no-ipv4-address');
115
+ rendezvousAddresses = resolved;
116
+ return true;
117
+ } catch (error) {
118
+ logWarn('udp', `UDP rendezvous host resolution failed: ${error?.message || error}`);
119
+ return false;
120
+ }
121
+ }
122
+
123
+ function refreshRendezvousRegistrations(reason = 'periodic') {
124
+ if (!started || !rendezvousHost) return Promise.resolve(false);
125
+ if (rendezvousRefreshPromise) return rendezvousRefreshPromise;
126
+ rendezvousRefreshPromise = (async () => {
127
+ await resolveRendezvousAddresses();
128
+ if (!started) return false;
129
+ let refreshed = false;
130
+ for (const session of sessions.values()) {
131
+ refreshed = sendRendezvousRegister(session) || refreshed;
132
+ }
133
+ if (trace && refreshed) emit('UdpRendezvousRegistrationRefreshed', { reason });
134
+ return refreshed;
135
+ })().finally(() => {
136
+ rendezvousRefreshPromise = null;
137
+ });
138
+ return rendezvousRefreshPromise;
139
+ }
140
+
141
+ function startRendezvousRefreshTimer() {
142
+ if (!rendezvousHost || rendezvousRefreshTimer) return;
143
+ rendezvousRefreshTimer = setInterval(() => {
144
+ void refreshRendezvousRegistrations('periodic');
145
+ }, rendezvousRefreshMs);
146
+ rendezvousRefreshTimer.unref?.();
147
+ }
148
+
149
+ function stopRendezvousRefreshTimer() {
150
+ if (rendezvousRefreshTimer) clearInterval(rendezvousRefreshTimer);
151
+ rendezvousRefreshTimer = null;
152
+ }
153
+
154
+ function isTrustedRendezvousSource(rinfo) {
155
+ return Number(rinfo?.port) === rendezvousPort
156
+ && rendezvousAddresses.has(normalizedAddress(rinfo?.address));
157
+ }
158
+
159
+ function sessionForPacket(packet) {
160
+ if (!Buffer.isBuffer(packet) || packet.length < 7) return null;
161
+ const sessionLength = packet[6];
162
+ if (!sessionLength || packet.length < 27 + sessionLength) return null;
163
+ const sessionId = packet.subarray(27, 27 + sessionLength).toString('utf8');
164
+ return [...sessions.values()].find(item => item.sessionId === sessionId) || null;
165
+ }
166
+
167
+ function bindAuthenticatedClientEndpoint(session, rinfo) {
168
+ const endpoint = { address: rinfo.address, port: rinfo.port };
169
+ if (sameEndpoint(session.clientEndpoint, endpoint)) return false;
170
+ session.clientEndpoint = endpoint;
171
+ session.lastPeerAt = Date.now();
172
+ sendRendezvousRegister(session);
173
+ void refreshRendezvousRegistrations('authenticated-endpoint-change');
174
+ emit('UdpAuthenticatedEndpointObserved', {
175
+ deviceId: session.deviceId,
176
+ endpoint: `${endpoint.address}:${endpoint.port}`
177
+ });
178
+ return true;
179
+ }
180
+
181
+ function onRendezvousPeer(message, rinfo) {
182
+ const session = [...sessions.values()].find(item => item.sessionId === safeText(message.roomId, 160));
183
+ if (!session || message.protocol !== UDP_P2P_PROTOCOL || !isTrustedRendezvousSource(rinfo)) return;
184
+ const address = safeText(message.host, 128);
104
185
  const port = Number(message.port);
105
186
  if (!address || !Number.isInteger(port) || port < 1 || port > 65535) return;
106
187
  session.peerEndpoint = { address, port };
@@ -112,24 +193,23 @@ export function createHubUdpTransport({ env = process.env, logEvent = () => {},
112
193
  socket.on('message', (payload, rinfo) => {
113
194
  const rendezvous = decodeRendezvousMessage(payload);
114
195
  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 });
196
+ onRendezvousPeer(rendezvous, rinfo);
197
+ return;
198
+ }
199
+ const session = sessionForPacket(payload);
200
+ if (!session) return;
201
+ const packet = decodeUdpPacket(payload, session.key);
202
+ if (!packet || packet.sessionId !== session.sessionId || !session.replay.accept(packet.sequence)) return;
203
+ bindAuthenticatedClientEndpoint(session, rinfo);
204
+ session.lastPacketAt = Date.now();
205
+ if (packet.type === 'probe') {
206
+ sendPacket(session, 'probe-ack', { observedHost: rinfo.address, observedPort: rinfo.port });
126
207
  emit('UdpProbeReceived', { deviceId: session.deviceId });
127
208
  return;
128
209
  }
129
- if (packet.type === 'ready') {
130
- session.ready = true;
131
- session.clientEndpoint = { address: rinfo.address, port: rinfo.port };
132
- sendPacket(session, 'pong', { state: 'ready' });
210
+ if (packet.type === 'ready') {
211
+ session.ready = true;
212
+ sendPacket(session, 'pong', { state: 'ready' });
133
213
  session.sendControl?.({
134
214
  type: 'udp.session.ready',
135
215
  protocol: UDP_P2P_PROTOCOL,
@@ -189,7 +269,9 @@ export function createHubUdpTransport({ env = process.env, logEvent = () => {},
189
269
  logWarn('udp', `UDP socket buffer tuning was unavailable: ${error?.message || error}`);
190
270
  }
191
271
  logEvent('udp', `UDP P2P socket listening on udp://${bindHost}:${boundPort}`);
192
- return getStatus();
272
+ void refreshRendezvousRegistrations('startup');
273
+ startRendezvousRefreshTimer();
274
+ return getStatus();
193
275
  }
194
276
 
195
277
  function registerDevice({ deviceId, sessionId: tcpSessionId, sendControl, capabilities } = {}) {
@@ -260,11 +342,12 @@ export function createHubUdpTransport({ env = process.env, logEvent = () => {},
260
342
  };
261
343
  }
262
344
 
263
- async function close() {
264
- sessions.clear();
265
- if (!started) return;
266
- await new Promise(resolve => socket.close(() => resolve()));
267
- started = false;
345
+ async function close() {
346
+ stopRendezvousRefreshTimer();
347
+ sessions.clear();
348
+ if (!started) return;
349
+ started = false;
350
+ await new Promise(resolve => socket.close(() => resolve()));
268
351
  }
269
352
 
270
353
  return {
@@ -286,8 +369,10 @@ export function createHubUdpTransport({ env = process.env, logEvent = () => {},
286
369
  bindHost,
287
370
  port: boundPort,
288
371
  advertiseHost,
289
- rendezvousHost,
290
- rendezvousPort,
372
+ rendezvousHost,
373
+ rendezvousPort,
374
+ rendezvousRefreshMs,
375
+ rendezvousResolvedAddressCount: rendezvousAddresses.size,
291
376
  maxDatagramBytes: UDP_MAX_DATAGRAM_BYTES,
292
377
  sessionCount: sessions.size,
293
378
  readySessionCount: [...sessions.values()].filter(session => session.ready).length
@@ -1,78 +1,402 @@
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
- }
1
+ import dgram from 'node:dgram';
2
+ import {
3
+ decodeRendezvousMessage,
4
+ encodeRendezvousMessage,
5
+ UDP_MAX_DATAGRAM_BYTES,
6
+ UDP_P2P_PROTOCOL
7
+ } from './udp-protocol.js';
8
+
9
+ const DEFAULT_LIMITS = Object.freeze({
10
+ roomTtlMs: 60_000,
11
+ sourceTtlMs: 120_000,
12
+ sweepIntervalMs: 10_000,
13
+ statusIntervalMs: 60_000,
14
+ maxRooms: 4_096,
15
+ maxSources: 8_192,
16
+ maxRoomsPerSource: 128,
17
+ perSourceRate: 20,
18
+ perSourceBurst: 40,
19
+ globalRate: 1_000,
20
+ globalBurst: 2_000
21
+ });
22
+
23
+ function safeText(value, max = 160) {
24
+ return String(value || '').replace(/[\r\n\t]/g, ' ').trim().slice(0, max);
25
+ }
26
+
27
+ function normalizePort(value, fallback) {
28
+ const port = Number(value);
29
+ return Number.isInteger(port) && port >= 0 && port <= 65535 ? port : fallback;
30
+ }
31
+
32
+ function normalizeInteger(value, fallback, min = 1, max = Number.MAX_SAFE_INTEGER) {
33
+ const number = Number(value);
34
+ return Number.isInteger(number) && number >= min && number <= max ? number : fallback;
35
+ }
36
+
37
+ function normalizeRate(value, fallback) {
38
+ const number = Number(value);
39
+ return Number.isFinite(number) && number > 0 ? number : fallback;
40
+ }
41
+
42
+ function createTokenBucket(capacity, now) {
43
+ return { tokens: capacity, updatedAt: now };
44
+ }
45
+
46
+ function consumeToken(bucket, rate, capacity, now) {
47
+ const elapsedMs = Math.max(0, now - bucket.updatedAt);
48
+ bucket.tokens = Math.min(capacity, bucket.tokens + (elapsedMs / 1_000) * rate);
49
+ bucket.updatedAt = Math.max(bucket.updatedAt, now);
50
+ if (bucket.tokens < 1) return false;
51
+ bucket.tokens -= 1;
52
+ return true;
53
+ }
54
+
55
+ export function createUdpRendezvousServer({
56
+ host = '0.0.0.0',
57
+ port = 5199,
58
+ log = () => {},
59
+ version = 'dev',
60
+ now = () => Date.now(),
61
+ roomTtlMs = DEFAULT_LIMITS.roomTtlMs,
62
+ sourceTtlMs = DEFAULT_LIMITS.sourceTtlMs,
63
+ sweepIntervalMs = DEFAULT_LIMITS.sweepIntervalMs,
64
+ statusIntervalMs = DEFAULT_LIMITS.statusIntervalMs,
65
+ maxRooms = DEFAULT_LIMITS.maxRooms,
66
+ maxSources = DEFAULT_LIMITS.maxSources,
67
+ maxRoomsPerSource = DEFAULT_LIMITS.maxRoomsPerSource,
68
+ perSourceRate = DEFAULT_LIMITS.perSourceRate,
69
+ perSourceBurst = DEFAULT_LIMITS.perSourceBurst,
70
+ globalRate = DEFAULT_LIMITS.globalRate,
71
+ globalBurst = DEFAULT_LIMITS.globalBurst
72
+ } = {}) {
73
+ const socket = dgram.createSocket('udp4');
74
+ const rooms = new Map();
75
+ const unpairedRooms = new Map();
76
+ const sources = new Map();
77
+ const evictableSources = new Map();
78
+ const limits = Object.freeze({
79
+ roomTtlMs: normalizeInteger(roomTtlMs, DEFAULT_LIMITS.roomTtlMs),
80
+ sourceTtlMs: normalizeInteger(sourceTtlMs, DEFAULT_LIMITS.sourceTtlMs),
81
+ sweepIntervalMs: normalizeInteger(sweepIntervalMs, DEFAULT_LIMITS.sweepIntervalMs),
82
+ statusIntervalMs: normalizeInteger(statusIntervalMs, DEFAULT_LIMITS.statusIntervalMs, 0),
83
+ maxRooms: normalizeInteger(maxRooms, DEFAULT_LIMITS.maxRooms),
84
+ maxSources: normalizeInteger(maxSources, DEFAULT_LIMITS.maxSources),
85
+ maxRoomsPerSource: normalizeInteger(maxRoomsPerSource, DEFAULT_LIMITS.maxRoomsPerSource),
86
+ perSourceRate: normalizeRate(perSourceRate, DEFAULT_LIMITS.perSourceRate),
87
+ perSourceBurst: normalizeRate(perSourceBurst, DEFAULT_LIMITS.perSourceBurst),
88
+ globalRate: normalizeRate(globalRate, DEFAULT_LIMITS.globalRate),
89
+ globalBurst: normalizeRate(globalBurst, DEFAULT_LIMITS.globalBurst)
90
+ });
91
+ const counters = {
92
+ receivedDatagrams: 0,
93
+ receivedBytes: 0,
94
+ acceptedRegistrations: 0,
95
+ peerNotifications: 0,
96
+ sentBytes: 0,
97
+ sendErrors: 0,
98
+ invalidDatagrams: 0,
99
+ oversizeDatagrams: 0,
100
+ globalRateDrops: 0,
101
+ sourceRateDrops: 0,
102
+ sourceCapacityDrops: 0,
103
+ sourceRoomLimitDrops: 0,
104
+ roomCapacityDrops: 0,
105
+ tokenMismatchDrops: 0,
106
+ expiredRooms: 0,
107
+ evictedRooms: 0,
108
+ expiredSources: 0,
109
+ evictedSources: 0,
110
+ sweeps: 0,
111
+ socketErrors: 0
112
+ };
113
+ const globalBucket = createTokenBucket(limits.globalBurst, now());
114
+ const serviceVersion = safeText(version, 64) || 'dev';
115
+ let started = false;
116
+ let closing = false;
117
+ let startedAt = 0;
118
+ let boundPort = normalizePort(port, 5199);
119
+ let sweepTimer = null;
120
+ let statusTimer = null;
121
+ let lastError = '';
122
+
123
+ function sourceKey(rinfo) {
124
+ return safeText(rinfo?.address, 128);
125
+ }
126
+
127
+ function deleteRoom(roomId, reason = '') {
128
+ const room = rooms.get(roomId);
129
+ if (!room) return false;
130
+ rooms.delete(roomId);
131
+ unpairedRooms.delete(roomId);
132
+ const owner = sources.get(room.ownerSource);
133
+ if (owner) {
134
+ owner.activeRooms = Math.max(0, owner.activeRooms - 1);
135
+ if (owner.activeRooms === 0) {
136
+ evictableSources.delete(owner.key);
137
+ evictableSources.set(owner.key, owner);
138
+ }
139
+ }
140
+ if (reason === 'expired') counters.expiredRooms += 1;
141
+ if (reason === 'capacity') counters.evictedRooms += 1;
142
+ return true;
143
+ }
144
+
145
+ function sweep(at = now()) {
146
+ for (const [roomId, room] of rooms) {
147
+ if (at - room.updatedAt >= limits.roomTtlMs) deleteRoom(roomId, 'expired');
148
+ }
149
+ for (const [key, source] of sources) {
150
+ if (source.activeRooms === 0 && at - source.lastSeenAt >= limits.sourceTtlMs) {
151
+ sources.delete(key);
152
+ evictableSources.delete(key);
153
+ counters.expiredSources += 1;
154
+ }
155
+ }
156
+ counters.sweeps += 1;
157
+ return getStatus();
158
+ }
159
+
160
+ function getOrCreateSource(rinfo, at) {
161
+ const key = sourceKey(rinfo);
162
+ if (!key) return null;
163
+ let source = sources.get(key);
164
+ if (!source) {
165
+ if (sources.size >= limits.maxSources) {
166
+ const oldestEvictableSource = evictableSources.keys().next().value;
167
+ if (!oldestEvictableSource) {
168
+ counters.sourceCapacityDrops += 1;
169
+ return null;
170
+ }
171
+ evictableSources.delete(oldestEvictableSource);
172
+ sources.delete(oldestEvictableSource);
173
+ counters.evictedSources += 1;
174
+ }
175
+ source = {
176
+ key,
177
+ bucket: createTokenBucket(limits.perSourceBurst, at),
178
+ lastSeenAt: at,
179
+ activeRooms: 0
180
+ };
181
+ sources.set(key, source);
182
+ evictableSources.set(key, source);
183
+ }
184
+ source.lastSeenAt = at;
185
+ if (source.activeRooms === 0) {
186
+ evictableSources.delete(key);
187
+ evictableSources.set(key, source);
188
+ }
189
+ if (!consumeToken(source.bucket, limits.perSourceRate, limits.perSourceBurst, at)) {
190
+ counters.sourceRateDrops += 1;
191
+ return null;
192
+ }
193
+ return source;
194
+ }
195
+
196
+ function send(message, address, targetPort) {
197
+ let payload;
198
+ try {
199
+ payload = encodeRendezvousMessage(message);
200
+ } catch (error) {
201
+ counters.sendErrors += 1;
202
+ lastError = safeText(error?.message || error, 240);
203
+ return;
204
+ }
205
+ counters.peerNotifications += 1;
206
+ counters.sentBytes += payload.length;
207
+ socket.send(payload, targetPort, address, error => {
208
+ if (!error) return;
209
+ counters.sendErrors += 1;
210
+ lastError = safeText(error?.message || error, 240);
211
+ });
212
+ }
213
+
214
+ function createRoom(roomId, token, source, at) {
215
+ if (source.activeRooms >= limits.maxRoomsPerSource) {
216
+ counters.sourceRoomLimitDrops += 1;
217
+ return null;
218
+ }
219
+ if (rooms.size >= limits.maxRooms) {
220
+ const oldestUnpairedRoomId = unpairedRooms.keys().next().value;
221
+ if (oldestUnpairedRoomId) {
222
+ deleteRoom(oldestUnpairedRoomId, 'capacity');
223
+ } else {
224
+ counters.roomCapacityDrops += 1;
225
+ return null;
226
+ }
227
+ }
228
+ const room = {
229
+ token,
230
+ ownerSource: source.key,
231
+ peers: new Map(),
232
+ createdAt: at,
233
+ updatedAt: at
234
+ };
235
+ rooms.set(roomId, room);
236
+ unpairedRooms.set(roomId, room);
237
+ source.activeRooms += 1;
238
+ evictableSources.delete(source.key);
239
+ return room;
240
+ }
241
+
242
+ function onMessage(payload, rinfo) {
243
+ const at = now();
244
+ counters.receivedDatagrams += 1;
245
+ counters.receivedBytes += Buffer.isBuffer(payload) ? payload.length : 0;
246
+ if (!Buffer.isBuffer(payload)) {
247
+ counters.invalidDatagrams += 1;
248
+ return;
249
+ }
250
+ if (payload.length > UDP_MAX_DATAGRAM_BYTES) {
251
+ counters.oversizeDatagrams += 1;
252
+ return;
253
+ }
254
+ if (!consumeToken(globalBucket, limits.globalRate, limits.globalBurst, at)) {
255
+ counters.globalRateDrops += 1;
256
+ return;
257
+ }
258
+ const source = getOrCreateSource(rinfo, at);
259
+ if (!source) return;
260
+ const message = decodeRendezvousMessage(payload);
261
+ if (message?.type !== 'rendezvous.register') {
262
+ counters.invalidDatagrams += 1;
263
+ return;
264
+ }
265
+ const roomId = safeText(message.roomId, 160);
266
+ const role = safeText(message.role, 16).toLowerCase();
267
+ const token = safeText(message.token, 256);
268
+ if (!roomId || !token || !['hub', 'client'].includes(role)) {
269
+ counters.invalidDatagrams += 1;
270
+ return;
271
+ }
272
+ let room = rooms.get(roomId);
273
+ if (room && at - room.updatedAt >= limits.roomTtlMs) {
274
+ deleteRoom(roomId, 'expired');
275
+ room = null;
276
+ }
277
+ if (room && room.token !== token) {
278
+ counters.tokenMismatchDrops += 1;
279
+ return;
280
+ }
281
+ if (!room) {
282
+ room = createRoom(roomId, token, source, at);
283
+ if (!room) return;
284
+ }
285
+ room.updatedAt = at;
286
+ room.peers.set(role, { address: rinfo.address, port: rinfo.port });
287
+ counters.acceptedRegistrations += 1;
288
+ if (room.peers.size < 2) {
289
+ unpairedRooms.delete(roomId);
290
+ unpairedRooms.set(roomId, room);
291
+ return;
292
+ }
293
+ unpairedRooms.delete(roomId);
294
+ const hub = room.peers.get('hub');
295
+ const client = room.peers.get('client');
296
+ if (!hub || !client) return;
297
+ send({
298
+ type: 'rendezvous.peer',
299
+ roomId,
300
+ role: 'client',
301
+ host: client.address,
302
+ port: client.port
303
+ }, hub.address, hub.port);
304
+ send({
305
+ type: 'rendezvous.peer',
306
+ roomId,
307
+ role: 'hub',
308
+ host: hub.address,
309
+ port: hub.port
310
+ }, client.address, client.port);
311
+ }
312
+
313
+ socket.on('message', onMessage);
314
+ socket.on('error', error => {
315
+ counters.socketErrors += 1;
316
+ lastError = safeText(error?.message || error, 240);
317
+ log(`UDP rendezvous error: ${lastError}`);
318
+ });
319
+
320
+ function getStatus() {
321
+ const current = now();
322
+ return {
323
+ service: 'livedesk-udp-rendezvous',
324
+ version: serviceVersion,
325
+ protocol: UDP_P2P_PROTOCOL,
326
+ healthy: started && !closing,
327
+ started,
328
+ host,
329
+ port: boundPort,
330
+ uptimeMs: startedAt ? Math.max(0, current - startedAt) : 0,
331
+ rooms: rooms.size,
332
+ pairedRooms: rooms.size - unpairedRooms.size,
333
+ unpairedRooms: unpairedRooms.size,
334
+ sources: sources.size,
335
+ evictableSources: evictableSources.size,
336
+ limits: { ...limits },
337
+ counters: { ...counters },
338
+ lastError
339
+ };
340
+ }
341
+
342
+ function logStatus(reason) {
343
+ log(`UDP rendezvous status ${JSON.stringify({ reason, ...getStatus() })}`);
344
+ }
345
+
346
+ function startTimers() {
347
+ sweepTimer = setInterval(() => sweep(), limits.sweepIntervalMs);
348
+ sweepTimer.unref?.();
349
+ if (limits.statusIntervalMs > 0) {
350
+ statusTimer = setInterval(() => logStatus('periodic'), limits.statusIntervalMs);
351
+ statusTimer.unref?.();
352
+ }
353
+ }
354
+
355
+ function stopTimers() {
356
+ if (sweepTimer) clearInterval(sweepTimer);
357
+ if (statusTimer) clearInterval(statusTimer);
358
+ sweepTimer = null;
359
+ statusTimer = null;
360
+ }
361
+
362
+ async function start() {
363
+ if (started) return { host, port: boundPort };
364
+ await new Promise((resolve, reject) => {
365
+ const onError = error => { socket.off('listening', onListening); reject(error); };
366
+ const onListening = () => { socket.off('error', onError); resolve(); };
367
+ socket.once('error', onError);
368
+ socket.once('listening', onListening);
369
+ socket.bind(boundPort, host);
370
+ });
371
+ started = true;
372
+ closing = false;
373
+ startedAt = now();
374
+ boundPort = socket.address().port;
375
+ startTimers();
376
+ log(`UDP rendezvous listening on udp://${host}:${boundPort} version=${serviceVersion}`);
377
+ log(`UDP rendezvous limits ${JSON.stringify(limits)}`);
378
+ return { host, port: boundPort };
379
+ }
380
+
381
+ async function close() {
382
+ closing = true;
383
+ stopTimers();
384
+ rooms.clear();
385
+ unpairedRooms.clear();
386
+ sources.clear();
387
+ evictableSources.clear();
388
+ if (started) {
389
+ await new Promise(resolve => socket.close(() => resolve()));
390
+ started = false;
391
+ }
392
+ closing = false;
393
+ }
394
+
395
+ return {
396
+ start,
397
+ close,
398
+ getStatus,
399
+ sweepNow: () => sweep(),
400
+ logStatus: (reason = 'requested') => logStatus(safeText(reason, 48) || 'requested')
401
+ };
402
+ }