@livedesk/hub 0.1.6 → 0.1.8

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/src/server.js CHANGED
@@ -61,7 +61,7 @@ const runtimeManager = createHubRuntime({
61
61
  });
62
62
  runtimeManager.emit('runtime.started', { role: runtimeRole, pid: process.pid });
63
63
  const frameBackpressureBytes = readPositiveIntegerEnv('LIVEDESK_FRAME_WS_BACKPRESSURE_BYTES', 8 * 1024 * 1024);
64
- const frameClientQueuePackets = readPositiveIntegerEnv('LIVEDESK_FRAME_WS_QUEUE_PACKETS', 4);
64
+ const frameClientQueuePackets = readPositiveIntegerEnv('LIVEDESK_FRAME_WS_QUEUE_PACKETS', 8);
65
65
  const controlFrameClientQueuePackets = readPositiveIntegerEnv('LIVEDESK_CONTROL_FRAME_WS_QUEUE_PACKETS', 2);
66
66
  const frameStreamStopGraceMs = readPositiveIntegerEnv('LIVEDESK_FRAME_STREAM_STOP_GRACE_MS', 1500);
67
67
  const mode5FrameBackpressureBytes = readPositiveIntegerEnv('LIVEDESK_MODE5_WS_BACKPRESSURE_BYTES', 2 * 1024 * 1024);
