ep_webrtc 2.1.1 → 2.2.2

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/README.md CHANGED
@@ -185,6 +185,31 @@ Example:
185
185
  },
186
186
  ```
187
187
 
188
+ #### Horizontally scaled TURN servers
189
+
190
+ To spread load across multiple TURN services, you can enable sharding:
191
+
192
+ ```json
193
+ "ep_webrtc": {
194
+ "iceServers": [
195
+ {"urls": ["stun:shard0.example.com", "turn:shard0.example.com"]},
196
+ {"urls": ["stun:shard1.example.com", "turn:shard1.example.com"]},
197
+ {"urls": ["stun:shard2.example.com", "turn:shard2.example.com"]},
198
+ {"urls": ["stun:shard3.example.com", "turn:shard3.example.com"]},
199
+ ],
200
+ "shardIceServers": true
201
+ },
202
+ ```
203
+
204
+ When `shardIceServers` is `false` (the default), all clients receive all
205
+ RTCIceServer objects in the `iceServers` list and it's up to the browser to
206
+ figure out how to use them to connect with peers. When `true`, this plugin
207
+ assigns a single entry from `iceServers` to each pad and gives out only that
208
+ assigned entry to users that connect to the pad. The intention is to provide a
209
+ better guarantee of load distribution across a set of TURN servers, and to avoid
210
+ an unnecessary network hop when both peers are configured to force the use of
211
+ TURN.
212
+
188
213
  ### Microphone Settings
189
214
 
190
215
  The microphone can be configured by setting `audio.constraints` to any [audio
package/index.js CHANGED
@@ -49,8 +49,10 @@ const defaultSettings = {
49
49
  iceServers: [{urls: ['stun:stun.l.google.com:19302']}],
50
50
  listenClass: null,
51
51
  moreInfoUrl: {},
52
+ shardIceServers: false,
52
53
  };
53
54
  let settings = null;
55
+ let shardIceServersHmacSecret;
54
56
  let socketio;
55
57
 
56
58
  const addContextToError = (err, pfx) => {
@@ -139,54 +141,90 @@ const fetchJson = async (url, opts = {}) => {
139
141
  return await res.json();
140
142
  };
141
143
 
142
- exports.clientVars = async (hookName, {clientVars: {userId: authorId}}) => ({ep_webrtc: {
143
- ...settings,
144
- iceServers: await Promise.all(settings.iceServers.map(async (server) => {
145
- switch (server.credentialType) {
146
- case 'coturn ephemeral password': {
147
- const {lifetime = 60 * 60 * 12 /* seconds */} = server;
148
- const username = `${Math.floor(Date.now() / 1000) + lifetime}:${authorId}`;
149
- const hmac = crypto.createHmac('sha1', server.credential);
150
- hmac.update(username);
151
- const credential = hmac.digest('base64');
152
- return {urls: server.urls, username, credential};
153
- }
154
- case 'xirsys ephemeral credentials': {
155
- const {
156
- url,
157
- username,
158
- credential,
159
- lifetime: expire = 12 * 60 * 60, // seconds
160
- method = 'PUT',
161
- headers: h = {},
162
- jsonBody: b = {},
163
- } = server;
164
- // Can't set default values for the Content-Type and Authorization headers by using an
165
- // object literal with spread (e.g., `{'content-type': 'foo', ...h}`) because the Headers
166
- // constructor uses `.append()` internally instead of `.set()`. This matters if a header is
167
- // repeated multiple times by using different mixes of upper- and lower-case letters.
168
- const headers = new globalThis.Headers(h);
169
- if (!headers.has('content-type')) headers.set('content-type', 'application/json');
170
- if (username && !headers.has('authorization')) {
171
- headers.set('authorization',
172
- `Basic ${Buffer.from(`${username}:${credential}`).toString('base64')}`);
144
+ exports.clientVars = async (hookName, {clientVars: {userId: authorId}, pad: {id: padId}}) => {
145
+ let iceServers = settings.iceServers;
146
+ if (settings.shardIceServers && iceServers.length > 1) {
147
+ // We could simply hash the pad ID, but we include some randomness to make it slightly harder
148
+ // for a malicious user to overload a particular shard by picking pad IDs that all use the same
149
+ // shard. (The randomness forces malicious users to try multiple pad IDs and keep the ones that
150
+ // use the same shard.) The randomness also helps avoid chronic imbalance due to unlucky
151
+ // assignments; generating a new secret will reassign the shards.
152
+ //
153
+ // The secret is generated at startup, so all users visiting the same pad will get the same HMAC
154
+ // value (and thus the same shard) until Etherpad is restarted. Users that connect after
155
+ // Etherpad restarts might be assigned a different shard from the users on the pad that received
156
+ // their clientVars before Etherpad restarted. This doesn't affect protocol correctness, but it
157
+ // might result in three network hops instead of two: client A sends to TURN A which relays to
158
+ // TURN B which relays to client B, instead of client A sends to TURN AB which relays to
159
+ // localhost (TURN AB) which relays to client B. This should be rare because it will only happen
160
+ // if all of the following are true:
161
+ //
162
+ // * Both users have configured their browsers to force relay.
163
+ // * One user loaded the pad before Etherpad restarted and the other loaded after.
164
+ // * The new random value caused the pad to be assigned to a different shard.
165
+ //
166
+ // TODO: Convey ICE servers via a message that is sent every time a user connects. (CLIENT_VARS
167
+ // is only sent on initial connection, so if a client reconnects due to Etherpad restarting, a
168
+ // new CLIENT_VARS is not sent.) This will allow the server to select a different shard for a
169
+ // pad when it restarts, and all clients (old and new) will use the new shard for new sessions.
170
+ //
171
+ // TODO: Select the shard for the pad when the first user joins the pad and forget that
172
+ // selection once all users have left. This would enable alternative load balancing schemes such
173
+ // as true random or least loaded.
174
+ const hmac = crypto.createHmac('sha256', shardIceServersHmacSecret);
175
+ hmac.update(padId);
176
+ const i = Number(BigInt(`0x${hmac.digest('hex')}`) % BigInt(iceServers.length));
177
+ iceServers = iceServers.slice(i, i + 1);
178
+ }
179
+ return {ep_webrtc: {
180
+ ...settings,
181
+ iceServers: await Promise.all(iceServers.map(async (server) => {
182
+ switch (server.credentialType) {
183
+ case 'coturn ephemeral password': {
184
+ const {lifetime = 60 * 60 * 12 /* seconds */} = server;
185
+ const username = `${Math.floor(Date.now() / 1000) + lifetime}:${authorId}`;
186
+ const hmac = crypto.createHmac('sha1', server.credential);
187
+ hmac.update(username);
188
+ const credential = hmac.digest('base64');
189
+ return {urls: server.urls, username, credential};
173
190
  }
174
- const body =
175
- JSON.stringify(b && typeof b === 'object' ? {format: 'urls', expire, ...b} : b);
176
- try {
177
- const {v, s} = await fetchJson(url, {method, headers, body});
178
- if (s !== 'ok') throw new Error(`API error: ${v}`);
179
- return v.iceServers;
180
- } catch (err) {
181
- const newErr = addContextToError(err, 'failed to get TURN credentials: ');
182
- logger.error(newErr.stack || newErr.toString());
183
- throw newErr;
191
+ case 'xirsys ephemeral credentials': {
192
+ const {
193
+ url,
194
+ username,
195
+ credential,
196
+ lifetime: expire = 12 * 60 * 60, // seconds
197
+ method = 'PUT',
198
+ headers: h = {},
199
+ jsonBody: b = {},
200
+ } = server;
201
+ // Can't set default values for the Content-Type and Authorization headers by using an
202
+ // object literal with spread (e.g., `{'content-type': 'foo', ...h}`) because the Headers
203
+ // constructor uses `.append()` internally instead of `.set()`. This matters if a header
204
+ // is repeated multiple times by using different mixes of upper- and lower-case letters.
205
+ const headers = new globalThis.Headers(h);
206
+ if (!headers.has('content-type')) headers.set('content-type', 'application/json');
207
+ if (username && !headers.has('authorization')) {
208
+ headers.set('authorization',
209
+ `Basic ${Buffer.from(`${username}:${credential}`).toString('base64')}`);
210
+ }
211
+ const body =
212
+ JSON.stringify(b && typeof b === 'object' ? {format: 'urls', expire, ...b} : b);
213
+ try {
214
+ const {v, s} = await fetchJson(url, {method, headers, body});
215
+ if (s !== 'ok') throw new Error(`API error: ${v}`);
216
+ return v.iceServers;
217
+ } catch (err) {
218
+ const newErr = addContextToError(err, 'failed to get TURN credentials: ');
219
+ logger.error(newErr.stack || newErr.toString());
220
+ throw newErr;
221
+ }
184
222
  }
223
+ default: return server;
185
224
  }
186
- default: return server;
187
- }
188
- })),
189
- }});
225
+ })),
226
+ }};
227
+ };
190
228
 
191
229
  exports.handleMessage = async (hookName, {message, socket}) => {
192
230
  if (message.type === 'COLLABROOM' && message.data.type === 'RTC_MESSAGE') {
@@ -243,6 +281,9 @@ exports.loadSettings = async (hookName, {settings: {ep_webrtc: s = {}}}) => {
243
281
  }
244
282
  return false;
245
283
  })();
284
+ if (settings.shardIceServers && settings.iceServers.length > 1) {
285
+ shardIceServersHmacSecret = await util.promisify(crypto.randomBytes.bind(crypto))(16);
286
+ }
246
287
  logger.info('configured:', util.inspect({
247
288
  ...settings,
248
289
  iceServers: settings.iceServers.map((s) => s.credential ? {...s, credential: '*****'} : s),
package/package.json CHANGED
@@ -5,7 +5,7 @@
5
5
  "url": "git@github.com:ether/ep_webrtc.git",
6
6
  "type": "git"
7
7
  },
8
- "version": "2.1.1",
8
+ "version": "2.2.2",
9
9
  "description": "WebRTC based audio/video chat to Etherpad",
10
10
  "author": "John McLear <john@mclear.co.uk>",
11
11
  "contributors": [],
@@ -22,13 +22,15 @@
22
22
  "url": "https://etherpad.org/"
23
23
  },
24
24
  "devDependencies": {
25
- "eslint": "^8.10.0",
26
- "eslint-config-etherpad": "^3.0.5",
25
+ "eslint": "^8.11.0",
26
+ "eslint-config-etherpad": "^3.0.9",
27
+ "mocha": "^9.2.2",
27
28
  "typescript": "^4.6.2"
28
29
  },
29
30
  "scripts": {
30
31
  "lint": "eslint .",
31
- "lint:fix": "eslint --fix ."
32
+ "lint:fix": "eslint --fix .",
33
+ "test": "mocha --recursive static/tests/backend/specs"
32
34
  },
33
35
  "peerDependencies": {
34
36
  "ep_etherpad-lite": ">=1.8.7"
@@ -87,7 +87,7 @@ class LocalTracks extends EventTargetPolyfill {
87
87
  this.videoIsScreenshare = false;
88
88
  }
89
89
 
90
- _getTracks(kind) {
90
+ getTracks(kind) {
91
91
  return kind === 'audio' ? this.stream.getAudioTracks()
92
92
  : kind === 'video' ? this.stream.getVideoTracks()
93
93
  : this.stream.getTracks();
@@ -96,7 +96,7 @@ class LocalTracks extends EventTargetPolyfill {
96
96
  setTrack(kind, newTrack) {
97
97
  newTrack = newTrack || null; // Convert undefined to null.
98
98
  let oldTrack = null;
99
- const tracks = this._getTracks(kind);
99
+ const tracks = this.getTracks(kind);
100
100
  for (const track of tracks) {
101
101
  if (track.kind !== kind) continue;
102
102
  if (track === newTrack) return; // No change.
@@ -109,7 +109,7 @@ class LocalTracks extends EventTargetPolyfill {
109
109
  debug(`adding ${kind} track ${newTrack.id} to local stream`);
110
110
  newTrack.addEventListener('ended', () => {
111
111
  debug(`local ${kind} track ${newTrack.id} ended`);
112
- if (!this._getTracks(kind).includes(newTrack)) return;
112
+ if (!this.getTracks(kind).includes(newTrack)) return;
113
113
  this.setTrack(kind, null);
114
114
  });
115
115
  this.stream.addTrack(newTrack);
@@ -163,45 +163,42 @@ class PeerState extends EventTargetPolyfill {
163
163
  };
164
164
  this._closed = false;
165
165
  this._pc = null;
166
+ this._xcvrs = {};
166
167
  this._remoteStream = null;
167
- this._onremovetrack =
168
- () => { if (this._remoteStream.getTracks().length === 0) this._setRemoteStream(null); };
169
168
  this._ontrackchanged = async ({oldTrack, newTrack}) => {
170
- if (this._pc == null || this._pc.connectionState === 'closed') return;
171
- this._debug(`replacing ${oldTrack ? oldTrack.kind : newTrack.kind} track ` +
172
- `${oldTrack ? oldTrack.id : '(null)'} with ` +
173
- `${newTrack ? newTrack.id : '(null)'}`);
174
- if (oldTrack != null) {
175
- for (const sender of this._pc.getSenders()) {
176
- if (sender.track !== oldTrack) continue;
177
- if (newTrack != null) {
178
- try {
179
- return await sender.replaceTrack(newTrack);
180
- } catch (err) {
181
- this._debug('renegotiation is required');
182
- logErrorToServer(err);
183
- }
184
- }
185
- this._pc.removeTrack(sender);
186
- break;
187
- }
188
- }
189
- if (newTrack != null) this._pc.addTrack(newTrack, this._localTracks.stream);
169
+ const {kind} = oldTrack || newTrack;
170
+ await this._sendLocalTrack(kind, newTrack);
190
171
  };
191
172
  this._localTracks.addEventListener('trackchanged', this._ontrackchanged);
192
173
  }
193
174
 
175
+ async _sendLocalTrack(kind, track) {
176
+ if (this._pc == null || this._pc.connectionState === 'closed') return;
177
+ const {sender: {track: old} = {}} = this._xcvrs[kind] || {};
178
+ if ((old == null && track == null) || old === track) return;
179
+ this._debug(
180
+ `replacing ${kind} track ${old ? old.id : '(null)'} with ${track ? track.id : '(null)'}`);
181
+ const xcvr = this._xcvrs[kind];
182
+ if (!xcvr) return;
183
+ try {
184
+ if (track != null) xcvr.direction = 'sendrecv'; // Will throw if not already bidirectional.
185
+ await xcvr.sender.replaceTrack(track);
186
+ } catch (err) {
187
+ this._debug('renegotiation is required');
188
+ this._resetConnection(err);
189
+ return;
190
+ }
191
+ }
192
+
194
193
  _setRemoteStream(stream) {
195
194
  if (stream === this._remoteStream) return;
196
195
  if (this._remoteStream != null) {
197
196
  const oldStream = this._remoteStream;
198
- oldStream.removeEventListener('removetrack', this._onremovetrack);
199
197
  this._remoteStream = null;
200
198
  this.dispatchEvent(new StreamEvent('streamgone', oldStream));
201
199
  }
202
200
  if (stream != null) {
203
201
  this._remoteStream = stream;
204
- stream.addEventListener('removetrack', this._onremovetrack);
205
202
  this.dispatchEvent(new StreamEvent('stream', stream));
206
203
  }
207
204
  }
@@ -221,20 +218,19 @@ class PeerState extends EventTargetPolyfill {
221
218
  this._ids.from.instance = ++nextInstanceId;
222
219
  this._ids.to = peerIds;
223
220
  const pc = new RTCPeerConnection(this._pcConfig);
224
- pc.addEventListener('track', ({track, streams}) => {
225
- if (streams.length !== 1) throw new Error('Expected track to be in exactly one stream');
226
- this._setRemoteStream(streams[0]);
221
+ pc.addEventListener('track', async ({track, transceiver}) => {
222
+ this._debug(`Received ${track.kind} track from peer, ID: ${track.id}`);
223
+ const stream = this._remoteStream || new MediaStream();
224
+ stream.addTrack(track);
225
+ this._setRemoteStream(stream);
226
+ if (!this._caller) {
227
+ this._xcvrs[track.kind] = transceiver;
228
+ // _sendLocalTrack might call _resetConnection, so it should be called last.
229
+ await this._sendLocalTrack(track.kind, this._localTracks.getTracks(track.kind)[0]);
230
+ }
227
231
  });
228
232
  pc.addEventListener('icecandidate', ({candidate}) => this._sendMessage({candidate}));
229
233
  pc.addEventListener('negotiationneeded', async () => {
230
- if (!this._caller) {
231
- this._debug('Waiting for peer to call');
232
- // It is possible that the last invite sent to the peer was sent before the peer was ready
233
- // to accept invites, so the peer might not know that it should call now. Send another
234
- // invite just in case. The peer will ignore any superfluous invites.
235
- this._sendMessage({invite: 'invite'});
236
- return;
237
- }
238
234
  try {
239
235
  await pc.setLocalDescription();
240
236
  this._failedSLDAttempts = 0;
@@ -291,11 +287,32 @@ class PeerState extends EventTargetPolyfill {
291
287
 
292
288
  if (this._pc != null) this._pc.close();
293
289
  this._pc = pc;
294
-
295
- const tracks = this._localTracks.stream.getTracks();
296
- for (const track of tracks) {
297
- this._debug(`start streaming ${track.kind} track ${track.id}`);
298
- pc.addTrack(track, this._localTracks.stream);
290
+ this._xcvrs = {};
291
+
292
+ if (this._caller) {
293
+ // Adding transceivers triggers negotiation with the peer, which we want to do as soon as
294
+ // possible to warm up the peer connection (ICE takes a while). Adding a track implicitly adds
295
+ // a transceiver so we could do that instead, but:
296
+ //
297
+ // * We might not have a local track yet (maybe the user hasn't yet allowed access to the
298
+ // camera/mic).
299
+ // * Using a dummy silence track until a real track is ready might result in two
300
+ // unidirectional SDP m-lines (one in each direction) instead of a single bidirectional
301
+ // m-line. This seems to trigger an echo bug in Safari.
302
+ //
303
+ // Using transceivers to warm up the connection is the approach taken in:
304
+ // https://www.w3.org/TR/2021/REC-webrtc-20210126/#advanced-peer-to-peer-example-with-warm-up
305
+ // Also see: https://webrtcstandards.info/ice-warm-up-m-line-webrtc-transceivers/
306
+ Promise.all(['audio', 'video'].map(async (t) => {
307
+ this._debug(`adding ${t} transceiver`);
308
+ this._xcvrs[t] = this._pc.addTransceiver(t);
309
+ await this._sendLocalTrack(t, this._localTracks.getTracks(t)[0]);
310
+ }));
311
+ } else {
312
+ // It is possible that the last invite sent to the peer was sent before the peer was ready to
313
+ // accept invites, so the peer might not know that it should call now. Send another invite
314
+ // just in case. The peer will ignore any superfluous invites.
315
+ this._sendMessage({invite: 'invite'});
299
316
  }
300
317
  }
301
318
 
@@ -385,17 +402,7 @@ exports.rtc = new class {
385
402
  constructor() {
386
403
  this._activated = null;
387
404
  this._settings = null;
388
- this._disabledSilence = (() => {
389
- const ctx = new AudioContext();
390
- const gain = ctx.createGain();
391
- const dst = gain.connect(ctx.createMediaStreamDestination());
392
- const track = dst.stream.getAudioTracks()[0];
393
- track.enabled = false;
394
- return track;
395
- })();
396
- this._blankStream = new MediaStream([this._disabledSilence]);
397
405
  this._localTracks = new LocalTracks();
398
- this._localTracks.setTrack(this._disabledSilence.kind, this._disabledSilence);
399
406
  this._localTracks.addEventListener('trackchanged', ({oldTrack, newTrack}) => {
400
407
  // Normally the self-view UI only needs to be updated if the user clicks on something, but it
401
408
  // also needs to be updated if the browser decides to end the local stream for whatever
@@ -620,8 +627,7 @@ exports.rtc = new class {
620
627
  try {
621
628
  const devices = [];
622
629
  const addAudioTrack = updateAudio && this._selfViewButtons.audio.enabled &&
623
- !this._localTracks.stream.getAudioTracks().some(
624
- (t) => t !== this._disabledSilence && t.readyState === 'live');
630
+ !this._localTracks.stream.getAudioTracks().some((t) => t.readyState === 'live');
625
631
  if (addAudioTrack) devices.push('mic');
626
632
  const addVideoTrack = updateVideo && this._selfViewButtons.video.enabled &&
627
633
  (!this._localTracks.stream.getVideoTracks().some((t) => t.readyState === 'live') ||
@@ -674,7 +680,7 @@ exports.rtc = new class {
674
680
  for (const track of this._localTracks.stream.getAudioTracks()) {
675
681
  // Re-check the state of the button because the user might have clicked it while
676
682
  // getUserMedia() was running.
677
- track.enabled = track !== this._disabledSilence && this._selfViewButtons.audio.enabled;
683
+ track.enabled = this._selfViewButtons.audio.enabled;
678
684
  }
679
685
  const hasAudio = this._localTracks.stream.getAudioTracks().some(
680
686
  (t) => t.enabled && t.readyState === 'live');
@@ -880,8 +886,9 @@ exports.rtc = new class {
880
886
  minWidth: iw > width ? `${iw}px` : '',
881
887
  minHeight: nh + ih > height ? `${nh + ih}px` : '',
882
888
  });
883
- })
884
- .appendTo($('#rtcbox'));
889
+ });
890
+ if (isLocal) $videoContainer.prependTo($('#rtcbox'));
891
+ else $videoContainer.appendTo($('#rtcbox'));
885
892
  this.updatePeerNameAndColor(this.getUserFromId(userId));
886
893
 
887
894
  // For tests it is important to know when an asynchronous event handler has finishing handling
@@ -1278,7 +1285,7 @@ exports.rtc = new class {
1278
1285
  logDisconnectErrorTimeout =
1279
1286
  setTimeout(() => logErrorToServer(new Error('RTC connection lost'), 0), 10000);
1280
1287
  ($videoContainer.data('updateMinSize') || (() => {}))();
1281
- await this.setStream(userId, this._blankStream);
1288
+ await this.setStream(userId, new MediaStream());
1282
1289
  });
1283
1290
  peer.addEventListener('closed', () => {
1284
1291
  _debug('PeerState closed');
@@ -0,0 +1,24 @@
1
+ 'use strict';
2
+
3
+ const common = require('ep_etherpad-lite/tests/backend/common');
4
+ const fsp = require('fs').promises;
5
+ const path = require('path');
6
+ const pluginDefs = require('ep_etherpad-lite/static/js/pluginfw/plugin_defs');
7
+ const plugins = require('ep_etherpad-lite/static/js/pluginfw/plugins');
8
+
9
+ module.exports = async () => {
10
+ const agent = await common.init();
11
+ if (pluginDefs.plugins.ep_webrtc == null) {
12
+ const packagePath = path.dirname(require.resolve('../../../package.json'));
13
+ plugins.getPackages = async () => ({
14
+ 'ep_etherpad-lite': pluginDefs.plugins['ep_etherpad-lite'].package,
15
+ 'ep_webrtc': {
16
+ ...require('../../../package.json'),
17
+ path: packagePath,
18
+ realPath: await fsp.realpath(packagePath),
19
+ },
20
+ });
21
+ await plugins.update();
22
+ }
23
+ return agent;
24
+ };
@@ -0,0 +1,130 @@
1
+ 'use strict';
2
+
3
+ const assert = require('assert').strict;
4
+ const common = require('ep_etherpad-lite/tests/backend/common');
5
+ const init = require('../init');
6
+ const plugin = require('../../../../index');
7
+ const settings = require('ep_etherpad-lite/node/utils/Settings');
8
+
9
+ describe(__filename, function () {
10
+ let agent;
11
+ const backup = {settings: {...settings}};
12
+ const iceServers = [...Array(1000).keys()].map((i) => ({urls: [`turn:turn${i}.example.com`]}));
13
+
14
+ const reload = async (settings = {}) => {
15
+ await plugin.loadSettings('loadSettings', {settings: {ep_webrtc: {iceServers, ...settings}}});
16
+ };
17
+
18
+ const getIceServers = async (padId = common.randomString()) => {
19
+ while (getIceServers._busy != null) {
20
+ await getIceServers._busy;
21
+ }
22
+ if (++getIceServers._active >= getIceServers._limit) {
23
+ getIceServers._busy = new Promise((resolve) => getIceServers._resolve = resolve);
24
+ }
25
+ try {
26
+ const res = await agent.get(`/p/${padId}`).expect(200);
27
+ const socket = await common.connect(res);
28
+ try {
29
+ const {type, data: clientVars} = await common.handshake(socket, padId);
30
+ assert.equal(type, 'CLIENT_VARS');
31
+ return clientVars.ep_webrtc.iceServers;
32
+ } finally {
33
+ socket.close();
34
+ }
35
+ } finally {
36
+ if (--getIceServers._active < getIceServers._limit && getIceServers._busy != null) {
37
+ getIceServers._resolve();
38
+ getIceServers._busy = null;
39
+ }
40
+ }
41
+ };
42
+ getIceServers._limit = 5; // Avoid timeouts caused by overload.
43
+ getIceServers._active = 0;
44
+ getIceServers._resolve = () => {};
45
+
46
+ before(async function () {
47
+ settings.requireAuthentication = false;
48
+ agent = await init();
49
+ });
50
+
51
+ after(async function () {
52
+ Object.assign(settings, backup.settings);
53
+ await plugin.loadSettings('loadSettings', {settings});
54
+ });
55
+
56
+ it('defaults to disabled', async function () {
57
+ await reload();
58
+ const got = await getIceServers();
59
+ assert.deepEqual(got, iceServers);
60
+ });
61
+
62
+ it('explicitly disabled', async function () {
63
+ await reload({shardIceServers: false});
64
+ const got = await getIceServers();
65
+ assert.deepEqual(got, iceServers);
66
+ });
67
+
68
+ it('enabled, zero entries', async function () {
69
+ await reload({iceServers: [], shardIceServers: true});
70
+ assert.deepEqual(await getIceServers(), []);
71
+ });
72
+
73
+ it('enabled, one entry', async function () {
74
+ const entries = [{urls: ['turn:turn.example.com']}];
75
+ await reload({iceServers: entries, shardIceServers: true});
76
+ assert.deepEqual(await getIceServers(), entries);
77
+ });
78
+
79
+ describe('enabled, multiple entries', function () {
80
+ beforeEach(async function () {
81
+ await reload({shardIceServers: true});
82
+ });
83
+
84
+ it('only gives one entry to each client', async function () {
85
+ const got = await getIceServers();
86
+ assert.equal(got.length, 1);
87
+ assert(iceServers.some((s) => {
88
+ try {
89
+ assert.deepEqual(got[0], s);
90
+ return true;
91
+ } catch (err) {
92
+ return false;
93
+ }
94
+ }));
95
+ });
96
+
97
+ it('same pad gets same entry', async function () {
98
+ this.timeout(60000);
99
+ const assignments = new Map(await Promise.all([...Array(10).keys()].map(async () => {
100
+ const padId = common.randomString();
101
+ return [padId, await getIceServers(padId)];
102
+ })));
103
+ await Promise.all([...assignments].map(async ([padId, want]) => {
104
+ const got = await getIceServers(padId);
105
+ assert.deepEqual(got, want);
106
+ }));
107
+ });
108
+
109
+ it('randomizes assignments on reload', async function () {
110
+ this.timeout(60000);
111
+ const oldAssignments = new Map(await Promise.all([...Array(10).keys()].map(async () => {
112
+ const padId = common.randomString();
113
+ return [padId, await getIceServers(padId)];
114
+ })));
115
+ await reload({shardIceServers: true});
116
+ const newAssignments = new Map(await Promise.all(
117
+ [...oldAssignments.keys()].map(async (padId) => [padId, await getIceServers(padId)])));
118
+ // With 10 pad IDs and 1000 ICE servers, the probability that every new assignment exactly
119
+ // matches the old assignment is effectively zero.
120
+ assert([...newAssignments].some(([padId, newAssignment]) => {
121
+ try {
122
+ assert.deepEqual(newAssignment, oldAssignments.get(padId));
123
+ return false;
124
+ } catch (err) {
125
+ return true;
126
+ }
127
+ }));
128
+ });
129
+ });
130
+ });