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 +25 -0
- package/index.js +86 -45
- package/package.json +6 -4
- package/static/js/index.js +66 -59
- package/static/tests/backend/init.js +24 -0
- package/static/tests/backend/specs/sharding.js +130 -0
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}}) =>
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
26
|
-
"eslint-config-etherpad": "^3.0.
|
|
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"
|
package/static/js/index.js
CHANGED
|
@@ -87,7 +87,7 @@ class LocalTracks extends EventTargetPolyfill {
|
|
|
87
87
|
this.videoIsScreenshare = false;
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
171
|
-
this.
|
|
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,
|
|
225
|
-
|
|
226
|
-
this.
|
|
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
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
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 =
|
|
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
|
-
|
|
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,
|
|
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
|
+
});
|