@@ -170,19 +170,27 @@ function readPositiveIntegerEnv(name, fallback) {
170
170
  function handleRemoteHubEvent(type, event) {
171
171
  liveDeskUpdateManager?.handleRemoteEvent(type, event);
172
172
  hubTransferJobs?.handleRemoteEvent(type, event);
173
- if (type === 'RemoteInputApplied') {
174
- for (const ws of inputClients) {
175
- sendJson(ws, {
176
- type: 'RemoteInputApplied',
177
- ...event
173
+ if (type === 'RemoteInputApplied') {
174
+ const targetConnectionId = String(event?.hubConnectionId || '').trim();
175
+ for (const ws of inputClients) {
176
+ if (targetConnectionId && ws.liveDeskInputClientId !== targetConnectionId) {
177
+ continue;
178
+ }
179
+ sendJson(ws, {
180
+ type: 'RemoteInputApplied',
181
+ ...event
178
182
  });
179
183
  }
180
184
  return;
181
- }
185
+ }
182
186
  if (type === 'RemoteInputError') {
183
- for (const ws of inputClients) {
184
- sendJson(ws, {
185
- type: 'RemoteInputError',
187
+ const targetConnectionId = String(event?.hubConnectionId || '').trim();
188
+ for (const ws of inputClients) {
189
+ if (targetConnectionId && ws.liveDeskInputClientId !== targetConnectionId) {
190
+ continue;
191
+ }
192
+ sendJson(ws, {
193
+ type: 'RemoteInputError',
186
194
  ...event
187
195
  });
188
196
  }
@@ -1813,11 +1821,21 @@ function enqueueFramePacketForClient(ws, packet, meta) {
1813
1821
  ws.liveDeskFrameBackpressureDrops = Number(ws.liveDeskFrameBackpressureDrops || 0) + 1;
1814
1822
  }
1815
1823
  }
1816
- while (lane.queue.length >= frameClientQueueLimit(ws)) {
1817
- dropQueuedFrameForLane(lane);
1818
- ws.liveDeskFrameBackpressureDrops = Number(ws.liveDeskFrameBackpressureDrops || 0) + 1;
1819
- }
1820
- lane.queue.push({
1824
+ while (lane.queue.length >= frameClientQueueLimit(ws)) {
1825
+ dropQueuedFrameForLane(lane);
1826
+ ws.liveDeskFrameBackpressureDrops = Number(ws.liveDeskFrameBackpressureDrops || 0) + 1;
1827
+ }
1828
+ // Queue eviction can put this H.264 device into key-frame recovery after
1829
+ // the pre-overflow check above. Never enqueue the current dependent delta
1830
+ // while the decoder is waiting for a fresh recovery key frame.
1831
+ if (meta.isH264 && meta.isKeyFrame) {
1832
+ lane.awaitingKeyFrames.delete(meta.deviceId);
1833
+ } else if (meta.isH264 && lane.awaitingKeyFrames.has(meta.deviceId)) {
1834
+ lane.dropped += 1;
1835
+ ws.liveDeskFrameBackpressureDrops = Number(ws.liveDeskFrameBackpressureDrops || 0) + 1;
1836
+ return false;
1837
+ }
1838
+ lane.queue.push({
1821
1839
  packet,
1822
1840
  deviceId: meta.deviceId,
1823
1841
  frameSeq: meta.frameSeq,
@@ -2035,18 +2053,41 @@ function buildRemoteFrameBinaryPacket(frameEvent, diagnostics = {}) {
2035
2053
  agentPaceMs: Number(frame.agentPaceMs || 0) || 0,
2036
2054
  agentQueueBeforePaceMs: Number(frame.agentQueueBeforePaceMs || 0) || 0,
2037
2055
  agentFrameAgeMs: Number(frame.agentFrameAgeMs || 0) || 0,
2038
- droppedByAgent: Number(frame.droppedByAgent || 0) || 0,
2039
- hubIngressDropped: Number(frame.hubIngressDropped || 0) || 0,
2040
- hubClientDropped: Number(diagnostics.hubClientDropped || 0) || 0,
2041
- hardwareEncoder: frame.hardwareEncoder || '',
2056
+ droppedByAgent: Number(frame.droppedByAgent || 0) || 0,
2057
+ hubIngressDropped: Number(frame.hubIngressDropped || 0) || 0,
2058
+ hubClientDropped: Number(diagnostics.hubClientDropped || 0) || 0,
2059
+ hubClientQueueDepth: Number(diagnostics.hubClientQueueDepth || 0) || 0,
2060
+ hubClientQueueLimit: Number(diagnostics.hubClientQueueLimit || 0) || 0,
2061
+ hubClientBufferedBytes: Number(diagnostics.hubClientBufferedBytes || 0) || 0,
2062
+ udpIncompleteFrames: Number(frame.udpIncompleteFrames || 0) || 0,
2063
+ transport: frame.transport || '',
2064
+ videoTransport: frame.videoTransport || (frame.transport === 'udp-p2p'
2065
+ ? 'p2p-udp'
2066
+ : frame.transport === 'ws-binary'
2067
+ ? 'direct-ws'
2068
+ : 'direct-tcp'),
2069
+ frameTransportActive: frame.frameTransportActive || '',
2070
+ frameTransportProtocol: frame.frameTransportProtocol || '',
2071
+ frameTransportState: frame.frameTransportState || '',
2072
+ frameTransportP2pReady: frame.frameTransportP2pReady === true,
2073
+ frameTransportReason: frame.frameTransportReason || '',
2074
+ frameTransportChangedAt: frame.frameTransportChangedAt || '',
2075
+ frameTransportSwitchCount: Number(frame.frameTransportSwitchCount || 0) || 0,
2076
+ frameTransportTcpFailureCount: Number(frame.frameTransportTcpFailureCount || 0) || 0,
2077
+ hardwareEncoder: frame.hardwareEncoder || '',
2042
2078
  platformProfile: frame.platformProfile || '',
2043
2079
  monitorIndex: Number.isFinite(Number(frame.monitorIndex)) ? Number(frame.monitorIndex) : 0,
2044
2080
  monitorCount: Number.isFinite(Number(frame.monitorCount)) ? Number(frame.monitorCount) : 1,
2045
2081
  fps: Number(frame.fps || 0) || 0,
2046
2082
  requestedFps: Number(frame.requestedFps || 0) || 0,
2047
2083
  effectiveFps: Number(frame.effectiveFps || 0) || 0,
2048
- captureAverageMs: Number(frame.captureAverageMs || 0) || 0,
2049
- captureP95Ms: Number(frame.captureP95Ms || 0) || 0,
2084
+ captureTimingAvailable: frame.captureTimingAvailable === true,
2085
+ captureAverageMs: frame.captureTimingAvailable === true && Number.isFinite(Number(frame.captureAverageMs))
2086
+ ? Number(frame.captureAverageMs)
2087
+ : null,
2088
+ captureP95Ms: frame.captureTimingAvailable === true && Number.isFinite(Number(frame.captureP95Ms))
2089
+ ? Number(frame.captureP95Ms)
2090
+ : null,
2050
2091
  slowFrameCount: Number(frame.slowFrameCount || 0) || 0,
2051
2092
  captureHelperRestarts: Number(frame.captureHelperRestarts || 0) || 0,
2052
2093
  capturedAt: frame.capturedAt || '',
@@ -2055,8 +2096,12 @@ function buildRemoteFrameBinaryPacket(frameEvent, diagnostics = {}) {
2055
2096
  hubPacketEpochMs,
2056
2097
  byteLength: Number(frameEvent.byteLength || payload.length) || payload.length,
2057
2098
  contentHash: frame.contentHash || '',
2058
- captureMs: Number(frame.captureMs || 0) || 0,
2059
- captureStageMs: Number(frame.captureStageMs || 0) || 0,
2099
+ captureMs: frame.captureTimingAvailable === true && Number.isFinite(Number(frame.captureMs))
2100
+ ? Number(frame.captureMs)
2101
+ : null,
2102
+ captureStageMs: frame.captureTimingAvailable === true && Number.isFinite(Number(frame.captureStageMs))
2103
+ ? Number(frame.captureStageMs)
2104
+ : null,
2060
2105
  convertMs: Number(frame.convertMs || 0) || 0,
2061
2106
  compressMs: Number(frame.compressMs || 0) || 0,
2062
2107
  captureSourceSerial: Number(frame.captureSourceSerial || 0) || 0,
@@ -2140,7 +2185,6 @@ function broadcastRemoteBinaryFrame(frameEvent) {
2140
2185
  const isMode5 = String(frame.frameMode || frame.mode || '').toLowerCase() === 'mode5-lzo-delta';
2141
2186
  const isKeyFrame = frame.isKeyFrame === true || String(frame.chunkType || '').toLowerCase() === 'key';
2142
2187
  const frameSeq = Number(frame.frameSeq || 0) || 0;
2143
- let sharedPacket = null;
2144
2188
  for (const client of targetClients) {
2145
2189
  if (client.readyState !== client.OPEN) {
2146
2190
  continue;
@@ -2157,7 +2201,8 @@ function broadcastRemoteBinaryFrame(frameEvent) {
2157
2201
  || Number(frame.monitorIndex || 0) !== expectedBinding.monitorIndex) {
2158
2202
  continue;
2159
2203
  }
2160
- if (!expectedBinding.readySent) {
2204
+ const requiresReadyKeyFrame = String(expectedBinding.streamPurpose || '').toLowerCase() === 'control' && isH264;
2205
+ if (!expectedBinding.readySent && (!requiresReadyKeyFrame || isKeyFrame)) {
2161
2206
  expectedBinding.readySent = sendJson(client, {
2162
2207
  type: 'RemoteFrameStreamReady',
2163
2208
  deviceId,
@@ -2179,13 +2224,18 @@ function broadcastRemoteBinaryFrame(frameEvent) {
2179
2224
  client.liveDeskFrameBackpressureDrops = Number(client.liveDeskFrameBackpressureDrops || 0) + 1;
2180
2225
  continue;
2181
2226
  }
2182
- sharedPacket ||= buildRemoteFrameBinaryPacket(frameEvent);
2183
- if (!sharedPacket) {
2227
+ const packet = buildRemoteFrameBinaryPacket(frameEvent, {
2228
+ hubClientDropped: lane.dropped,
2229
+ hubClientQueueDepth: lane.queue.length,
2230
+ hubClientQueueLimit: frameClientQueueLimit(client),
2231
+ hubClientBufferedBytes: Number(client.bufferedAmount || 0) || 0
2232
+ });
2233
+ if (!packet) {
2184
2234
  continue;
2185
2235
  }
2186
- enqueueFramePacketForClient(client, sharedPacket, { deviceId, frameSeq, isH264, isMode5, isKeyFrame });
2187
- }
2188
- }
2236
+ enqueueFramePacketForClient(client, packet, { deviceId, frameSeq, isH264, isMode5, isKeyFrame });
2237
+ }
2238
+ }
2189
2239
 
2190
2240
  function broadcastRemoteBinaryAudio(audioEvent) {
2191
2241
  const deviceId = String(audioEvent?.deviceId || audioEvent?.frame?.deviceId || '').trim();
@@ -4016,12 +4066,26 @@ app.post('/api/remote/devices/:deviceId/live/start', requireHubFeatureAccess, (r
4016
4066
  res.json(remoteHub.startLiveStream(req.params.deviceId, req.body || {}));
4017
4067
  });
4018
4068
 
4019
- app.post('/api/remote/devices/:deviceId/live/stop', (req, res) => {
4020
- noStore(res);
4021
- res.json(remoteHub.stopLiveStream(req.params.deviceId, req.body || {}));
4022
- });
4023
-
4024
- app.post('/api/remote/devices/:deviceId/audio/start', requireHubFeatureAccess, (req, res) => {
4069
+ app.post('/api/remote/devices/:deviceId/live/stop', (req, res) => {
4070
+ noStore(res);
4071
+ res.json(remoteHub.stopLiveStream(req.params.deviceId, req.body || {}));
4072
+ });
4073
+
4074
+ app.post('/api/remote/devices/:deviceId/live/pause', requireHubFeatureAccess, async (req, res, next) => {
4075
+ noStore(res);
4076
+ try {
4077
+ res.json(await remoteHub.pauseLiveCapture(req.params.deviceId, req.body || {}));
4078
+ } catch (error) {
4079
+ next(error);
4080
+ }
4081
+ });
4082
+
4083
+ app.post('/api/remote/devices/:deviceId/live/resume', requireHubFeatureAccess, (req, res) => {
4084
+ noStore(res);
4085
+ res.json(remoteHub.resumeLiveCapture(req.params.deviceId, req.body || {}));
4086
+ });
4087
+
4088
+ app.post('/api/remote/devices/:deviceId/audio/start', requireHubFeatureAccess, (req, res) => {
4025
4089
  noStore(res);
4026
4090
  res.json(remoteHub.startAudioStream(req.params.deviceId, req.body || {}));
4027
4091
  });
@@ -4185,9 +4249,10 @@ audioWss.on('connection', (ws, req) => {
4185
4249
  });
4186
4250
  });
4187
4251
 
4188
- inputWss.on('connection', ws => {
4189
- inputClients.add(ws);
4190
- ws.liveDeskInputClientId = `riws-${++inputClientSeq}`;
4252
+ inputWss.on('connection', ws => {
4253
+ inputClients.add(ws);
4254
+ ws.liveDeskInputClientId = `riws-${++inputClientSeq}`;
4255
+ ws.liveDeskInputDeviceIds = new Set();
4191
4256
  try {
4192
4257
  ws._socket?.setNoDelay?.(true);
4193
4258
  } catch {
@@ -4202,10 +4267,18 @@ inputWss.on('connection', ws => {
4202
4267
  return;
4203
4268
  }
4204
4269
  const deviceId = String(payload?.deviceId || '').trim();
4205
- const inputEventId = String(payload?.requestId || '').trim() || crypto.randomUUID();
4270
+ const inputEventId = String(
4271
+ payload?.input?.inputEventId
4272
+ || payload?.inputEventId
4273
+ || payload?.requestId
4274
+ || ''
4275
+ ).trim() || crypto.randomUUID();
4206
4276
  const input = payload?.input && typeof payload.input === 'object'
4207
4277
  ? { ...payload.input, inputEventId, hubConnectionId: ws.liveDeskInputClientId, hubReceivedAtEpochMs: Date.now() }
4208
4278
  : { ...payload, inputEventId, hubConnectionId: ws.liveDeskInputClientId, hubReceivedAtEpochMs: Date.now() };
4279
+ if (deviceId) {
4280
+ ws.liveDeskInputDeviceIds.add(deviceId);
4281
+ }
4209
4282
  const result = remoteHub.sendInputControl(deviceId, input);
4210
4283
  const inputType = String(input?.type || '').toLowerCase();
4211
4284
  const fireAndForget = payload?.fireAndForget === true
@@ -4214,16 +4287,26 @@ inputWss.on('connection', ws => {
4214
4287
  if (fireAndForget && result?.ok) {
4215
4288
  return;
4216
4289
  }
4217
- sendJson(ws, {
4218
- type: result?.ok ? 'RemoteInputQueued' : 'RemoteInputError',
4219
- requestId: payload?.requestId || '',
4220
- deviceId,
4221
- result,
4290
+ sendJson(ws, {
4291
+ type: result?.ok ? 'RemoteInputQueued' : 'RemoteInputError',
4292
+ requestId: payload?.requestId || '',
4293
+ deviceId,
4294
+ inputSeq: Number(input?.inputSeq || 0) || 0,
4295
+ inputEventId: String(input?.inputEventId || ''),
4296
+ hubConnectionId: ws.liveDeskInputClientId,
4297
+ result,
4222
4298
  timestamp: new Date().toISOString()
4223
4299
  });
4224
4300
  });
4225
- ws.on('close', () => inputClients.delete(ws));
4226
- ws.on('error', () => inputClients.delete(ws));
4301
+ const releaseBrowserInputOwner = reason => {
4302
+ inputClients.delete(ws);
4303
+ for (const deviceId of ws.liveDeskInputDeviceIds || []) {
4304
+ remoteHub.releaseInputOwner(deviceId, ws.liveDeskInputClientId, reason);
4305
+ }
4306
+ ws.liveDeskInputDeviceIds?.clear?.();
4307
+ };
4308
+ ws.on('close', () => releaseBrowserInputOwner('browser-input-websocket-closed'));
4309
+ ws.on('error', () => releaseBrowserInputOwner('browser-input-websocket-error'));
4227
4310
  sendJson(ws, {
4228
4311
  type: 'RemoteInputSocketReady',
4229
4312
  protocol: 'livedesk.remote.input.json.v1',
@@ -234,18 +234,25 @@ export function publicSettings(settings) {
234
234
 
235
235
  export function effectiveDevicePolicy(settings = DEFAULT_LIVEDESK_SETTINGS) {
236
236
  const normalized = normalizeLiveDeskSettings(settings);
237
+ // The retired ask-every-time surface has no interactive approval authority,
238
+ // so a persisted legacy value must fail closed. View-only remains able to
239
+ // receive video/audio while every mutating operation is blocked.
240
+ const accessMode = normalized.security.accessMode === 'ask-every-time'
241
+ ? 'block-remote-access'
242
+ : normalized.security.accessMode;
243
+ const allowMutation = accessMode === 'trusted-only';
237
244
  return {
238
245
  settingsSchemaVersion: normalized.settingsSchemaVersion,
239
246
  policyRevision: Number(settings?.revision || 0),
240
- accessMode: normalized.security.accessMode,
247
+ accessMode,
241
248
  allowInternetConnections: normalized.security.allowInternetConnections,
242
249
  allowLanConnections: normalized.security.allowLanConnections,
243
250
  requireEncryptedConnections: normalized.security.requireEncryptedConnections,
244
251
  allowUnencryptedLanFallback: normalized.security.allowUnencryptedLanFallback,
245
- allowControl: normalized.security.accessMode !== 'block-remote-access' && normalized.control.allowKeyboardMouse,
246
- allowClipboardText: normalized.security.accessMode !== 'block-remote-access' && normalized.control.allowClipboardText,
247
- allowFileTransfer: normalized.security.accessMode !== 'block-remote-access' && normalized.filesAudio.allowFileTransfer,
248
- allowRemoteAudio: normalized.security.accessMode !== 'block-remote-access' && normalized.filesAudio.allowRemoteAudio,
249
- allowAgent: normalized.security.accessMode !== 'block-remote-access' && normalized.agent.enabled
252
+ allowControl: allowMutation && normalized.control.allowKeyboardMouse,
253
+ allowClipboardText: allowMutation && normalized.control.allowClipboardText,
254
+ allowFileTransfer: allowMutation && normalized.filesAudio.allowFileTransfer,
255
+ allowRemoteAudio: accessMode !== 'block-remote-access' && normalized.filesAudio.allowRemoteAudio,
256
+ allowAgent: allowMutation && normalized.agent.enabled
250
257
  };
251
258
  }
@@ -30,16 +30,16 @@ function sameEndpoint(left, right) {
30
30
  return !!left && !!right && String(left.address) === String(right.address) && Number(left.port) === Number(right.port);
31
31
  }
32
32
 
33
- export function createHubUdpTransport({ env = process.env, logEvent = () => {}, logWarn = () => {} } = {}) {
34
- const enabled = isEnabled(env.LIVEDESK_UDP_ENABLED, false);
35
- const preferP2p = isEnabled(env.LIVEDESK_UDP_PREFER_P2P, true);
33
+ export function createHubUdpTransport({ env = process.env, logEvent = () => {}, logWarn = () => {} } = {}) {
34
+ const enabled = isEnabled(env.LIVEDESK_UDP_ENABLED, true);
35
+ const preferP2p = isEnabled(env.LIVEDESK_UDP_PREFER_P2P, false);
36
36
  const bindHost = safeText(env.LIVEDESK_UDP_BIND_HOST || '0.0.0.0', 128) || '0.0.0.0';
37
37
  const requestedPort = numberEnv(env.LIVEDESK_UDP_PORT, 0, 65535, 0);
38
38
  const advertiseHost = safeText(env.LIVEDESK_UDP_ADVERTISE_HOST || env.LIVEDESK_REMOTE_PUBLIC_HOST || '', 128);
39
- const rendezvousHost = safeText(env.LIVEDESK_UDP_RENDEZVOUS_HOST || '', 128);
39
+ const rendezvousHost = safeText(env.LIVEDESK_UDP_RENDEZVOUS_HOST || 'www.gnsoftech.com', 128);
40
40
  const rendezvousPort = numberEnv(env.LIVEDESK_UDP_RENDEZVOUS_PORT, 1, 65535, 5199);
41
41
  const punchTimeoutMs = numberEnv(env.LIVEDESK_UDP_PUNCH_TIMEOUT_MS, 500, 10_000, 3000);
42
- const frameTimeoutMs = numberEnv(env.LIVEDESK_UDP_FRAME_TIMEOUT_MS, 20, 2000, 80);
42
+ const frameTimeoutMs = numberEnv(env.LIVEDESK_UDP_FRAME_TIMEOUT_MS, 20, 2000, 500);
43
43
  const maxPendingFrames = numberEnv(env.LIVEDESK_UDP_MAX_PENDING_FRAMES, 1, 16, 2);
44
44
  const trace = isEnabled(env.LIVEDESK_UDP_TRACE, false);
45
45
  const socket = dgram.createSocket('udp4');
@@ -146,15 +146,23 @@ export function createHubUdpTransport({ env = process.env, logEvent = () => {},
146
146
  return;
147
147
  }
148
148
  if (packet.type !== 'frame') return;
149
- const complete = session.reassembler.ingest(packet);
150
- if (!complete) return;
151
- session.framesReceived += 1;
152
- frameHandler({
153
- deviceId: session.deviceId,
154
- sessionId: session.sessionId,
155
- header: complete.header,
156
- payload: complete.payload,
157
- transport: 'udp-p2p'
149
+ const complete = session.reassembler.ingest(packet);
150
+ if (!complete) return;
151
+ session.framesReceived += 1;
152
+ const reassembly = session.reassembler.getStats();
153
+ frameHandler({
154
+ deviceId: session.deviceId,
155
+ sessionId: session.sessionId,
156
+ header: {
157
+ ...complete.header,
158
+ hubIngressDropped: Math.max(
159
+ Number(complete.header?.hubIngressDropped || 0) || 0,
160
+ reassembly.droppedFrames
161
+ ),
162
+ udpIncompleteFrames: reassembly.droppedFrames
163
+ },
164
+ payload: complete.payload,
165
+ transport: 'udp-p2p'
158
166
  });
159
167
  sendPacket(session, 'pong', {
160
168
  state: 'frame-ack',
@@ -171,10 +179,16 @@ export function createHubUdpTransport({ env = process.env, logEvent = () => {},
171
179
  socket.once('error', onError);
172
180
  socket.once('listening', onListening);
173
181
  socket.bind(requestedPort, bindHost);
174
- });
175
- started = true;
176
- boundPort = socket.address().port;
177
- logEvent('udp', `UDP P2P socket listening on udp://${bindHost}:${boundPort}`);
182
+ });
183
+ started = true;
184
+ boundPort = socket.address().port;
185
+ try {
186
+ socket.setRecvBufferSize(8 * 1024 * 1024);
187
+ socket.setSendBufferSize(1024 * 1024);
188
+ } catch (error) {
189
+ logWarn('udp', `UDP socket buffer tuning was unavailable: ${error?.message || error}`);
190
+ }
191
+ logEvent('udp', `UDP P2P socket listening on udp://${bindHost}:${boundPort}`);
178
192
  return getStatus();
179
193
  }
180
194
 
@@ -240,9 +254,10 @@ export function createHubUdpTransport({ env = process.env, logEvent = () => {},
240
254
  state: session.ready ? 'udp-ready' : 'udp-session-created',
241
255
  ready: session.ready,
242
256
  sessionId: session.sessionId,
243
- lastPacketAt: session.lastPacketAt ? new Date(session.lastPacketAt).toISOString() : '',
244
- framesReceived: session.framesReceived
245
- };
257
+ lastPacketAt: session.lastPacketAt ? new Date(session.lastPacketAt).toISOString() : '',
258
+ framesReceived: session.framesReceived,
259
+ ...session.reassembler.getStats()
260
+ };
246
261
  }
247
262
 
248
263
  async function close() {
@@ -1,4 +1,5 @@
1
- import crypto from 'node:crypto';
1
+ import crypto from 'node:crypto';
2
+ import { inflateRawSync } from 'node:zlib';
2
3
 
3
4
  export const UDP_P2P_PROTOCOL = 'livedesk.udp.p2p.v1';
4
5
  export const UDP_P2P_VERSION = 1;
@@ -17,8 +18,10 @@ const UDP_FIXED_HEADER_BYTES = 15;
17
18
  const UDP_NONCE_BYTES = 12;
18
19
  const UDP_TAG_BYTES = 16;
19
20
  const UDP_MAX_SESSION_ID_BYTES = 96;
20
- const UDP_MAX_HEADER_BYTES = 4096;
21
- const UDP_MAX_PAYLOAD_BYTES = UDP_MAX_DATAGRAM_BYTES - UDP_FIXED_HEADER_BYTES - UDP_NONCE_BYTES - UDP_TAG_BYTES - 1;
21
+ const UDP_MAX_HEADER_BYTES = 4096;
22
+ const UDP_MAX_PAYLOAD_BYTES = UDP_MAX_DATAGRAM_BYTES - UDP_FIXED_HEADER_BYTES - UDP_NONCE_BYTES - UDP_TAG_BYTES - 1;
23
+ const UDP_FRAME_PAYLOAD_MAGIC = Buffer.from('LDF1', 'ascii');
24
+ const UDP_MAX_FRAME_HEADER_BYTES = 64 * 1024;
22
25
 
23
26
  function asBuffer(value) {
24
27
  if (Buffer.isBuffer(value)) return value;
@@ -32,13 +35,53 @@ function normalizeKey(key) {
32
35
  return value.length === 32 ? value : crypto.createHash('sha256').update(value).digest();
33
36
  }
34
37
 
35
- function normalizeSessionId(value) {
38
+ function normalizeSessionId(value) {
36
39
  const sessionId = String(value || '').trim();
37
40
  if (!sessionId || Buffer.byteLength(sessionId, 'utf8') > UDP_MAX_SESSION_ID_BYTES) {
38
41
  throw new Error('invalid-udp-session-id');
39
42
  }
40
43
  return sessionId;
41
- }
44
+ }
45
+
46
+ function decodeFrameHeader(header) {
47
+ if (header?.frameHeader && typeof header.frameHeader === 'object') {
48
+ return header.frameHeader;
49
+ }
50
+ const encoded = String(header?.frameHeaderDeflate || '');
51
+ if (!encoded || encoded.length > 8192 || !/^[A-Za-z0-9+/]+={0,2}$/.test(encoded)) {
52
+ return null;
53
+ }
54
+ try {
55
+ const json = inflateRawSync(Buffer.from(encoded, 'base64'), { maxOutputLength: 64 * 1024 }).toString('utf8');
56
+ const value = JSON.parse(json);
57
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
58
+ } catch {
59
+ return null;
60
+ }
61
+ }
62
+
63
+ function decodeFramedPayload(payload, legacyHeader = null) {
64
+ const bytes = asBuffer(payload);
65
+ if (bytes.length < 8 || !bytes.subarray(0, 4).equals(UDP_FRAME_PAYLOAD_MAGIC)) {
66
+ return { header: legacyHeader || {}, payload: bytes };
67
+ }
68
+ const compressedHeaderLength = bytes.readUInt32BE(4);
69
+ if (compressedHeaderLength < 1 || compressedHeaderLength > UDP_MAX_FRAME_HEADER_BYTES
70
+ || 8 + compressedHeaderLength > bytes.length) return null;
71
+ try {
72
+ const json = inflateRawSync(bytes.subarray(8, 8 + compressedHeaderLength), {
73
+ maxOutputLength: UDP_MAX_FRAME_HEADER_BYTES
74
+ }).toString('utf8');
75
+ const header = JSON.parse(json);
76
+ if (!header || typeof header !== 'object' || Array.isArray(header)) return null;
77
+ return {
78
+ header,
79
+ payload: bytes.subarray(8 + compressedHeaderLength)
80
+ };
81
+ } catch {
82
+ return null;
83
+ }
84
+ }
42
85
 
43
86
  function packetTypeCode(type) {
44
87
  const code = typeof type === 'number' ? type : UDP_PACKET_TYPES[String(type || '').toLowerCase()];
@@ -147,12 +190,15 @@ export class UdpReplayWindow {
147
190
  }
148
191
  }
149
192
 
150
- export class UdpFrameReassembler {
151
- constructor({ maxPending = 2, timeoutMs = 80, maxFrameBytes = 8 * 1024 * 1024 } = {}) {
152
- this.maxPending = Math.max(1, Math.min(16, Math.floor(Number(maxPending) || 2)));
153
- this.timeoutMs = Math.max(20, Math.min(2000, Math.floor(Number(timeoutMs) || 80)));
154
- this.maxFrameBytes = Math.max(64 * 1024, Math.min(16 * 1024 * 1024, Math.floor(Number(maxFrameBytes) || 8 * 1024 * 1024)));
155
- this.pending = new Map();
193
+ export class UdpFrameReassembler {
194
+ constructor({ maxPending = 2, timeoutMs = 500, maxFrameBytes = 8 * 1024 * 1024 } = {}) {
195
+ this.maxPending = Math.max(1, Math.min(16, Math.floor(Number(maxPending) || 2)));
196
+ this.timeoutMs = Math.max(20, Math.min(2000, Math.floor(Number(timeoutMs) || 500)));
197
+ this.maxFrameBytes = Math.max(64 * 1024, Math.min(16 * 1024 * 1024, Math.floor(Number(maxFrameBytes) || 8 * 1024 * 1024)));
198
+ this.pending = new Map();
199
+ this.droppedFrames = 0;
200
+ this.expiredFrames = 0;
201
+ this.evictedFrames = 0;
156
202
  }
157
203
 
158
204
  ingest(packet, now = Date.now()) {
@@ -166,11 +212,14 @@ export class UdpFrameReassembler {
166
212
  || chunkIndex < 0 || chunkCount < 1 || chunkCount > 4096 || chunkIndex >= chunkCount) return null;
167
213
  let state = this.pending.get(frameId);
168
214
  if (!state) {
169
- while (this.pending.size >= this.maxPending) {
170
- const oldest = this.pending.keys().next().value;
171
- if (oldest === undefined) break;
172
- this.pending.delete(oldest);
173
- }
215
+ while (this.pending.size >= this.maxPending) {
216
+ const oldest = this.pending.keys().next().value;
217
+ if (oldest === undefined) break;
218
+ if (this.pending.delete(oldest)) {
219
+ this.droppedFrames += 1;
220
+ this.evictedFrames += 1;
221
+ }
222
+ }
174
223
  state = { createdAt: now, chunkCount, chunks: new Array(chunkCount), received: 0, totalBytes: 0, frameHeader: null };
175
224
  this.pending.set(frameId, state);
176
225
  }
@@ -179,26 +228,47 @@ export class UdpFrameReassembler {
179
228
  state.chunks[chunkIndex] = payload;
180
229
  state.received += 1;
181
230
  state.totalBytes += payload.length;
182
- if (header.frameHeader && typeof header.frameHeader === 'object') state.frameHeader = header.frameHeader;
183
- if (state.totalBytes > this.maxFrameBytes) {
184
- this.pending.delete(frameId);
185
- return null;
186
- }
187
- if (state.received !== state.chunkCount) return null;
188
- this.pending.delete(frameId);
189
- return {
190
- frameId,
191
- header: state.frameHeader || header.frameHeader || {},
192
- payload: Buffer.concat(state.chunks, state.totalBytes)
193
- };
194
- }
231
+ const decodedFrameHeader = decodeFrameHeader(header);
232
+ if (decodedFrameHeader) state.frameHeader = decodedFrameHeader;
233
+ if (state.totalBytes > this.maxFrameBytes) {
234
+ if (this.pending.delete(frameId)) this.droppedFrames += 1;
235
+ return null;
236
+ }
237
+ if (state.received !== state.chunkCount) return null;
238
+ this.pending.delete(frameId);
239
+ const complete = decodeFramedPayload(
240
+ Buffer.concat(state.chunks, state.totalBytes),
241
+ state.frameHeader || header.frameHeader || {}
242
+ );
243
+ if (!complete) {
244
+ this.droppedFrames += 1;
245
+ return null;
246
+ }
247
+ return {
248
+ frameId,
249
+ header: complete.header,
250
+ payload: complete.payload
251
+ };
252
+ }
195
253
 
196
- expire(now = Date.now()) {
197
- for (const [frameId, state] of this.pending) {
198
- if (now - state.createdAt > this.timeoutMs) this.pending.delete(frameId);
199
- }
200
- }
201
- }
254
+ expire(now = Date.now()) {
255
+ for (const [frameId, state] of this.pending) {
256
+ if (now - state.createdAt > this.timeoutMs && this.pending.delete(frameId)) {
257
+ this.droppedFrames += 1;
258
+ this.expiredFrames += 1;
259
+ }
260
+ }
261
+ }
262
+
263
+ getStats() {
264
+ return {
265
+ pendingFrames: this.pending.size,
266
+ droppedFrames: this.droppedFrames,
267
+ expiredFrames: this.expiredFrames,
268
+ evictedFrames: this.evictedFrames
269
+ };
270
+ }
271
+ }
202
272
 
203
273
  export function encodeRendezvousMessage(message) {
204
274
  const payload = Buffer.from(JSON.stringify(message && typeof message === 'object' ? message : {}), 'utf8');