livekit-client 2.22.1 → 2.22.3
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/dist/livekit-client.e2ee.worker.js +1 -1
- package/dist/livekit-client.e2ee.worker.js.map +1 -1
- package/dist/livekit-client.e2ee.worker.mjs +663 -476
- package/dist/livekit-client.e2ee.worker.mjs.map +1 -1
- package/dist/livekit-client.esm.mjs +480 -157
- package/dist/livekit-client.esm.mjs.map +1 -1
- package/dist/livekit-client.fm.worker.js +1 -1
- package/dist/livekit-client.fm.worker.js.map +1 -1
- package/dist/livekit-client.fm.worker.mjs +8 -1
- package/dist/livekit-client.fm.worker.mjs.map +1 -1
- package/dist/livekit-client.umd.js +1 -1
- package/dist/livekit-client.umd.js.map +1 -1
- package/dist/src/api/SignalClient.d.ts.map +1 -1
- package/dist/src/api/WebSocketStream.d.ts.map +1 -1
- package/dist/src/api/utils.d.ts +1 -0
- package/dist/src/api/utils.d.ts.map +1 -1
- package/dist/src/e2ee/E2eeManager.d.ts +33 -0
- package/dist/src/e2ee/E2eeManager.d.ts.map +1 -1
- package/dist/src/e2ee/constants.d.ts +5 -0
- package/dist/src/e2ee/constants.d.ts.map +1 -1
- package/dist/src/e2ee/types.d.ts +24 -3
- package/dist/src/e2ee/types.d.ts.map +1 -1
- package/dist/src/e2ee/worker/DataCryptor.d.ts.map +1 -1
- package/dist/src/e2ee/worker/ErrorRateLimiter.d.ts +21 -0
- package/dist/src/e2ee/worker/ErrorRateLimiter.d.ts.map +1 -0
- package/dist/src/e2ee/worker/FrameCryptor.d.ts +43 -19
- package/dist/src/e2ee/worker/FrameCryptor.d.ts.map +1 -1
- package/dist/src/logger.d.ts +4 -0
- package/dist/src/logger.d.ts.map +1 -1
- package/dist/src/room/PCTransportManager.d.ts +12 -0
- package/dist/src/room/PCTransportManager.d.ts.map +1 -1
- package/dist/src/room/RTCEngine.d.ts +1 -0
- package/dist/src/room/RTCEngine.d.ts.map +1 -1
- package/dist/src/room/data-stream/incoming/StreamReader.d.ts +17 -17
- package/dist/src/room/data-stream/incoming/StreamReader.d.ts.map +1 -1
- package/dist/src/room/participant/LocalParticipant.d.ts.map +1 -1
- package/dist/src/room/participant/publishUtils.d.ts +16 -0
- package/dist/src/room/participant/publishUtils.d.ts.map +1 -1
- package/dist/src/room/track/LocalVideoTrack.d.ts +7 -0
- package/dist/src/room/track/LocalVideoTrack.d.ts.map +1 -1
- package/dist/src/room/track/options.d.ts +1 -1
- package/dist/src/room/utils.d.ts +34 -0
- package/dist/src/room/utils.d.ts.map +1 -1
- package/dist/ts4.2/api/utils.d.ts +1 -0
- package/dist/ts4.2/e2ee/E2eeManager.d.ts +33 -0
- package/dist/ts4.2/e2ee/constants.d.ts +5 -0
- package/dist/ts4.2/e2ee/types.d.ts +24 -3
- package/dist/ts4.2/e2ee/worker/ErrorRateLimiter.d.ts +21 -0
- package/dist/ts4.2/e2ee/worker/FrameCryptor.d.ts +43 -19
- package/dist/ts4.2/logger.d.ts +4 -0
- package/dist/ts4.2/room/PCTransportManager.d.ts +12 -0
- package/dist/ts4.2/room/RTCEngine.d.ts +1 -0
- package/dist/ts4.2/room/data-stream/incoming/StreamReader.d.ts +17 -17
- package/dist/ts4.2/room/participant/publishUtils.d.ts +16 -0
- package/dist/ts4.2/room/track/LocalVideoTrack.d.ts +7 -0
- package/dist/ts4.2/room/track/options.d.ts +1 -1
- package/dist/ts4.2/room/utils.d.ts +34 -0
- package/package.json +1 -1
- package/src/api/SignalClient.ts +2 -1
- package/src/api/WebSocketStream.ts +3 -8
- package/src/api/utils.ts +10 -0
- package/src/e2ee/E2eeManager.test.ts +196 -0
- package/src/e2ee/E2eeManager.ts +150 -31
- package/src/e2ee/constants.ts +6 -0
- package/src/e2ee/subscriberBlackScreen.test.ts +544 -0
- package/src/e2ee/types.ts +28 -3
- package/src/e2ee/worker/DataCryptor.ts +2 -1
- package/src/e2ee/worker/ErrorRateLimiter.test.ts +53 -0
- package/src/e2ee/worker/ErrorRateLimiter.ts +52 -0
- package/src/e2ee/worker/FrameCryptor.race.test.ts +9 -26
- package/src/e2ee/worker/FrameCryptor.test.ts +0 -1
- package/src/e2ee/worker/FrameCryptor.ts +202 -119
- package/src/e2ee/worker/e2ee.worker.ts +72 -17
- package/src/logger.ts +22 -0
- package/src/room/PCTransportManager.test.ts +35 -0
- package/src/room/PCTransportManager.ts +12 -4
- package/src/room/RTCEngine.ts +28 -7
- package/src/room/Room.ts +1 -1
- package/src/room/data-stream/incoming/IncomingDataStreamManager.test.ts +171 -0
- package/src/room/data-stream/incoming/IncomingDataStreamManager.ts +17 -18
- package/src/room/data-stream/incoming/StreamReader.ts +20 -50
- package/src/room/participant/LocalParticipant.ts +30 -14
- package/src/room/participant/publishUtils.test.ts +133 -0
- package/src/room/participant/publishUtils.ts +54 -19
- package/src/room/track/LocalVideoTrack.ts +15 -5
- package/src/room/track/options.ts +1 -1
- package/src/room/utils.test.ts +87 -0
- package/src/room/utils.ts +59 -0
|
@@ -18,6 +18,7 @@ import type {
|
|
|
18
18
|
ScriptTransformOptions,
|
|
19
19
|
} from '../types';
|
|
20
20
|
import { DataCryptor } from './DataCryptor';
|
|
21
|
+
import { ErrorRateLimiter } from './ErrorRateLimiter';
|
|
21
22
|
import { FrameCryptor, encryptionEnabledMap } from './FrameCryptor';
|
|
22
23
|
import { ParticipantKeyHandler } from './ParticipantKeyHandler';
|
|
23
24
|
|
|
@@ -36,8 +37,21 @@ let keyProviderOptions: KeyProviderOptions = KEY_PROVIDER_DEFAULTS;
|
|
|
36
37
|
|
|
37
38
|
let rtpMap: Map<number, VideoCodec> = new Map();
|
|
38
39
|
|
|
40
|
+
const dataDecryptErrorLimiter = new ErrorRateLimiter();
|
|
41
|
+
|
|
39
42
|
workerLogger.setDefaultLevel('info');
|
|
40
43
|
|
|
44
|
+
// Forward worker log calls to the main thread so they reach any
|
|
45
|
+
// setLogExtension consumer installed there. The main-thread workerLogger
|
|
46
|
+
// re-emits them, which invokes both the console and the extension.
|
|
47
|
+
workerLogger.methodFactory = (methodName) => (msg, context) => {
|
|
48
|
+
postMessage({
|
|
49
|
+
kind: 'log',
|
|
50
|
+
data: { level: methodName, msg, context },
|
|
51
|
+
});
|
|
52
|
+
};
|
|
53
|
+
workerLogger.setLevel(workerLogger.getLevel());
|
|
54
|
+
|
|
41
55
|
onmessage = (ev) => {
|
|
42
56
|
messageQueue.run(async () => {
|
|
43
57
|
const { kind, data }: E2EEWorkerMessage = ev.data;
|
|
@@ -45,7 +59,7 @@ onmessage = (ev) => {
|
|
|
45
59
|
switch (kind) {
|
|
46
60
|
case 'init':
|
|
47
61
|
workerLogger.setLevel(data.loglevel);
|
|
48
|
-
workerLogger.info('worker initialized');
|
|
62
|
+
workerLogger.info('e2ee worker initialized');
|
|
49
63
|
keyProviderOptions = data.keyProviderOptions;
|
|
50
64
|
useSharedKey = !!data.keyProviderOptions.sharedKey;
|
|
51
65
|
// acknowledge init successful
|
|
@@ -55,6 +69,9 @@ onmessage = (ev) => {
|
|
|
55
69
|
};
|
|
56
70
|
postMessage(ackMsg);
|
|
57
71
|
break;
|
|
72
|
+
case 'setLogLevel':
|
|
73
|
+
workerLogger.setLevel(data.level);
|
|
74
|
+
break;
|
|
58
75
|
case 'enable':
|
|
59
76
|
setEncryptionEnabled(data.enabled, data.participantIdentity);
|
|
60
77
|
workerLogger.info(
|
|
@@ -71,7 +88,6 @@ onmessage = (ev) => {
|
|
|
71
88
|
data.readableStream,
|
|
72
89
|
data.writableStream,
|
|
73
90
|
data.trackId,
|
|
74
|
-
data.isReuse,
|
|
75
91
|
data.codec,
|
|
76
92
|
);
|
|
77
93
|
break;
|
|
@@ -83,7 +99,6 @@ onmessage = (ev) => {
|
|
|
83
99
|
data.readableStream,
|
|
84
100
|
data.writableStream,
|
|
85
101
|
data.trackId,
|
|
86
|
-
data.isReuse,
|
|
87
102
|
data.codec,
|
|
88
103
|
data.packetTrailer, // wire format still uses 'packetTrailer' field name
|
|
89
104
|
);
|
|
@@ -98,11 +113,6 @@ onmessage = (ev) => {
|
|
|
98
113
|
data.payload,
|
|
99
114
|
getParticipantKeyHandler(data.participantIdentity),
|
|
100
115
|
);
|
|
101
|
-
console.log('encrypted payload', {
|
|
102
|
-
original: data.payload,
|
|
103
|
-
encrypted: encryptedPayload,
|
|
104
|
-
iv,
|
|
105
|
-
});
|
|
106
116
|
postMessage({
|
|
107
117
|
kind: 'encryptDataResponse',
|
|
108
118
|
data: {
|
|
@@ -127,12 +137,23 @@ onmessage = (ev) => {
|
|
|
127
137
|
data: { payload: decryptedPayload, uuid: data.uuid },
|
|
128
138
|
} satisfies DecryptDataResponseMessage);
|
|
129
139
|
} catch (error) {
|
|
130
|
-
// Send error back to main thread with uuid so it can reject the corresponding promise
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
140
|
+
// Send error back to main thread with uuid so it can reject the corresponding promise.
|
|
141
|
+
// The error response must always be posted so the awaiting future resolves; only the
|
|
142
|
+
// log is throttled to avoid flooding when a broken key keeps producing failures.
|
|
143
|
+
const errorKey = `${data.participantIdentity}-datadecrypt`;
|
|
144
|
+
const shouldLog = dataDecryptErrorLimiter.shouldEmit(errorKey, () => {
|
|
145
|
+
workerLogger.warn(
|
|
146
|
+
`Suppressing further data decryption errors for ${data.participantIdentity}`,
|
|
147
|
+
{ errorKey },
|
|
148
|
+
);
|
|
135
149
|
});
|
|
150
|
+
if (shouldLog) {
|
|
151
|
+
workerLogger.error('DataCryptor decryption failed', {
|
|
152
|
+
error,
|
|
153
|
+
participantIdentity: data.participantIdentity,
|
|
154
|
+
uuid: data.uuid,
|
|
155
|
+
});
|
|
156
|
+
}
|
|
136
157
|
postMessage({
|
|
137
158
|
kind: 'error',
|
|
138
159
|
data: {
|
|
@@ -163,15 +184,32 @@ onmessage = (ev) => {
|
|
|
163
184
|
unsetCryptorParticipant(data.trackId, data.participantIdentity);
|
|
164
185
|
break;
|
|
165
186
|
case 'updateCodec':
|
|
166
|
-
const trackCryptor = getTrackCryptor(
|
|
167
|
-
|
|
187
|
+
const trackCryptor = getTrackCryptor(
|
|
188
|
+
data.participantIdentity,
|
|
189
|
+
data.trackId,
|
|
190
|
+
data.previousTrackId,
|
|
191
|
+
);
|
|
192
|
+
if (data.codec) {
|
|
193
|
+
trackCryptor.setVideoCodec(data.codec);
|
|
194
|
+
}
|
|
168
195
|
trackCryptor.setHasFrameMetadata(data.hasPacketTrailer);
|
|
169
196
|
workerLogger.info('updated codec', {
|
|
170
197
|
participantIdentity: data.participantIdentity,
|
|
171
198
|
trackId: data.trackId,
|
|
199
|
+
previousTrackId: data.previousTrackId,
|
|
172
200
|
codec: data.codec,
|
|
173
201
|
hasPacketTrailer: data.hasPacketTrailer,
|
|
174
202
|
});
|
|
203
|
+
// Sent on transceiver reuse, where the main thread cannot hand us a new
|
|
204
|
+
// pipeline. If the previous one died in the meantime we have to rebuild it
|
|
205
|
+
// here or no frame ever gets decrypted again.
|
|
206
|
+
if (data.previousTrackId !== undefined && !trackCryptor.ensureTransform()) {
|
|
207
|
+
workerLogger.error('could not re-establish transform for reused receiver', {
|
|
208
|
+
participantIdentity: data.participantIdentity,
|
|
209
|
+
trackId: data.trackId,
|
|
210
|
+
previousTrackId: data.previousTrackId,
|
|
211
|
+
});
|
|
212
|
+
}
|
|
175
213
|
break;
|
|
176
214
|
case 'setRTPMap':
|
|
177
215
|
// this is only used for the local participant
|
|
@@ -210,8 +248,26 @@ async function handleRatchetRequest(data: RatchetRequestMessage['data']) {
|
|
|
210
248
|
}
|
|
211
249
|
}
|
|
212
250
|
|
|
213
|
-
function getTrackCryptor(participantIdentity: string, trackId: string) {
|
|
251
|
+
function getTrackCryptor(participantIdentity: string, trackId: string, previousTrackId?: string) {
|
|
214
252
|
let cryptors = participantCryptors.filter((c) => c.getTrackId() === trackId);
|
|
253
|
+
|
|
254
|
+
if (cryptors.length === 0 && previousTrackId !== undefined && previousTrackId !== trackId) {
|
|
255
|
+
// A reused transceiver carries a new trackId, but the cryptor that owns this
|
|
256
|
+
// receiver's encoded streams is still keyed on the old one. Re-point it instead
|
|
257
|
+
// of creating a fresh cryptor that has no pipeline (and leaving the old one
|
|
258
|
+
// piping frames with no participant assigned).
|
|
259
|
+
const previous = participantCryptors.filter((c) => c.getTrackId() === previousTrackId);
|
|
260
|
+
if (previous.length > 0) {
|
|
261
|
+
workerLogger.info('reusing cryptor from previous trackId', {
|
|
262
|
+
participantIdentity,
|
|
263
|
+
trackId,
|
|
264
|
+
previousTrackId,
|
|
265
|
+
});
|
|
266
|
+
previous[0].setTrackId(trackId);
|
|
267
|
+
cryptors = previous;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
215
271
|
if (cryptors.length > 1) {
|
|
216
272
|
const debugInfo = cryptors
|
|
217
273
|
.map((c) => {
|
|
@@ -351,7 +407,6 @@ if (self.RTCTransformEvent) {
|
|
|
351
407
|
transformer.readable,
|
|
352
408
|
transformer.writable,
|
|
353
409
|
trackId,
|
|
354
|
-
false,
|
|
355
410
|
codec,
|
|
356
411
|
kind === 'encode' ? options.packetTrailer : undefined,
|
|
357
412
|
);
|
package/src/logger.ts
CHANGED
|
@@ -131,3 +131,25 @@ export function setLogExtension(extension: LogExtension, logger?: StructuredLogg
|
|
|
131
131
|
}
|
|
132
132
|
|
|
133
133
|
export const workerLogger = log.getLogger(LoggerNames.E2EE) as StructuredLogger;
|
|
134
|
+
|
|
135
|
+
const workerLogLevelListeners = new Set<(level: LogLevel) => void>();
|
|
136
|
+
|
|
137
|
+
const originalWorkerSetLevel = workerLogger.setLevel.bind(workerLogger);
|
|
138
|
+
workerLogger.setLevel = ((level: log.LogLevelDesc, persist?: boolean) => {
|
|
139
|
+
originalWorkerSetLevel(level, persist);
|
|
140
|
+
const numeric = workerLogger.getLevel() as LogLevel;
|
|
141
|
+
workerLogLevelListeners.forEach((cb) => cb(numeric));
|
|
142
|
+
}) as typeof workerLogger.setLevel;
|
|
143
|
+
|
|
144
|
+
/** @internal Subscribe to workerLogger level changes (so E2EE workers can be kept in sync). */
|
|
145
|
+
export function onWorkerLogLevelChanged(cb: (level: LogLevel) => void): () => void {
|
|
146
|
+
workerLogLevelListeners.add(cb);
|
|
147
|
+
return () => {
|
|
148
|
+
workerLogLevelListeners.delete(cb);
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** @internal Test-only accessor: current number of workerLogger level listeners. */
|
|
153
|
+
export function getWorkerLogLevelListenerCount(): number {
|
|
154
|
+
return workerLogLevelListeners.size;
|
|
155
|
+
}
|
|
@@ -279,3 +279,38 @@ describe('PCTransportManager.negotiate', () => {
|
|
|
279
279
|
await expect(p).resolves.toBeUndefined();
|
|
280
280
|
});
|
|
281
281
|
});
|
|
282
|
+
|
|
283
|
+
describe('PCTransportManager.triggerIceRestart', () => {
|
|
284
|
+
let originalRTCPeerConnection: unknown;
|
|
285
|
+
|
|
286
|
+
beforeEach(() => {
|
|
287
|
+
originalRTCPeerConnection = (globalThis as unknown as { RTCPeerConnection?: unknown })
|
|
288
|
+
.RTCPeerConnection;
|
|
289
|
+
(globalThis as unknown as { RTCPeerConnection: unknown }).RTCPeerConnection = StubPC;
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
afterEach(() => {
|
|
293
|
+
(globalThis as unknown as { RTCPeerConnection: unknown }).RTCPeerConnection =
|
|
294
|
+
originalRTCPeerConnection;
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* The subscriber must keep applying remote candidates across a reconnect.
|
|
299
|
+
*
|
|
300
|
+
* Putting it into `restartingIce` would queue them until a new remote description arrives —
|
|
301
|
+
* but the server only re-offers the subscriber when the reconnect moved us to another node,
|
|
302
|
+
* so on an ordinary signal-only resume nothing would ever flush that queue, and the
|
|
303
|
+
* transport would stop adopting new network paths for the rest of the session. The server
|
|
304
|
+
* also never sends candidates ahead of the offer that introduces them, so queueing buys
|
|
305
|
+
* nothing in exchange.
|
|
306
|
+
*/
|
|
307
|
+
it('does not stop the subscriber applying remote candidates', async () => {
|
|
308
|
+
const manager = new PCTransportManager('subscriber-primary', {});
|
|
309
|
+
const publisher = new FakePublisher();
|
|
310
|
+
(manager as unknown as { publisher: FakePublisher }).publisher = publisher;
|
|
311
|
+
|
|
312
|
+
await manager.triggerIceRestart();
|
|
313
|
+
|
|
314
|
+
expect(manager.subscriber?.restartingIce).toBe(false);
|
|
315
|
+
});
|
|
316
|
+
});
|
|
@@ -162,12 +162,20 @@ export class PCTransportManager {
|
|
|
162
162
|
this.updateState();
|
|
163
163
|
}
|
|
164
164
|
|
|
165
|
+
/**
|
|
166
|
+
* Restarts ICE on the transports that need it. Only the publisher: the server restarts the
|
|
167
|
+
* subscriber's ICE itself and follows with a fresh offer.
|
|
168
|
+
*
|
|
169
|
+
* The subscriber deliberately does NOT enter `restartingIce` here. Queueing its remote
|
|
170
|
+
* candidates would guard against candidates for a new generation arriving before the offer
|
|
171
|
+
* that introduces it, but the server does not send them in that order -- on a same-node
|
|
172
|
+
* resume it buffers them until the offer has gone out, and on a reconnect that lands on
|
|
173
|
+
* another node it withholds subscriber candidates until immediately before creating the
|
|
174
|
+
* offer. Setting the flag only risks withholding candidates during the window that decides
|
|
175
|
+
* whether the reconnect succeeded.
|
|
176
|
+
*/
|
|
165
177
|
async triggerIceRestart() {
|
|
166
178
|
this.iceLog.warn('triggering ICE restart');
|
|
167
|
-
if (this.subscriber) {
|
|
168
|
-
this.subscriber.restartingIce = true;
|
|
169
|
-
}
|
|
170
|
-
// only restart publisher if it's needed
|
|
171
179
|
if (this.needsPublisher) {
|
|
172
180
|
await this.createAndSendPublisherOffer({ iceRestart: true });
|
|
173
181
|
}
|
package/src/room/RTCEngine.ts
CHANGED
|
@@ -173,6 +173,14 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
|
|
|
173
173
|
return !!this.reconnectTimeout;
|
|
174
174
|
}
|
|
175
175
|
|
|
176
|
+
get serverVersion(): string | undefined {
|
|
177
|
+
return (
|
|
178
|
+
this.latestJoinResponse?.serverInfo?.version ||
|
|
179
|
+
this.latestJoinResponse?.serverVersion ||
|
|
180
|
+
undefined
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
|
|
176
184
|
/**
|
|
177
185
|
* Owns the data channels: the three flow-controlled publisher wrappers (engine-lifetime; the
|
|
178
186
|
* RTCDataChannel handles underneath are attached/detached as peer connections come and go, with
|
|
@@ -655,7 +663,11 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
|
|
|
655
663
|
sdp: sd.sdp,
|
|
656
664
|
midToTrackId,
|
|
657
665
|
});
|
|
658
|
-
|
|
666
|
+
// in dual PC mode the publisher answer carries no mapping (the server's publisher
|
|
667
|
+
// transport has no sending tracks) and must not clobber the subscriber offer mapping
|
|
668
|
+
if (this.pcManager.mode === 'publisher-only') {
|
|
669
|
+
this.midToTrackId = midToTrackId;
|
|
670
|
+
}
|
|
659
671
|
await this.pcManager.setPublisherAnswer(sd, offerId);
|
|
660
672
|
};
|
|
661
673
|
|
|
@@ -918,12 +930,21 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
|
|
|
918
930
|
this.log.error('Received encrypted packet but E2EE not set up');
|
|
919
931
|
return;
|
|
920
932
|
}
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
933
|
+
let decryptedData;
|
|
934
|
+
try {
|
|
935
|
+
decryptedData = await this.e2eeManager.handleEncryptedData(
|
|
936
|
+
dp.value.value.encryptedValue as NonSharedUint8Array,
|
|
937
|
+
dp.value.value.iv as NonSharedUint8Array,
|
|
938
|
+
dp.participantIdentity,
|
|
939
|
+
dp.value.value.keyIndex,
|
|
940
|
+
);
|
|
941
|
+
} catch (err) {
|
|
942
|
+
this.log.debug('failed to decrypt data packet', {
|
|
943
|
+
error: err,
|
|
944
|
+
participantIdentity: dp.participantIdentity,
|
|
945
|
+
});
|
|
946
|
+
return;
|
|
947
|
+
}
|
|
927
948
|
const decryptedPacket = EncryptedPacketPayload.fromBinary(decryptedData.payload);
|
|
928
949
|
const newDp = new DataPacket({
|
|
929
950
|
value: decryptedPacket.value,
|
package/src/room/Room.ts
CHANGED
|
@@ -339,7 +339,7 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
|
|
|
339
339
|
this.log,
|
|
340
340
|
this.outgoingDataStreamManager,
|
|
341
341
|
this.getRemoteParticipantClientProtocol,
|
|
342
|
-
() => this.engine
|
|
342
|
+
() => this.engine?.serverVersion,
|
|
343
343
|
);
|
|
344
344
|
this.rpcClientManager.on('sendDataPacket', ({ packet }) => {
|
|
345
345
|
this.engine?.sendDataPacket(packet, DataChannelKind.RELIABLE);
|
|
@@ -1237,6 +1237,177 @@ describe('IncomingDataStreamManager', () => {
|
|
|
1237
1237
|
await expect(reader.readAll()).rejects.toThrow('Missing chunk(s)');
|
|
1238
1238
|
});
|
|
1239
1239
|
|
|
1240
|
+
it('should error on a gap in chunk indices on an uncompressed text stream', async () => {
|
|
1241
|
+
const manager = new IncomingDataStreamManager();
|
|
1242
|
+
manager.setConnected(true);
|
|
1243
|
+
|
|
1244
|
+
const readerPromise = new Promise<TextStreamReader>((resolve) => {
|
|
1245
|
+
manager.registerTextStreamHandler('my-topic', (reader) => resolve(reader));
|
|
1246
|
+
});
|
|
1247
|
+
|
|
1248
|
+
const streamId = crypto.randomUUID();
|
|
1249
|
+
const text = randomText(3_000);
|
|
1250
|
+
const textBytes = new TextEncoder().encode(text);
|
|
1251
|
+
const split = Math.floor(textBytes.length / 3);
|
|
1252
|
+
|
|
1253
|
+
manager.handleDataStreamPacket(
|
|
1254
|
+
headerPacket(streamId, 'textHeader', {
|
|
1255
|
+
totalLength: BigInt(textBytes.length),
|
|
1256
|
+
compression: DataStream_CompressionType.NONE,
|
|
1257
|
+
}),
|
|
1258
|
+
Encryption_Type.NONE,
|
|
1259
|
+
);
|
|
1260
|
+
manager.handleDataStreamPacket(
|
|
1261
|
+
chunkPacket(streamId, 0, textBytes.slice(0, split)),
|
|
1262
|
+
Encryption_Type.NONE,
|
|
1263
|
+
);
|
|
1264
|
+
// Skip chunk index 1 entirely — a gap means the payload cannot be reassembled in order.
|
|
1265
|
+
manager.handleDataStreamPacket(
|
|
1266
|
+
chunkPacket(streamId, 2, textBytes.slice(split)),
|
|
1267
|
+
Encryption_Type.NONE,
|
|
1268
|
+
);
|
|
1269
|
+
|
|
1270
|
+
const reader = await readerPromise;
|
|
1271
|
+
await expect(reader.readAll()).rejects.toThrow('Missing chunk(s)');
|
|
1272
|
+
});
|
|
1273
|
+
|
|
1274
|
+
it('should error on a gap in chunk indices on an uncompressed byte stream', async () => {
|
|
1275
|
+
const manager = new IncomingDataStreamManager();
|
|
1276
|
+
manager.setConnected(true);
|
|
1277
|
+
|
|
1278
|
+
const readerPromise = new Promise<ByteStreamReader>((resolve) => {
|
|
1279
|
+
manager.registerByteStreamHandler('my-topic', (reader) => resolve(reader));
|
|
1280
|
+
});
|
|
1281
|
+
|
|
1282
|
+
const streamId = crypto.randomUUID();
|
|
1283
|
+
const bytes = randomBytes(3_000);
|
|
1284
|
+
const split = Math.floor(bytes.length / 3);
|
|
1285
|
+
|
|
1286
|
+
manager.handleDataStreamPacket(
|
|
1287
|
+
headerPacket(streamId, 'byteHeader', {
|
|
1288
|
+
totalLength: BigInt(bytes.length),
|
|
1289
|
+
compression: DataStream_CompressionType.NONE,
|
|
1290
|
+
}),
|
|
1291
|
+
Encryption_Type.NONE,
|
|
1292
|
+
);
|
|
1293
|
+
manager.handleDataStreamPacket(
|
|
1294
|
+
chunkPacket(streamId, 0, bytes.slice(0, split)),
|
|
1295
|
+
Encryption_Type.NONE,
|
|
1296
|
+
);
|
|
1297
|
+
// Skip chunk index 1 entirely.
|
|
1298
|
+
manager.handleDataStreamPacket(
|
|
1299
|
+
chunkPacket(streamId, 2, bytes.slice(split)),
|
|
1300
|
+
Encryption_Type.NONE,
|
|
1301
|
+
);
|
|
1302
|
+
|
|
1303
|
+
const reader = await readerPromise;
|
|
1304
|
+
await expect(reader.readAll()).rejects.toThrow('Missing chunk(s)');
|
|
1305
|
+
});
|
|
1306
|
+
|
|
1307
|
+
it('should drop a duplicate chunk index on an uncompressed text stream and still decode', async () => {
|
|
1308
|
+
const manager = new IncomingDataStreamManager();
|
|
1309
|
+
manager.setConnected(true);
|
|
1310
|
+
|
|
1311
|
+
const readerPromise = new Promise<TextStreamReader>((resolve) => {
|
|
1312
|
+
manager.registerTextStreamHandler('my-topic', (reader) => resolve(reader));
|
|
1313
|
+
});
|
|
1314
|
+
|
|
1315
|
+
const streamId = crypto.randomUUID();
|
|
1316
|
+
const text = randomText(3_000);
|
|
1317
|
+
const textBytes = new TextEncoder().encode(text);
|
|
1318
|
+
const split = Math.floor(textBytes.length / 2);
|
|
1319
|
+
|
|
1320
|
+
manager.handleDataStreamPacket(
|
|
1321
|
+
headerPacket(streamId, 'textHeader', {
|
|
1322
|
+
totalLength: BigInt(textBytes.length),
|
|
1323
|
+
compression: DataStream_CompressionType.NONE,
|
|
1324
|
+
}),
|
|
1325
|
+
Encryption_Type.NONE,
|
|
1326
|
+
);
|
|
1327
|
+
const chunk0 = chunkPacket(streamId, 0, textBytes.slice(0, split));
|
|
1328
|
+
manager.handleDataStreamPacket(chunk0, Encryption_Type.NONE);
|
|
1329
|
+
// A replayed chunk (e.g. reconnect logic) must be dropped with a warning, not appended a
|
|
1330
|
+
// second time — otherwise the payload is corrupted and exceeds `totalLength`.
|
|
1331
|
+
manager.handleDataStreamPacket(chunk0, Encryption_Type.NONE);
|
|
1332
|
+
manager.handleDataStreamPacket(
|
|
1333
|
+
chunkPacket(streamId, 1, textBytes.slice(split)),
|
|
1334
|
+
Encryption_Type.NONE,
|
|
1335
|
+
);
|
|
1336
|
+
manager.handleDataStreamPacket(trailerPacket(streamId), Encryption_Type.NONE);
|
|
1337
|
+
|
|
1338
|
+
const reader = await readerPromise;
|
|
1339
|
+
expect(await reader.readAll()).toStrictEqual(text);
|
|
1340
|
+
});
|
|
1341
|
+
|
|
1342
|
+
it('should drop a duplicate chunk index on an uncompressed byte stream and still decode', async () => {
|
|
1343
|
+
const manager = new IncomingDataStreamManager();
|
|
1344
|
+
manager.setConnected(true);
|
|
1345
|
+
|
|
1346
|
+
const readerPromise = new Promise<ByteStreamReader>((resolve) => {
|
|
1347
|
+
manager.registerByteStreamHandler('my-topic', (reader) => resolve(reader));
|
|
1348
|
+
});
|
|
1349
|
+
|
|
1350
|
+
const streamId = crypto.randomUUID();
|
|
1351
|
+
const bytes = randomBytes(3_000);
|
|
1352
|
+
const split = Math.floor(bytes.length / 2);
|
|
1353
|
+
|
|
1354
|
+
manager.handleDataStreamPacket(
|
|
1355
|
+
headerPacket(streamId, 'byteHeader', {
|
|
1356
|
+
totalLength: BigInt(bytes.length),
|
|
1357
|
+
compression: DataStream_CompressionType.NONE,
|
|
1358
|
+
}),
|
|
1359
|
+
Encryption_Type.NONE,
|
|
1360
|
+
);
|
|
1361
|
+
const chunk0 = chunkPacket(streamId, 0, bytes.slice(0, split));
|
|
1362
|
+
manager.handleDataStreamPacket(chunk0, Encryption_Type.NONE);
|
|
1363
|
+
manager.handleDataStreamPacket(chunk0, Encryption_Type.NONE);
|
|
1364
|
+
manager.handleDataStreamPacket(
|
|
1365
|
+
chunkPacket(streamId, 1, bytes.slice(split)),
|
|
1366
|
+
Encryption_Type.NONE,
|
|
1367
|
+
);
|
|
1368
|
+
manager.handleDataStreamPacket(trailerPacket(streamId), Encryption_Type.NONE);
|
|
1369
|
+
|
|
1370
|
+
const reader = await readerPromise;
|
|
1371
|
+
expect(concatChunks(await reader.readAll())).toStrictEqual(bytes);
|
|
1372
|
+
});
|
|
1373
|
+
|
|
1374
|
+
it('should drop chunks resent at an already-received index', async () => {
|
|
1375
|
+
const manager = new IncomingDataStreamManager();
|
|
1376
|
+
manager.setConnected(true);
|
|
1377
|
+
|
|
1378
|
+
const readerPromise = new Promise<TextStreamReader>((resolve) => {
|
|
1379
|
+
manager.registerTextStreamHandler('my-topic', (reader) => resolve(reader));
|
|
1380
|
+
});
|
|
1381
|
+
|
|
1382
|
+
const streamId = crypto.randomUUID();
|
|
1383
|
+
const text = 'hello world';
|
|
1384
|
+
const textBytes = new TextEncoder().encode(text);
|
|
1385
|
+
|
|
1386
|
+
manager.handleDataStreamPacket(
|
|
1387
|
+
headerPacket(streamId, 'textHeader', { totalLength: BigInt(textBytes.length) }),
|
|
1388
|
+
Encryption_Type.NONE,
|
|
1389
|
+
);
|
|
1390
|
+
// first assuring a version going from 1 -> 2 works as expected
|
|
1391
|
+
manager.handleDataStreamPacket(chunkPacket(streamId, 0, textBytes, 1), Encryption_Type.NONE);
|
|
1392
|
+
// Chunk-level `version` retcon is not supported: a reader that has already yielded chunk 0 to
|
|
1393
|
+
// its consumer cannot retract it, so a resend at the same index is dropped like any other
|
|
1394
|
+
// duplicate rather than superseding the original. See the note on
|
|
1395
|
+
// `TextStreamReader.handleChunkReceived`.
|
|
1396
|
+
manager.handleDataStreamPacket(
|
|
1397
|
+
chunkPacket(streamId, 0, new TextEncoder().encode('goodbye world'), 2),
|
|
1398
|
+
Encryption_Type.NONE,
|
|
1399
|
+
);
|
|
1400
|
+
// sending a lower version number again to ensure this one also gets dropped
|
|
1401
|
+
manager.handleDataStreamPacket(
|
|
1402
|
+
chunkPacket(streamId, 0, new TextEncoder().encode('goodbye world'), 0),
|
|
1403
|
+
Encryption_Type.NONE,
|
|
1404
|
+
);
|
|
1405
|
+
manager.handleDataStreamPacket(trailerPacket(streamId), Encryption_Type.NONE);
|
|
1406
|
+
|
|
1407
|
+
const reader = await readerPromise;
|
|
1408
|
+
expect(await reader.readAll()).toStrictEqual(text);
|
|
1409
|
+
});
|
|
1410
|
+
|
|
1240
1411
|
it('should reframe multibyte UTF-8 on chunk boundaries when decompressing a text stream', async () => {
|
|
1241
1412
|
const manager = new IncomingDataStreamManager();
|
|
1242
1413
|
manager.setConnected(true);
|
|
@@ -241,7 +241,7 @@ export default class IncomingDataStreamManager {
|
|
|
241
241
|
info,
|
|
242
242
|
compressed
|
|
243
243
|
? inflateRawByteChunkStream(stream, streamHeader.streamId, this.maxPayloadByteLength)
|
|
244
|
-
: stream,
|
|
244
|
+
: stream.pipeThrough(ensureOrderedChunks(streamHeader.streamId)),
|
|
245
245
|
// `totalLength` is the pre-compression size, and the reader counts decompressed bytes,
|
|
246
246
|
// so it applies to both paths (mirrors text).
|
|
247
247
|
bigIntToNumber(streamHeader.totalLength),
|
|
@@ -344,7 +344,7 @@ export default class IncomingDataStreamManager {
|
|
|
344
344
|
info,
|
|
345
345
|
compressed
|
|
346
346
|
? inflateRawChunkStream(stream, streamHeader.streamId, this.maxPayloadByteLength)
|
|
347
|
-
: stream,
|
|
347
|
+
: stream.pipeThrough(ensureOrderedChunks(streamHeader.streamId)),
|
|
348
348
|
// `totalLength` is the pre-compression size, and the reader sees decompressed bytes, so
|
|
349
349
|
// it applies to both paths.
|
|
350
350
|
bigIntToNumber(streamHeader.totalLength),
|
|
@@ -367,7 +367,7 @@ export default class IncomingDataStreamManager {
|
|
|
367
367
|
),
|
|
368
368
|
);
|
|
369
369
|
this.byteStreamControllers.delete(chunk.streamId);
|
|
370
|
-
} else
|
|
370
|
+
} else {
|
|
371
371
|
fileBuffer.controller.enqueue(chunk);
|
|
372
372
|
}
|
|
373
373
|
}
|
|
@@ -381,7 +381,7 @@ export default class IncomingDataStreamManager {
|
|
|
381
381
|
),
|
|
382
382
|
);
|
|
383
383
|
this.textStreamControllers.delete(chunk.streamId);
|
|
384
|
-
} else
|
|
384
|
+
} else {
|
|
385
385
|
textBuffer.controller.enqueue(chunk);
|
|
386
386
|
}
|
|
387
387
|
}
|
|
@@ -455,15 +455,9 @@ function createInlineStream(
|
|
|
455
455
|
): ReadableStream<DataStream_Chunk> {
|
|
456
456
|
return new ReadableStream<DataStream_Chunk>({
|
|
457
457
|
start: async (controller) => {
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
new DataStream_Chunk({ streamId, chunkIndex: BigInt(0), content: bytes }),
|
|
462
|
-
);
|
|
463
|
-
controller.close();
|
|
464
|
-
} catch (err) {
|
|
465
|
-
controller.error(err);
|
|
466
|
-
}
|
|
458
|
+
const bytes = await content;
|
|
459
|
+
controller.enqueue(new DataStream_Chunk({ streamId, chunkIndex: BigInt(0), content: bytes }));
|
|
460
|
+
controller.close();
|
|
467
461
|
},
|
|
468
462
|
});
|
|
469
463
|
}
|
|
@@ -471,9 +465,11 @@ function createInlineStream(
|
|
|
471
465
|
/**
|
|
472
466
|
* Validates that chunks are received in order, dropping duplicates and throwing if gaps are found.
|
|
473
467
|
*
|
|
474
|
-
*
|
|
475
|
-
* dropped (with a warning - in-order delivery is expected
|
|
476
|
-
* handling may replay) and a gap is a hard error.
|
|
468
|
+
* Reassembly (and, for compressed streams, a stateful decompressor) silently corrupts on duplicated
|
|
469
|
+
* or out-of-order input, so duplicates are dropped (with a warning - in-order delivery is expected
|
|
470
|
+
* on the reliable channel, but reconnect handling may replay) and a gap is a hard error. Empty
|
|
471
|
+
* chunks consume their index and are then dropped, so they never reach the reader. Applied to every
|
|
472
|
+
* chunked stream, compressed or not.
|
|
477
473
|
*/
|
|
478
474
|
function ensureOrderedChunks(
|
|
479
475
|
streamId: string,
|
|
@@ -484,17 +480,20 @@ function ensureOrderedChunks(
|
|
|
484
480
|
const index = bigIntToNumber(value.chunkIndex);
|
|
485
481
|
if (index <= lastChunkIndex) {
|
|
486
482
|
log.warn(
|
|
487
|
-
`ignoring duplicate chunk ${index} for
|
|
483
|
+
`ignoring duplicate chunk ${index} ${value.version > 0 ? `(version ${value.version})` : ''} for data stream ${streamId} (last processed: ${lastChunkIndex})`,
|
|
488
484
|
);
|
|
489
485
|
return;
|
|
490
486
|
}
|
|
491
487
|
if (index > lastChunkIndex + 1) {
|
|
492
488
|
throw new DataStreamError(
|
|
493
|
-
`Missing chunk(s) ${lastChunkIndex + 1}..${index - 1} for
|
|
489
|
+
`Missing chunk(s) ${lastChunkIndex + 1}..${index - 1} for data stream ${streamId} - cannot reassemble payload`,
|
|
494
490
|
DataStreamErrorReason.Incomplete,
|
|
495
491
|
);
|
|
496
492
|
}
|
|
497
493
|
lastChunkIndex = index;
|
|
494
|
+
if (value.content.length === 0) {
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
498
497
|
controller.enqueue(value);
|
|
499
498
|
},
|
|
500
499
|
});
|