livedesk 0.1.440 → 0.1.442

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.
@@ -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
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "livedesk",
3
- "version": "0.1.440",
4
- "livedeskClientVersion": "0.1.195",
3
+ "version": "0.1.442",
4
+ "livedeskClientVersion": "0.1.197",
5
5
  "buildFlavor": "production",
6
6
  "description": "LiveDesk Hub and client launcher",
7
7
  "type": "module",
@@ -50,10 +50,10 @@
50
50
  "ws": "^8.18.3"
51
51
  },
52
52
  "optionalDependencies": {
53
- "@livedesk/fast-linux-x64": "0.1.402",
54
- "@livedesk/fast-osx-arm64": "0.1.402",
55
- "@livedesk/fast-osx-x64": "0.1.402",
56
- "@livedesk/fast-win-x64": "0.1.402"
53
+ "@livedesk/fast-linux-x64": "0.1.404",
54
+ "@livedesk/fast-osx-arm64": "0.1.404",
55
+ "@livedesk/fast-osx-x64": "0.1.404",
56
+ "@livedesk/fast-win-x64": "0.1.404"
57
57
  },
58
58
  "publishConfig": {
59
59
  "access": "public"