@livedesk/hub 0.1.26 → 0.1.28

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.
@@ -14,15 +14,19 @@ export const UDP_PACKET_TYPES = Object.freeze({
14
14
  pong: 5,
15
15
  frame: 6,
16
16
  close: 7
17
- });
18
- const UDP_PACKET_TYPE_NAMES = new Map(Object.entries(UDP_PACKET_TYPES).map(([name, value]) => [value, name]));
19
- const UDP_FIXED_HEADER_BYTES = 15;
20
- const UDP_NONCE_BYTES = 12;
21
- const UDP_TAG_BYTES = 16;
22
- const UDP_MAX_SESSION_ID_BYTES = 96;
17
+ });
18
+ const UDP_PACKET_TYPE_NAMES = new Map(Object.entries(UDP_PACKET_TYPES).map(([name, value]) => [value, name]));
19
+ const UDP_FIXED_HEADER_BYTES = 15;
20
+ const UDP_NONCE_BYTES = 12;
21
+ const UDP_TAG_BYTES = 16;
22
+ const UDP_MAX_SESSION_ID_BYTES = 96;
23
23
  const UDP_MAX_HEADER_BYTES = 4096;
24
24
  const UDP_MAX_PAYLOAD_BYTES = UDP_MAX_DATAGRAM_BYTES - UDP_FIXED_HEADER_BYTES - UDP_NONCE_BYTES - UDP_TAG_BYTES - 1;
25
25
  const UDP_FRAME_PAYLOAD_MAGIC = Buffer.from('LDF1', 'ascii');
26
+ const UDP_PACKET_MAGIC = Buffer.from('LDU1', 'ascii');
27
+ const UDP_FRAME_CHUNK_MAGIC = Buffer.from('LDC1', 'ascii');
28
+ export const UDP_FRAME_CHUNK_PROTOCOL = 'livedesk.udp.frame-chunk.v1';
29
+ export const UDP_FRAME_CHUNK_HEADER_BYTES = 12;
26
30
  const UDP_MAX_FRAME_HEADER_BYTES = 64 * 1024;
27
31
 
