@fnlb-project/stanza
Version:
Modern XMPP in the browser, with a JSON API
404 lines (403 loc) • 16.8 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const tslib_1 = require("tslib");
const SDPUtils = (0, tslib_1.__importStar)(require("sdp"));
const Constants_1 = require("../Constants");
const Namespaces_1 = require("../Namespaces");
const Intermediate_1 = require("./sdp/Intermediate");
const Protocol_1 = require("./sdp/Protocol");
const Session_1 = (0, tslib_1.__importDefault)(require("./Session"));
class ICESession extends Session_1.default {
constructor(opts) {
super(opts);
this.bitrateLimit = 0;
this.candidateBuffer = [];
this.transportType = Namespaces_1.NS_JINGLE_ICE_UDP_1;
this.restartingIce = false;
this.usingRelay = false;
this.maxRelayBandwidth = opts.maxRelayBandwidth;
this.pc = this.parent.createPeerConnection(this, {
...opts.config,
iceServers: opts.iceServers
});
this.pc.oniceconnectionstatechange = () => {
this.onIceStateChange();
};
this.pc.onicecandidate = e => {
if (e.candidate) {
this.onIceCandidate(e);
}
else {
this.onIceEndOfCandidates();
}
};
this.restrictRelayBandwidth();
}
end(reason = 'success', silent = false) {
this.pc.close();
super.end(reason, silent);
}
/* actually do an ice restart */
async restartIce() {
// only initiators do an ice-restart to avoid conflicts.
if (!this.isInitiator) {
return;
}
if (this._maybeRestartingIce !== undefined) {
clearTimeout(this._maybeRestartingIce);
}
this.restartingIce = true;
try {
await this.processLocal('restart-ice', async () => {
const offer = await this.pc.createOffer({ iceRestart: true });
// extract new ufrag / pwd, send transport-info with just that.
const json = (0, Intermediate_1.importFromSDP)(offer.sdp);
this.send(Constants_1.JingleAction.TransportInfo, {
contents: json.media.map(media => ({
creator: Constants_1.JingleSessionRole.Initiator,
name: media.mid,
transport: (0, Protocol_1.convertIntermediateToTransport)(media, this.transportType)
})),
sid: this.sid
});
await this.pc.setLocalDescription(offer);
});
}
catch (err) {
this._log('error', 'Could not create WebRTC offer', err);
this.end(Constants_1.JingleReasonCondition.FailedTransport, true);
}
}
// set the maximum bitrate. Only supported in Chrome and Firefox right now.
async setMaximumBitrate(maximumBitrate) {
if (this.maximumBitrate) {
// potentially take into account bandwidth restrictions due to using TURN.
maximumBitrate = Math.min(maximumBitrate, this.maximumBitrate);
}
this.currentBitrate = maximumBitrate;
// changes the maximum bandwidth using RTCRtpSender.setParameters.
const sender = this.pc.getSenders().find(s => !!s.track && s.track.kind === 'video');
if (!sender || !sender.getParameters) {
return;
}
try {
await this.processLocal('set-bitrate', async () => {
const parameters = sender.getParameters();
if (!parameters.encodings || !parameters.encodings.length) {
parameters.encodings = [{}];
}
if (maximumBitrate === 0) {
delete parameters.encodings[0].maxBitrate;
}
else {
parameters.encodings[0].maxBitrate = maximumBitrate;
}
await sender.setParameters(parameters);
});
}
catch (err) {
this._log('error', 'Set maximumBitrate failed', err);
}
}
// ----------------------------------------------------------------
// Jingle action handers
// ----------------------------------------------------------------
async onTransportInfo(changes, cb) {
if (changes.contents &&
changes.contents[0] &&
changes.contents[0].transport.gatheringComplete) {
const candidate = { sdpMid: changes.contents[0].name, candidate: '' };
try {
if (this.pc.signalingState === 'stable') {
await this.pc.addIceCandidate(candidate);
}
else {
this.candidateBuffer.push(candidate);
}
}
catch (err) {
this._log('debug', 'Could not add null end-of-candidate');
}
finally {
cb();
}
return;
}
// detect an ice restart.
if (this.pc.remoteDescription) {
const remoteDescription = this.pc.remoteDescription;
const remoteJSON = (0, Intermediate_1.importFromSDP)(remoteDescription.sdp);
const remoteMedia = remoteJSON.media.find(m => m.mid === changes.contents[0].name);
const currentUsernameFragment = remoteMedia.iceParameters.usernameFragment;
const remoteUsernameFragment = changes.contents[0].transport
.usernameFragment;
if (remoteUsernameFragment && currentUsernameFragment !== remoteUsernameFragment) {
for (const [idx, content] of changes.contents.entries()) {
const transport = content.transport;
remoteJSON.media[idx].iceParameters = {
password: transport.password,
usernameFragment: transport.usernameFragment
};
remoteJSON.media[idx].candidates = [];
}
try {
await this.pc.setRemoteDescription({
type: remoteDescription.type,
sdp: (0, Intermediate_1.exportToSDP)(remoteJSON)
});
await this.processBufferedCandidates();
if (remoteDescription.type === 'offer') {
const answer = await this.pc.createAnswer();
await this.pc.setLocalDescription(answer);
const json = (0, Intermediate_1.importFromSDP)(answer.sdp);
this.send(Constants_1.JingleAction.TransportInfo, {
contents: json.media.map(media => ({
creator: Constants_1.JingleSessionRole.Initiator,
name: media.mid,
transport: (0, Protocol_1.convertIntermediateToTransport)(media, this.transportType)
})),
sid: this.sid
});
}
else {
this.restartingIce = false;
}
}
catch (err) {
this._log('error', 'Could not do remote ICE restart', err);
cb(err);
this.end(Constants_1.JingleReasonCondition.FailedTransport);
return;
}
}
}
const all = (changes.contents || []).map(content => {
const sdpMid = content.name;
const results = (content.transport.candidates || []).map(async (json) => {
const candidate = SDPUtils.writeCandidate((0, Protocol_1.convertCandidateToIntermediate)(json));
if (this.pc.remoteDescription && this.pc.signalingState === 'stable') {
try {
await this.pc.addIceCandidate({ sdpMid, candidate });
}
catch (err) {
this._log('error', 'Could not add ICE candidate', err);
}
}
else {
this.candidateBuffer.push({ sdpMid, candidate });
}
});
return Promise.all(results);
});
try {
await Promise.all(all);
cb();
}
catch (err) {
this._log('error', `Could not process transport-info: ${err}`);
cb(err);
}
}
async onSessionAccept(changes, cb) {
this.state = 'active';
const json = (0, Protocol_1.convertRequestToIntermediate)(changes, this.peerRole);
const sdp = (0, Intermediate_1.exportToSDP)(json);
try {
await this.pc.setRemoteDescription({ type: 'answer', sdp });
await this.processBufferedCandidates();
this.parent.emit('accepted', this, undefined);
cb();
}
catch (err) {
this._log('error', `Could not process WebRTC answer: ${err}`);
cb({ condition: 'general-error' });
}
}
onSessionTerminate(changes, cb) {
this._log('info', 'Terminating session');
this.pc.close();
super.end(changes.reason, true);
cb();
}
// ----------------------------------------------------------------
// ICE action handers
// ----------------------------------------------------------------
onIceCandidate(e) {
if (!e.candidate || !e.candidate.candidate) {
return;
}
const candidate = SDPUtils.parseCandidate(e.candidate.candidate);
const jingle = {
contents: [
{
creator: Constants_1.JingleSessionRole.Initiator,
name: e.candidate.sdpMid,
transport: {
candidates: [(0, Protocol_1.convertIntermediateToCandidate)(candidate)],
transportType: this.transportType,
usernameFragment: candidate.usernameFragment
}
}
]
};
this._log('info', 'Discovered new ICE candidate', jingle);
this.send(Constants_1.JingleAction.TransportInfo, jingle);
}
onIceEndOfCandidates() {
this._log('info', 'ICE end of candidates');
const json = (0, Intermediate_1.importFromSDP)(this.pc.localDescription.sdp);
const firstMedia = json.media[0];
// signal end-of-candidates with our first media mid/ufrag
this.send(Constants_1.JingleAction.TransportInfo, {
contents: [
{
creator: Constants_1.JingleSessionRole.Initiator,
name: firstMedia.mid,
transport: {
gatheringComplete: true,
transportType: this.transportType,
usernameFragment: firstMedia.iceParameters.usernameFragment
}
}
]
});
}
onIceStateChange() {
switch (this.pc.iceConnectionState) {
case 'checking':
this.connectionState = 'connecting';
break;
case 'completed':
case 'connected':
this.connectionState = 'connected';
break;
case 'disconnected':
if (this.pc.signalingState === 'stable') {
this.connectionState = 'interrupted';
}
else {
this.connectionState = 'disconnected';
}
if (this.restartingIce) {
this.end(Constants_1.JingleReasonCondition.FailedTransport);
return;
}
this.maybeRestartIce();
break;
case 'failed':
if (this.connectionState === 'failed' || this.restartingIce) {
this.end(Constants_1.JingleReasonCondition.FailedTransport);
return;
}
this.connectionState = 'failed';
this.restartIce();
break;
case 'closed':
this.connectionState = 'disconnected';
if (this.restartingIce) {
this.end(Constants_1.JingleReasonCondition.FailedTransport);
}
else {
this.end();
}
break;
}
}
async processBufferedCandidates() {
for (const candidate of this.candidateBuffer) {
try {
await this.pc.addIceCandidate(candidate);
}
catch (err) {
this._log('error', 'Could not add ICE candidate', err);
}
}
this.candidateBuffer = [];
}
/* when using TURN, we might want to restrict the bandwidth
* to the value specified by MAX_RELAY_BANDWIDTH
* in order to prevent sending excessive traffic through
* the TURN server.
*/
restrictRelayBandwidth() {
this.pc.addEventListener('iceconnectionstatechange', async () => {
if (this.pc.iceConnectionState !== 'completed' &&
this.pc.iceConnectionState !== 'connected') {
return;
}
const stats = await this.pc.getStats();
let activeCandidatePair;
stats.forEach(report => {
if (report.type === 'transport') {
activeCandidatePair = stats.get(report.selectedCandidatePairId);
}
});
// Fallback for Firefox.
if (!activeCandidatePair) {
stats.forEach(report => {
if (report.type === 'candidate-pair' && report.selected) {
activeCandidatePair = report;
}
});
}
if (!activeCandidatePair) {
return;
}
let isRelay = false;
let localCandidateType = '';
let remoteCandidateType = '';
if (activeCandidatePair.remoteCandidateId) {
const remoteCandidate = stats.get(activeCandidatePair.remoteCandidateId);
if (remoteCandidate) {
remoteCandidateType = remoteCandidate.candidateType;
}
}
if (activeCandidatePair.localCandidateId) {
const localCandidate = stats.get(activeCandidatePair.localCandidateId);
if (localCandidate) {
localCandidateType = localCandidate.candidateType;
}
}
if (localCandidateType === 'relay' || remoteCandidateType === 'relay') {
isRelay = true;
}
this.usingRelay = isRelay;
this.parent.emit('iceConnectionType', this, {
localCandidateType,
relayed: isRelay,
remoteCandidateType
});
if (isRelay && this.maxRelayBandwidth !== undefined) {
this.maximumBitrate = this.maxRelayBandwidth;
if (this.currentBitrate) {
this.setMaximumBitrate(Math.min(this.currentBitrate, this.maximumBitrate));
}
else {
this.setMaximumBitrate(this.maximumBitrate);
}
}
});
}
/* determine whether an ICE restart is in order
* when transitioning to disconnected. Strategy is
* 'wait 2 seconds for things to repair themselves'
* 'maybe check if bytes are sent/received' by comparing
* getStats measurements
*/
maybeRestartIce() {
// only initiators do an ice-restart to avoid conflicts.
if (!this.isInitiator) {
return;
}
if (this._maybeRestartingIce !== undefined) {
clearTimeout(this._maybeRestartingIce);
}
this._maybeRestartingIce = setTimeout(() => {
this._maybeRestartingIce = undefined;
if (this.pc.iceConnectionState === 'disconnected') {
this.restartIce();
}
}, 2000);
}
}
exports.default = ICESession;