@livedesk/hub 0.1.10 → 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.10",
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
@@ -63,10 +63,11 @@ runtimeManager.emit('runtime.started', { role: runtimeRole, pid: process.pid });
63
63
  const frameBackpressureBytes = readPositiveIntegerEnv('LIVEDESK_FRAME_WS_BACKPRESSURE_BYTES', 8 * 1024 * 1024);
64
64
  // ScreenCaptureKit and the native Agent handoff can release two bounded
65
65
  // eight-frame batches together. Keep the Wall lane large enough to retain the
66
- // complete dependent H.264 burst; Control keeps its separate two-frame
67
- // low-latency lane below.
66
+ // complete dependent H.264 burst. Control is normally a single target, but its
67
+ // macOS Agent handoff can release eight dependent H.264 frames together; keep
68
+ // that complete bounded burst instead of stalling until the next recovery key.
68
69
  const frameClientQueuePackets = readPositiveIntegerEnv('LIVEDESK_FRAME_WS_QUEUE_PACKETS', 16);
69
- const controlFrameClientQueuePackets = readPositiveIntegerEnv('LIVEDESK_CONTROL_FRAME_WS_QUEUE_PACKETS', 2);
70
+ const controlFrameClientQueuePackets = readPositiveIntegerEnv('LIVEDESK_CONTROL_FRAME_WS_QUEUE_PACKETS', 8);
70
71
  const frameStreamStopGraceMs = readPositiveIntegerEnv('LIVEDESK_FRAME_STREAM_STOP_GRACE_MS', 1500);
71
72
  const mode5FrameBackpressureBytes = readPositiveIntegerEnv('LIVEDESK_MODE5_WS_BACKPRESSURE_BYTES', 2 * 1024 * 1024);
72
73
  const mode5FrameQueuePackets = readPositiveIntegerEnv('LIVEDESK_MODE5_WS_QUEUE_PACKETS', 2);
@@ -2069,6 +2070,9 @@ function buildRemoteFrameBinaryPacket(frameEvent, diagnostics = {}) {
2069
2070
  nativeEncodableFrameCount: Number(frame.nativeEncodableFrameCount || 0) || 0,
2070
2071
  nativeSkippedFrameCount: Number(frame.nativeSkippedFrameCount || 0) || 0,
2071
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,
2072
2076
  nativeCaptureTelemetryAt: frame.nativeCaptureTelemetryAt || '',
2073
2077
  droppedByAgent: Number(frame.droppedByAgent || 0) || 0,
2074
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
+ }