28
32
  function asBuffer(value) {
@@ -45,6 +49,106 @@ function normalizeSessionId(value) {
45
49
  return sessionId;
46
50
  }
47
51
 
52
+ export function isUdpBinaryDatagram(packet) {
53
+ return Buffer.isBuffer(packet)
54
+ && packet.length >= UDP_FIXED_HEADER_BYTES + UDP_NONCE_BYTES + UDP_TAG_BYTES
55
+ && packet[0] === UDP_PACKET_MAGIC[0]
56
+ && packet[1] === UDP_PACKET_MAGIC[1]
57
+ && packet[2] === UDP_PACKET_MAGIC[2]
58
+ && packet[3] === UDP_PACKET_MAGIC[3]
59
+ && packet[4] === UDP_P2P_VERSION;
60
+ }
61
+
62
+ export function peekUdpSessionId(packet) {
63
+ if (!isUdpBinaryDatagram(packet)) return '';
64
+ const sessionLength = packet[6];
65
+ const headerLength = packet.readUInt16BE(7);
66
+ const payloadLength = packet.readUInt16BE(9);
67
+ const associatedLength = UDP_FIXED_HEADER_BYTES + UDP_NONCE_BYTES + sessionLength + headerLength;
68
+ const expectedLength = associatedLength + payloadLength + UDP_TAG_BYTES;
69
+ if (sessionLength < 1 || sessionLength > UDP_MAX_SESSION_ID_BYTES
70
+ || headerLength > UDP_MAX_HEADER_BYTES || payloadLength > UDP_MAX_PAYLOAD_BYTES
71
+ || expectedLength !== packet.length) return '';
72
+ return packet.subarray(
73
+ UDP_FIXED_HEADER_BYTES + UDP_NONCE_BYTES,
74
+ UDP_FIXED_HEADER_BYTES + UDP_NONCE_BYTES + sessionLength
75
+ ).toString('utf8');
76
+ }
77
+
78
+ function routeKeyForBytes(bytes, offset = 0, length = bytes.length - offset) {
79
+ let hash = 0x811c9dc5;
80
+ const end = offset + length;
81
+ for (let index = offset; index < end; index += 1) {
82
+ hash = Math.imul(hash ^ bytes[index], 0x01000193) >>> 0;
83
+ }
84
+ return hash;
85
+ }
86
+
87
+ export function udpSessionRouteKey(sessionId) {
88
+ return routeKeyForBytes(Buffer.from(normalizeSessionId(sessionId), 'utf8'));
89
+ }
90
+
91
+ export function peekUdpSessionRouteKey(packet) {
92
+ if (!isUdpBinaryDatagram(packet)) return null;
93
+ const sessionLength = packet[6];
94
+ const headerLength = packet.readUInt16BE(7);
95
+ const payloadLength = packet.readUInt16BE(9);
96
+ const associatedLength = UDP_FIXED_HEADER_BYTES + UDP_NONCE_BYTES + sessionLength + headerLength;
97
+ if (sessionLength < 1 || sessionLength > UDP_MAX_SESSION_ID_BYTES
98
+ || headerLength > UDP_MAX_HEADER_BYTES || payloadLength > UDP_MAX_PAYLOAD_BYTES
99
+ || associatedLength + payloadLength + UDP_TAG_BYTES !== packet.length) return null;
100
+ return routeKeyForBytes(
101
+ packet,
102
+ UDP_FIXED_HEADER_BYTES + UDP_NONCE_BYTES,
103
+ sessionLength
104
+ );
105
+ }
106
+
107
+ function encodeFrameChunkHeader(header) {
108
+ const frameSerial = Number(header?.frameSerial);
109
+ const chunkIndex = Number(header?.chunkIndex);
110
+ const chunkCount = Number(header?.chunkCount);
111
+ if (!Number.isInteger(frameSerial) || frameSerial < 0 || frameSerial > 0xffff_ffff
112
+ || !Number.isInteger(chunkIndex) || chunkIndex < 0 || chunkIndex > 0xffff
113
+ || !Number.isInteger(chunkCount) || chunkCount < 1 || chunkCount > 4096
114
+ || chunkIndex >= chunkCount) {
115
+ throw new Error('invalid-udp-frame-chunk-header');
116
+ }
117
+ const bytes = Buffer.allocUnsafe(UDP_FRAME_CHUNK_HEADER_BYTES);
118
+ UDP_FRAME_CHUNK_MAGIC.copy(bytes, 0);
119
+ bytes.writeUInt32BE(frameSerial >>> 0, 4);
120
+ bytes.writeUInt16BE(chunkIndex, 8);
121
+ bytes.writeUInt16BE(chunkCount, 10);
122
+ return bytes;
123
+ }
124
+
125
+ function decodePacketHeader(type, bytes) {
126
+ if (type === 'frame'
127
+ && bytes.length === UDP_FRAME_CHUNK_HEADER_BYTES
128
+ && bytes[0] === UDP_FRAME_CHUNK_MAGIC[0]
129
+ && bytes[1] === UDP_FRAME_CHUNK_MAGIC[1]
130
+ && bytes[2] === UDP_FRAME_CHUNK_MAGIC[2]
131
+ && bytes[3] === UDP_FRAME_CHUNK_MAGIC[3]) {
132
+ const chunkIndex = bytes.readUInt16BE(8);
133
+ const chunkCount = bytes.readUInt16BE(10);
134
+ if (chunkCount < 1 || chunkCount > 4096 || chunkIndex >= chunkCount) return null;
135
+ const frameSerial = bytes.readUInt32BE(4);
136
+ return {
137
+ frameId: frameSerial,
138
+ frameSerial,
139
+ chunkIndex,
140
+ chunkCount,
141
+ binaryChunkHeader: true
142
+ };
143
+ }
144
+ try {
145
+ const value = JSON.parse(bytes.toString('utf8'));
146
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
147
+ } catch {
148
+ return null;
149
+ }
150
+ }
151
+
48
152
  function decodeFrameHeader(header) {
49
153
  if (header?.frameHeader && typeof header.frameHeader === 'object') {
50
154
  return header.frameHeader;
@@ -84,6 +188,72 @@ function decodeFramedPayload(payload, legacyHeader = null) {
84
188
  return null;
85
189
  }
86
190
  }
191
+
192
+ function copyChunkRange(chunks, sourceOffset, length, target, targetOffset = 0) {
193
+ let remaining = length;
194
+ let skip = sourceOffset;
195
+ let written = 0;
196
+ for (const chunk of chunks) {
197
+ if (!chunk || remaining <= 0) continue;
198
+ if (skip >= chunk.length) {
199
+ skip -= chunk.length;
200
+ continue;
201
+ }
202
+ const available = Math.min(remaining, chunk.length - skip);
203
+ chunk.copy(target, targetOffset + written, skip, skip + available);
204
+ written += available;
205
+ remaining -= available;
206
+ skip = 0;
207
+ }
208
+ return written === length;
209
+ }
210
+
211
+ function decodeFramedChunks(chunks, totalBytes, legacyHeader = null) {
212
+ if (chunks.length === 1) {
213
+ const complete = decodeFramedPayload(chunks[0], legacyHeader);
214
+ return complete
215
+ ? { ...complete, payloadCopyCount: 0, payloadCopyBytes: 0 }
216
+ : null;
217
+ }
218
+
219
+ const prefix = Buffer.allocUnsafe(Math.min(8, totalBytes));
220
+ if (!copyChunkRange(chunks, 0, prefix.length, prefix)) return null;
221
+ if (prefix.length < 8 || !prefix.subarray(0, 4).equals(UDP_FRAME_PAYLOAD_MAGIC)) {
222
+ const payload = Buffer.allocUnsafe(totalBytes);
223
+ if (!copyChunkRange(chunks, 0, totalBytes, payload)) return null;
224
+ return {
225
+ header: legacyHeader || {},
226
+ payload,
227
+ payloadCopyCount: 1,
228
+ payloadCopyBytes: totalBytes
229
+ };
230
+ }
231
+
232
+ const compressedHeaderLength = prefix.readUInt32BE(4);
233
+ const payloadOffset = 8 + compressedHeaderLength;
234
+ if (compressedHeaderLength < 1 || compressedHeaderLength > UDP_MAX_FRAME_HEADER_BYTES
235
+ || payloadOffset > totalBytes) return null;
236
+ try {
237
+ const compressedHeader = Buffer.allocUnsafe(compressedHeaderLength);
238
+ if (!copyChunkRange(chunks, 8, compressedHeaderLength, compressedHeader)) return null;
239
+ const json = inflateRawSync(compressedHeader, {
240
+ maxOutputLength: UDP_MAX_FRAME_HEADER_BYTES
241
+ }).toString('utf8');
242
+ const header = JSON.parse(json);
243
+ if (!header || typeof header !== 'object' || Array.isArray(header)) return null;
244
+ const payloadLength = totalBytes - payloadOffset;
245
+ const payload = Buffer.allocUnsafe(payloadLength);
246
+ if (!copyChunkRange(chunks, payloadOffset, payloadLength, payload)) return null;
247
+ return {
248
+ header,
249
+ payload,
250
+ payloadCopyCount: 1,
251
+ payloadCopyBytes: payloadLength
252
+ };
253
+ } catch {
254
+ return null;
255
+ }
256
+ }
87
257
 
88
258
  function packetTypeCode(type) {
89
259
  const code = typeof type === 'number' ? type : UDP_PACKET_TYPES[String(type || '').toLowerCase()];
@@ -99,11 +269,16 @@ function normalizeSequence(value) {
99
269
  return sequence;
100
270
  }
101
271
 
102
- function encodeHeader(header) {
103
- const bytes = Buffer.from(JSON.stringify(header && typeof header === 'object' ? header : {}), 'utf8');
104
- if (bytes.length > UDP_MAX_HEADER_BYTES) throw new Error('udp-packet-header-too-large');
105
- return bytes;
106
- }
272
+ function encodeHeader(type, header) {
273
+ if ((typeof type === 'number' ? type : UDP_PACKET_TYPES[String(type || '').toLowerCase()])
274
+ === UDP_PACKET_TYPES.frame
275
+ && Number.isInteger(Number(header?.frameSerial))) {
276
+ return encodeFrameChunkHeader(header);
277
+ }
278
+ const bytes = Buffer.from(JSON.stringify(header && typeof header === 'object' ? header : {}), 'utf8');
279
+ if (bytes.length > UDP_MAX_HEADER_BYTES) throw new Error('udp-packet-header-too-large');
280
+ return bytes;
281
+ }
107
282
 
108
283
  export function deriveUdpSessionKey(keyMaterial) {
109
284
  return normalizeKey(keyMaterial);
@@ -116,12 +291,12 @@ export function createUdpSessionKey() {
116
291
  export function encodeUdpPacket({ type, sessionId, key, sequence = 0, header = {}, payload = Buffer.alloc(0) }) {
117
292
  const normalizedSessionId = normalizeSessionId(sessionId);
118
293
  const sessionBytes = Buffer.from(normalizedSessionId, 'utf8');
119
- const headerBytes = encodeHeader(header);
120
- const payloadBytes = asBuffer(payload);
121
- if (payloadBytes.length > UDP_MAX_PAYLOAD_BYTES) throw new Error('udp-packet-payload-too-large');
122
-
123
- const preamble = Buffer.allocUnsafe(UDP_FIXED_HEADER_BYTES);
124
- UDP_P2P_PROTOCOL.startsWith('livedesk') && Buffer.from('LDU1', 'ascii').copy(preamble, 0);
294
+ const headerBytes = encodeHeader(type, header);
295
+ const payloadBytes = asBuffer(payload);
296
+ if (payloadBytes.length > UDP_MAX_PAYLOAD_BYTES) throw new Error('udp-packet-payload-too-large');
297
+
298
+ const preamble = Buffer.allocUnsafe(UDP_FIXED_HEADER_BYTES);
299
+ UDP_PACKET_MAGIC.copy(preamble, 0);
125
300
  preamble[4] = UDP_P2P_VERSION;
126
301
  preamble[5] = packetTypeCode(type);
127
302
  preamble[6] = sessionBytes.length;
@@ -139,11 +314,10 @@ export function encodeUdpPacket({ type, sessionId, key, sequence = 0, header = {
139
314
  return packet;
140
315
  }
141
316
 
142
- export function decodeUdpPacket(packet, key) {
143
- const bytes = asBuffer(packet);
144
- if (bytes.length < UDP_FIXED_HEADER_BYTES + UDP_NONCE_BYTES + UDP_TAG_BYTES) return null;
145
- if (!bytes.subarray(0, 4).equals(Buffer.from('LDU1', 'ascii')) || bytes[4] !== UDP_P2P_VERSION) return null;
146
- const type = UDP_PACKET_TYPE_NAMES.get(bytes[5]);
317
+ export function decodeUdpPacket(packet, key, expectedSession = null) {
318
+ const bytes = asBuffer(packet);
319
+ if (!isUdpBinaryDatagram(bytes)) return null;
320
+ const type = UDP_PACKET_TYPE_NAMES.get(bytes[5]);
147
321
  const sessionLength = bytes[6];
148
322
  const headerLength = bytes.readUInt16BE(7);
149
323
  const payloadLength = bytes.readUInt16BE(9);
@@ -154,28 +328,36 @@ export function decodeUdpPacket(packet, key) {
154
328
  || headerLength > UDP_MAX_HEADER_BYTES || payloadLength > UDP_MAX_PAYLOAD_BYTES
155
329
  || expectedLength !== bytes.length) return null;
156
330
 
157
- const associatedData = bytes.subarray(0, associatedLength);
158
- const sessionStart = UDP_FIXED_HEADER_BYTES + UDP_NONCE_BYTES;
159
- const sessionId = bytes.subarray(sessionStart, sessionStart + sessionLength).toString('utf8');
160
- const headerStart = sessionStart + sessionLength;
161
- let header;
162
- try {
163
- header = JSON.parse(bytes.subarray(headerStart, headerStart + headerLength).toString('utf8'));
164
- } catch {
165
- return null;
166
- }
167
- const nonce = bytes.subarray(UDP_FIXED_HEADER_BYTES, UDP_FIXED_HEADER_BYTES + UDP_NONCE_BYTES);
168
- const ciphertextStart = associatedLength;
169
- const ciphertext = bytes.subarray(ciphertextStart, ciphertextStart + payloadLength);
170
- const tag = bytes.subarray(ciphertextStart + payloadLength);
171
- try {
172
- const decipher = crypto.createDecipheriv('aes-256-gcm', normalizeKey(key), nonce);
173
- decipher.setAAD(associatedData);
174
- decipher.setAuthTag(tag);
175
- const payload = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
176
- return { type, sessionId, sequence, header, payload };
177
- } catch {
178
- return null;
331
+ const associatedData = bytes.subarray(0, associatedLength);
332
+ const sessionStart = UDP_FIXED_HEADER_BYTES + UDP_NONCE_BYTES;
333
+ const sessionSlice = bytes.subarray(sessionStart, sessionStart + sessionLength);
334
+ const expectedSessionBytes = Buffer.isBuffer(expectedSession?.bytes)
335
+ ? expectedSession.bytes
336
+ : null;
337
+ if (expectedSessionBytes && !sessionSlice.equals(expectedSessionBytes)) return null;
338
+ const sessionId = expectedSessionBytes
339
+ ? String(expectedSession?.id || '')
340
+ : sessionSlice.toString('utf8');
341
+ const headerStart = sessionStart + sessionLength;
342
+ const nonce = bytes.subarray(UDP_FIXED_HEADER_BYTES, UDP_FIXED_HEADER_BYTES + UDP_NONCE_BYTES);
343
+ const ciphertextStart = associatedLength;
344
+ const ciphertext = bytes.subarray(ciphertextStart, ciphertextStart + payloadLength);
345
+ const tag = bytes.subarray(ciphertextStart + payloadLength);
346
+ try {
347
+ const decipher = crypto.createDecipheriv('aes-256-gcm', normalizeKey(key), nonce);
348
+ decipher.setAAD(associatedData);
349
+ decipher.setAuthTag(tag);
350
+ let payload = decipher.update(ciphertext);
351
+ const final = decipher.final();
352
+ if (final.length > 0) payload = Buffer.concat([payload, final], payload.length + final.length);
353
+ const header = decodePacketHeader(
354
+ type,
355
+ bytes.subarray(headerStart, headerStart + headerLength)
356
+ );
357
+ if (!header) return null;
358
+ return { type, sessionId, sequence, header, payload };
359
+ } catch {
360
+ return null;
179
361
  }
180
362
  }
181
363
 
@@ -221,54 +403,80 @@ export class UdpFrameReassembler {
221
403
  this.timeoutMs = Math.max(20, Math.min(2000, Math.floor(Number(timeoutMs) || 500)));
222
404
  this.maxFrameBytes = Math.max(64 * 1024, Math.min(16 * 1024 * 1024, Math.floor(Number(maxFrameBytes) || 8 * 1024 * 1024)));
223
405
  this.pending = new Map();
406
+ this.pendingBytes = 0;
407
+ this.pendingChunkBuffers = 0;
408
+ this.pendingBytesHighWatermark = 0;
409
+ this.pendingChunkBuffersHighWatermark = 0;
224
410
  this.droppedFrames = 0;
225
411
  this.expiredFrames = 0;
226
412
  this.evictedFrames = 0;
227
- }
228
-
229
- ingest(packet, now = Date.now()) {
230
- if (!packet || packet.type !== 'frame') return null;
231
- this.expire(now);
232
- const header = packet.header && typeof packet.header === 'object' ? packet.header : {};
233
- const frameId = String(header.frameId || '').slice(0, 160);
234
- const chunkIndex = Number(header.chunkIndex);
235
- const chunkCount = Number(header.chunkCount);
236
- if (!frameId || !Number.isInteger(chunkIndex) || !Number.isInteger(chunkCount)
237
- || chunkIndex < 0 || chunkCount < 1 || chunkCount > 4096 || chunkIndex >= chunkCount) return null;
238
- let state = this.pending.get(frameId);
239
- if (!state) {
413
+ this.completedFrames = 0;
414
+ this.completedPayloadCopyCount = 0;
415
+ this.completedPayloadCopyBytes = 0;
416
+ this.fullWireDuplicateCopyCount = 0;
417
+ this.binaryChunkHeaderCount = 0;
418
+ this.legacyChunkHeaderCount = 0;
419
+ }
420
+
421
+ ingest(packet, now = Date.now()) {
422
+ if (!packet || packet.type !== 'frame') return null;
423
+ this.expire(now);
424
+ const header = packet.header && typeof packet.header === 'object' ? packet.header : {};
425
+ const frameId = Number.isInteger(header.frameSerial)
426
+ ? header.frameSerial
427
+ : String(header.frameId || '').slice(0, 160);
428
+ const chunkIndex = Number(header.chunkIndex);
429
+ const chunkCount = Number(header.chunkCount);
430
+ if ((frameId === '' || frameId === null) || !Number.isInteger(chunkIndex) || !Number.isInteger(chunkCount)
431
+ || chunkIndex < 0 || chunkCount < 1 || chunkCount > 4096 || chunkIndex >= chunkCount) return null;
432
+ let state = this.pending.get(frameId);
433
+ if (!state) {
240
434
  while (this.pending.size >= this.maxPending) {
241
435
  const oldest = this.pending.keys().next().value;
242
436
  if (oldest === undefined) break;
243
- if (this.pending.delete(oldest)) {
437
+ const evicted = this.#detach(oldest);
438
+ if (evicted) {
244
439
  this.droppedFrames += 1;
245
440
  this.evictedFrames += 1;
246
441
  }
247
442
  }
248
443
  state = { createdAt: now, chunkCount, chunks: new Array(chunkCount), received: 0, totalBytes: 0, frameHeader: null };
249
444
  this.pending.set(frameId, state);
250
- }
251
- if (state.chunkCount !== chunkCount || state.chunks[chunkIndex]) return null;
252
- const payload = asBuffer(packet.payload);
253
- state.chunks[chunkIndex] = payload;
254
- state.received += 1;
255
- state.totalBytes += payload.length;
445
+ }
446
+ if (state.chunkCount !== chunkCount || state.chunks[chunkIndex]) return null;
447
+ const payload = asBuffer(packet.payload);
448
+ state.chunks[chunkIndex] = payload;
449
+ state.received += 1;
450
+ state.totalBytes += payload.length;
451
+ this.pendingBytes += payload.length;
452
+ this.pendingChunkBuffers += 1;
453
+ this.pendingBytesHighWatermark = Math.max(this.pendingBytesHighWatermark, this.pendingBytes);
454
+ this.pendingChunkBuffersHighWatermark = Math.max(
455
+ this.pendingChunkBuffersHighWatermark,
456
+ this.pendingChunkBuffers
457
+ );
458
+ if (header.binaryChunkHeader === true) this.binaryChunkHeaderCount += 1;
459
+ else this.legacyChunkHeaderCount += 1;
256
460
  const decodedFrameHeader = decodeFrameHeader(header);
257
461
  if (decodedFrameHeader) state.frameHeader = decodedFrameHeader;
258
462
  if (state.totalBytes > this.maxFrameBytes) {
259
- if (this.pending.delete(frameId)) this.droppedFrames += 1;
463
+ if (this.#detach(frameId)) this.droppedFrames += 1;
260
464
  return null;
261
465
  }
262
466
  if (state.received !== state.chunkCount) return null;
263
- this.pending.delete(frameId);
264
- const complete = decodeFramedPayload(
265
- Buffer.concat(state.chunks, state.totalBytes),
467
+ this.#detach(frameId);
468
+ const complete = decodeFramedChunks(
469
+ state.chunks,
470
+ state.totalBytes,
266
471
  state.frameHeader || header.frameHeader || {}
267
472
  );
268
473
  if (!complete) {
269
474
  this.droppedFrames += 1;
270
475
  return null;
271
476
  }
477
+ this.completedFrames += 1;
478
+ this.completedPayloadCopyCount += complete.payloadCopyCount;
479
+ this.completedPayloadCopyBytes += complete.payloadCopyBytes;
272
480
  return {
273
481
  frameId,
274
482
  header: complete.header,
@@ -278,21 +486,45 @@ export class UdpFrameReassembler {
278
486
 
279
487
  expire(now = Date.now()) {
280
488
  for (const [frameId, state] of this.pending) {
281
- if (now - state.createdAt > this.timeoutMs && this.pending.delete(frameId)) {
489
+ if (now - state.createdAt > this.timeoutMs && this.#detach(frameId)) {
282
490
  this.droppedFrames += 1;
283
491
  this.expiredFrames += 1;
284
492
  }
285
493
  }
286
494
  }
287
495
 
496
+ clear() {
497
+ this.pending.clear();
498
+ this.pendingBytes = 0;
499
+ this.pendingChunkBuffers = 0;
500
+ }
501
+
288
502
  getStats() {
289
503
  return {
290
504
  pendingFrames: this.pending.size,
505
+ pendingBytes: this.pendingBytes,
506
+ pendingChunkBuffers: this.pendingChunkBuffers,
507
+ pendingBytesHighWatermark: this.pendingBytesHighWatermark,
508
+ pendingChunkBuffersHighWatermark: this.pendingChunkBuffersHighWatermark,
291
509
  droppedFrames: this.droppedFrames,
292
510
  expiredFrames: this.expiredFrames,
293
- evictedFrames: this.evictedFrames
511
+ evictedFrames: this.evictedFrames,
512
+ completedFrames: this.completedFrames,
513
+ completedPayloadCopyCount: this.completedPayloadCopyCount,
514
+ completedPayloadCopyBytes: this.completedPayloadCopyBytes,
515
+ fullWireDuplicateCopyCount: this.fullWireDuplicateCopyCount,
516
+ binaryChunkHeaderCount: this.binaryChunkHeaderCount,
517
+ legacyChunkHeaderCount: this.legacyChunkHeaderCount
294
518
  };
295
519
  }
520
+
521
+ #detach(frameId) {
522
+ const state = this.pending.get(frameId);
523
+ if (!state || !this.pending.delete(frameId)) return null;
524
+ this.pendingBytes = Math.max(0, this.pendingBytes - state.totalBytes);
525
+ this.pendingChunkBuffers = Math.max(0, this.pendingChunkBuffers - state.received);
526
+ return state;
527
+ }
296
528
  }
297
529
 
298
530
  export function encodeRendezvousMessage(message) {