@livedesk/hub 0.1.7 → 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/package.json +1 -1
- package/src/live-desk-update.js +1 -1
- package/src/remote-hub.js +835 -157
- package/src/server.js +130 -47
- package/src/transport/udp-hub-transport.js +36 -21
- package/src/transport/udp-protocol.js +105 -35
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',
|
|
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
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2049
|
-
|
|
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
|
|
2059
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2183
|
-
|
|
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,
|
|
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/
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
4226
|
-
|
|
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',
|
|
@@ -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,
|
|
35
|
-
const preferP2p = isEnabled(env.LIVEDESK_UDP_PREFER_P2P,
|
|
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,
|
|
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
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
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 =
|
|
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) ||
|
|
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
|
-
|
|
183
|
-
if (state.
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
|
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');
|