@livedesk/hub 0.1.11 → 0.1.12

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.12",
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,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
+ }