@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 +1 -1
- package/src/remote-hub.js +6 -0
- package/src/server.js +3 -0
- package/src/transport/udp-hub-transport.js +146 -61
- package/src/transport/udp-rendezvous.js +402 -78
package/package.json
CHANGED
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
|
|
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.
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
|
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
|
|
123
|
-
|
|
124
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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 {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
+
}
|