diff --git a/README.md b/README.md index 0fcf901..5f66436 100644 --- a/README.md +++ b/README.md @@ -1,286 +1,458 @@ # sylkrtc.js JavaScript library implementing the API for communicating with [SylkServer's](http://sylkserver.com) WebRTC gateway application. +This client library can be used for creating Web applications with the following features: + +* Audio / Video calls +* Interoperability with SIP endpoints +* Multi-party video conferencing + ## Building Grab the source code using Darcs or Git and install the dependencies: cd sylkrtc ./configure Build the development release (not minified): make Build a minified version: make min ## Development Auto-building the library as changes are made: make watch + ### Debugging sylkrtc uses the [debug](https://github.com/visionmedia/debug) library for easy debugging. By default debugging is disabled. In order to enable sylkrtc debug type the following in the browser JavaScript console: sylkrtc.debug.enable('sylkrtc*'); Then refresh the page. ## API The entrypoint to the library is the `sylkrtc` object. Several objects (`Connection`, `Account` and `Call`) inherit from Node's `EventEmitter` class, you may want to check [its documentation](https://nodejs.org/api/events.html). + ### sylkrtc The main entrypoint to the library. It exposes the main function to connect to SylkServer and some utility functions for general use. + #### sylkrtc.createConnection(options={}) Creates a `sylkrtc` connection towards a SylkServer instance. The only supported option (at the moment) is "server", which should point to the WebSocket endpoint of the WebRTC gateway application. Example: `wss://1.2.3.4:8088/webrtcgateway/ws`. It returns a `Connection` object. Example: let connection = sylkrtc.createConnection({server: 'wss://1.2.3.4:8088/webrtcgateway/ws'}); + #### sylkrtc.debug [debug](https://github.com/visionmedia/debug) module, exposed. Used for debugging, with the 'sylkrtc' prefix. + #### sylkrtc.rtcninja [rtcninja](https://github.com/eface2face/rtcninja.js) module, exposed. Used for accessing WebRTC APIs and dealing with platform differences. + #### sylkrtc.closeMediaStream(stream) Helper function to close the given `stream`. When a local media stream is closed the camera is stopped in case it was active, for example. Note: when a `Call` is terminated all streams will be automatically closed. + ### Connection Object representing the interaction with SylkServer. Multiple connections can be created with `sylkrtc.createConnection`, but typically only one is needed. Reconnecting in case the connection is interrupted is taken care of automatically. Events emitted: * **stateChanged**: indicates the WebSocket connection state has changed. Two arguments are provided: `oldState` and `newState`, the old connection state and the new connection state, respectively. Possible state values are: null, connecting, connected, ready, disconnected and closed. If the connection is involuntarily interrupted the state will transition to disconnected and the connection will be retried. Once the closed state is set, as a result of the user calling Connection.close(), the connection can no longer be used or reconnected. + #### Connection.addAccount(options={}, cb=null) Configures an `Account` to be used through `sylkrtc`. 2 options are required: *account* (the account ID) and *password*. An optional *displayName* can be set. The account won't be registered, it will just be created. Optionally *realm* can be passed, which will be used instead of the domain for the HA1 calculation. The *password* won't be stored or transmitted as given, the HA1 hash (as used in [Digest access authentication](https://en.wikipedia.org/wiki/Digest_access_authentication)) is created and used instead. The `cb` argument is a callback which will be called with an error and the account object itself. Example: connection.addAccount({account: saghul@sip2sip.info, password: 1234}, function(error, account) { if (error) { console.log('Error adding account!' + account); } else { console.log('Account added!'); } }); + #### Connection.removeAccount(account, cb=null) Removes the given account. The callback will be called once the operation completes (it cannot fail). Example: connection.removeAccount(account, function() { console('Account removed!'); }); + #### Connection.reconnect() Starts reconnecting immediately if the state was 'disconnected'; + #### Connection.close() Close the connection with SylkServer. All accounts will be unbound. + #### Connection.state Getter property returning the current connection state. + ### Account Object representing a SIP account which will be used for making / receiving calls. Events emitted: * **registrationStateChanged**: indicates the SIP registration state has changed. Three arguments are provided: `oldState`, `newState` and `data`. `oldState` and `newState` represent the old registration state and the new registration state, respectively, and `data` is a generic per-state data object. Possible states: * null: registration hasn't started or it has ended * registering: registration is in progress * registered * failed: registration failed, the `data` object will contain a 'reason' property. * **outgoingCall**: emitted when an outgoing call is made. A single argument is provided: the `Call` object. * **incomingCall**: emitted when an incoming call is received. Two arguments are provided: the `Call` object and a `mediaTypes` object, which has 2 boolean properties: `audio` and `video`, indicating if those media types were present in the initial SDP. * **missedCall**: emitted when an incoming call is missed. A `data` object is provided, which contains an `originator` attribute, which is an `Identity` object. + #### Account.register() Start the SIP registration process for the account. Progress will be reported via the *registrationStateChanged* event. Note: it's not necessary to be registered to make an outgoing call. + #### Account.unregister() Unregister the account. Progress will be reported via the *registrationStateChanged* event. + #### Account.call(uri, options={}) Start an outgoing call. Supported options: * pcConfig: configuration options for `RTCPeerConnection`. [Reference](http://w3c.github.io/webrtc-pc/#configuration). * mediaConstraints: constraints to be used when getting the local user media. [Reference](http://www.w3.org/TR/mediacapture-streams/#mediastreamconstraints). * offerOptions: `RTCOfferOptions`. [Reference](http://w3c.github.io/webrtc-pc/#idl-def-RTCOfferOptions). * localStream: if specified, it will be used by sylkrtc instead of using `getUserMedia`. Example: - let call = account.call('3333@sip2sip.info', {mediaConstraints: {audio: true, video: false}}); + const call = account.call('3333@sip2sip.info', {mediaConstraints: {audio: true, video: false}}); + + +#### Account.joinConference(uri, options={}) + +Join (or create in case it doesn't exist) a multi-party video conference at the given URI. Supported options: + +* pcConfig: configuration options for `RTCPeerConnection`. [Reference](http://w3c.github.io/webrtc-pc/#configuration). +* mediaConstraints: constraints to be used when getting the local user media. [Reference](http://www.w3.org/TR/mediacapture-streams/#mediastreamconstraints). +* offerOptions: `RTCOfferOptions`. [Reference](http://w3c.github.io/webrtc-pc/#idl-def-RTCOfferOptions). +* localStream: if specified, it will be used by sylkrtc instead of using `getUserMedia`. + +**NOTE**: Some of the `mediaConstraints` are ignored: audio and video are always requested. + +Example: + + const conf = account.joinConference('test123@conference.sip2sip.info'); + #### Account.id Getter property returning the account ID. + #### Account.displayName Getter property returning the account display name. + #### Account.password Getter property returning the HA1 password for the account. + #### Account.registrationState getter property returning the current registration state. + ### Call Object representing a audio/video call. Signalling is done using SIP underneath. Events emitted: * **localStreamAdded**: emitted when the local stream is added to the call. A single argument is provided: the stream itself. * **streamAdded**: emitted when a remote stream is added to the call. A single argument is provided: the stream itself. * **stateChanged**: indicates the call state has changed. Three arguments are provided: `oldState`, `newState` and `data`. `oldState` and `newState` indicate the previous and current state respectively, and `data` is a generic per-state data object. Possible states: * terminated: the call has ended (the `data` object contains a `reason` attribute) * accepted: the call has been accepted (either locally or remotely) * incoming: initial state for incoming calls * progress: initial state for outgoing calls * established: call media has been established * **dtmfToneSent**: emitted when one of the tones passed to `sendDtmf` is actually sent. An empty tone indicates all tones have finished playing. + #### Call.answer(options={}) Answer an incoming call. Supported options: * pcConfig: configuration options for `RTCPeerConnection`. [Reference](http://w3c.github.io/webrtc-pc/#configuration). * mediaConstraints: constraints to be used when getting the local user media. [Reference](http://www.w3.org/TR/mediacapture-streams/#mediastreamconstraints). * answerOptions: `RTCAnswerOptions`. [Reference](http://w3c.github.io/webrtc-pc/#idl-def-RTCAnswerOptions). * localStream: if specified, it will be used by sylkrtc instead of using `getUserMedia`. + #### Call.terminate() End the call. + #### Call.getLocalStreams() Returns an array of *local* `RTCMediaStream` objects. + #### Call.getRemoteStreams() Returns an array of *remote* `RTCMediaStream` objects. + #### Call.sendDtmf(tones, duration=100, interToneGap=70) Sends the given DTMF tones over the active audio stream track. **Note**: This feature requires browser support for `RTCPeerConnection.createDTMFSender`. + #### Call.account Getter property which returns the `Account` object associated with this call. + #### Call.id Getter property which returns the ID for this call. Note: this is not related to the SIP Call-ID header. + #### Call.direction Getter property which returns the call direction: "incoming" or "outgoing". Note: this is not related to the SDP "a=" direction attribute. + #### Call.state Getter property which returns the call state. + #### Call.localIdentity Getter property which returns the local identity. (See the `Identity` object). + #### Call.remoteIdentity Getter property which returns the remote identity. (See the `Identity` object). + +### Conference + +Object representing a multi-party audio/video conference. + +Events emitted: + +* **localStreamAdded**: emitted when the local stream is added to the call. A single argument is provided: the stream itself. +* **stateChanged**: indicates the conference state has changed. Three arguments are provided: `oldState`, `newState` and + `data`. `oldState` and `newState` indicate the previous and current state respectively, and `data` is a generic + per-state data object. Possible states: + * terminated: the conference has ended + * accepted: the initial offer has been accepted + * progress: initial state + * established: conference has been established and media is flowing +* **participantJoined**: emitted when a participant joined the conference. A single argument is provided: an instance of + `Participant`. Note that this event is only emitted when new participants join, `Conference.participants` should be checked + upon the initial join to check what participants are already in the conference. +* **participantLeft**: emitted when a participant leaves the conference. A single argument is provided: an instance of + `Participant`. + +#### Conference.getLocalStreams() + +Returns an array of *local* `RTCMediaStream` objects. These are the streams being published to the conference. + + +#### Conference.getRemoteStreams() + +Returns an array of *remote* `RTCMediaStream` objects. These are the streams published by all other participants in the conference. + + +#### Conference.participants + +Getter property which returns an array of `Participant` objects in the conference. + + +#### Conference.account + +Getter property which returns the `Account` object associated with this conference. + + +#### Conference.id + +Getter property which returns the ID for this conference. Note: this is not related to the URI. + + +#### Conference.direction + +Dummy property always returning "outgoing", in order to provide the same API as `Call`. + + +#### Conference.state + +Getter property which returns the conference state. + + +#### Conference.localIdentity + +Getter property which returns the local identity. (See the `Identity` object). This will always be built from the account. + + +#### Conference.remoteIdentity + +Getter property which returns the remote identity. (See the `Identity` object). This will always be built from the remote URI. + + +### Participant + +Object representing another user connected to the same conference. + +Events emitted: + +* **streamAdded**: emitted when a remote stream is added. A single argument is provided: the stream itself. +* **stateChanged**: indicates the participant state has changed. Three arguments are provided: `oldState`, `newState` and + `data`. `oldState` and `newState` indicate the previous and current state respectively, and `data` is a generic + per-state data object. Possible states: + * null: initial state + * progress: the participant is being attached to, this will happen as a result to `Participant.attach` + * established: media is flowing from this participant + + +#### Participant.id + + Getter property which returns the ID for this participant. Note this an abstract ID. + + +#### Participant.state + + Getter property which returns the participant state. + + +#### Participant.identity + + Getter property which returns the participant's identity. (See the `Identity` object). + + +#### Participant.streams + + Getter property which returns the audio / video streams for this participant. + + +#### Participant.attach() + + Start receiving audio / video from this participant. Once attached the participant's state will switch to 'established' + and its audio /video stream(s) will be available in `Participant.streams`. If a participant is not attached to, no + audio or video will be received from them. + + +#### Participant.detach() + + Stop receiving audio / video from this participant. The opposite of `Participant.attach()`. + + ### Identity Object representing the identity of the caller / callee. + #### Identity.uri SIP URI, without the 'sip:' prefix. + #### Identity.displayName Display name assiciated with the identity. Set to '' if absent. + #### Identity.toString() Function returning a string representation of the identity. It can take 2 forms depending on the availability of the display name: 'bob@biloxi.com' or 'Bob '. + ## License MIT. See the `LICENSE` file in this directory. + ## Credits Special thanks to [NLnet](http://nlnet.nl) for sponsoring most of the efforts behind this project. diff --git a/lib/account.js b/lib/account.js index da6c1ce..4ff2782 100644 --- a/lib/account.js +++ b/lib/account.js @@ -1,135 +1,146 @@ 'use strict'; import debug from 'debug'; import md5 from 'blueimp-md5'; import transform from 'sdp-transform'; import { EventEmitter } from 'events'; -import { Call, Identity } from './call'; +import { Call } from './call'; +import { ConferenceCall } from './conference'; +import { Identity } from './utils'; const DEBUG = debug('sylkrtc:Account'); class Account extends EventEmitter { constructor(options, connection) { if (options.account.indexOf('@') === -1) { throw new Error('Invalid account id specified'); } super(); const id = options.account; const [username, domain] = id.split('@'); this._id = id; this._displayName = options.displayName; this._password = md5(username + ':' + (options.realm || domain)+ ':' + options.password); this._connection = connection; this._registrationState = null; this._calls = new Map(); + this._confCalls = new Map(); } get id() { return this._id; } get password() { return this._password; } get displayName() { return this._displayName; } get registrationState() { return this._registrationState; } register() { let req = { sylkrtc: 'account-register', account: this._id }; this._sendRequest(req, (error) => { if (error) { DEBUG('Register error: %s', error); const oldState = this._registrationState; const newState = 'failed'; let data = {reason: error.toString()}; this._registrationState = newState; this.emit('registrationStateChanged', oldState, newState, data); } }); } unregister() { let req = { sylkrtc: 'account-unregister', account: this._id, }; this._sendRequest(req, (error) => { if (error) { DEBUG('Unregister error: %s', error); } const oldState = this._registrationState; const newState = null; this._registrationState = newState; this.emit('registrationStateChanged', oldState, newState, {}); }); } call(uri, options={}) { let callObj = new Call(this); callObj._initOutgoing(uri, options); this._calls.set(callObj.id, callObj); this.emit('outgoingCall', callObj); return callObj; } + joinConference(uri, options={}) { + let confCall = new ConferenceCall(this); + confCall._initialize(uri, options); + this._confCalls.set(confCall.id, confCall); + this.emit('conferenceCall', confCall); + return confCall; + } + // Private API _handleEvent(message) { DEBUG('Received account event: %s', message.event); let data = {}; switch (message.event) { case 'registration_state': const oldState = this._registrationState; const newState = message.data.state; this._registrationState = newState; if (newState === 'failed') { data.reason = message.data.reason; } this.emit('registrationStateChanged', oldState, newState, data); break; case 'incoming_session': let call = new Call(this); call._initIncoming(message.session, message.data.originator, message.data.sdp); this._calls.set(call.id, call); // see what media types are offered let mediaTypes = { audio: false, video: false }; const parsedSdp = transform.parse(message.data.sdp); for (let media of parsedSdp.media) { if (media.type === 'audio' && media.port !== 0 && media.direction === 'sendrecv') { mediaTypes.audio = true; } else if (media.type === 'video' && media.port !== 0 && media.direction === 'sendrecv') { mediaTypes.video = true; } } DEBUG('Incoming call from %s with media types: %o', message.data.originator.uri, mediaTypes); this.emit('incomingCall', call, mediaTypes); break; case 'missed_session': data.originator = new Identity(message.data.originator.uri, message.data.originator.display_name); this.emit('missedCall', data); break; default: break; } } _sendRequest(req, cb) { this._connection._sendRequest(req, cb); } } export { Account }; diff --git a/lib/call.js b/lib/call.js index 28141db..f4b2727 100644 --- a/lib/call.js +++ b/lib/call.js @@ -1,384 +1,360 @@ 'use strict'; import debug from 'debug'; import uuid from 'node-uuid'; import rtcninja from 'rtcninja'; import utils from './utils'; import { EventEmitter } from 'events'; const DEBUG = debug('sylkrtc:Call'); -class Identity { - constructor(uri, displayName='') { - this._uri = uri; - this._displayName = displayName; - } - - get uri() { - return this._uri; - } - - get displayName() { - return this._displayName; - } - - toString() { - if (!this._displayName) { - return this._uri; - } else { - return `${this._displayName} <${this._uri}>`; - } - } -} - - class Call extends EventEmitter { constructor(account) { super(); this._account = account; this._id = null; this._direction = null; this._pc = null; this._state = null; this._terminated = false; this._incomingSdp = null; - this._localIdentity = new Identity(account.id, account.displayName); + this._localIdentity = new utils.Identity(account.id, account.displayName); this._remoteIdentity = null; this._dtmfSender = null; // bind some handlers to this instance this._onDtmf = this._onDtmf.bind(this); } get account() { return this._account; } get id() { return this._id; } get direction() { return this._direction; } get state() { return this._state; } get localIdentity() { return this._localIdentity; } get remoteIdentity() { return this._remoteIdentity; } getLocalStreams() { if (this._pc !== null) { return this._pc.getLocalStreams(); } else { return []; } } getRemoteStreams() { if (this._pc !== null) { return this._pc.getRemoteStreams(); } else { return []; } } answer(options = {}) { if (this._state !== 'incoming') { throw new Error('Call is not in the incoming state: ' + this._state); } const pcConfig = options.pcConfig || {iceServers:[]}; const mediaConstraints = options.mediaConstraints || {audio: true, video: true}; const answerOptions = options.answerOptions; const localStream = options.localStream || null; // Create the RTCPeerConnection this._initRTCPeerConnection(pcConfig); utils.getUserMedia(mediaConstraints, localStream) .then((stream) => { this._pc.addStream(stream); this.emit('localStreamAdded', stream); this._pc.setRemoteDescription( new rtcninja.RTCSessionDescription({type: 'offer', sdp: this._incomingSdp}), // success () => { utils.createLocalSdp(this._pc, 'answer', answerOptions) .then((sdp) => { DEBUG('Local SDP: %s', sdp); this._sendAnswer(sdp); }) .catch((reason) => { DEBUG(reason); this.terminate(); }); }, // failure (error) => { DEBUG('Error setting remote description: %s', error); this.terminate(); } ); }) .catch(function(reason) { DEBUG(reason); this.terminate(); }); } terminate() { if (this._terminated) { return; } DEBUG('Terminating call'); this._sendTerminate(); } sendDtmf(tones, duration=100, interToneGap=70) { DEBUG('sendDtmf()'); if (this._dtmfSender === null) { if (this._pc !== null) { let track = null; try { track = this._pc.getLocalStreams()[0].getAudioTracks()[0]; } catch (e) { // ignore } if (track !== null) { DEBUG('Creating DTMF sender'); this._dtmfSender = this._pc.createDTMFSender(track); if (this._dtmfSender) { this._dtmfSender.addEventListener('tonechange', this._onDtmf); } } } } if (this._dtmfSender) { DEBUG('Sending DTMF tones'); this._dtmfSender.insertDTMF(tones, duration, interToneGap); } } // Private API _initOutgoing(uri, options={}) { if (uri.indexOf('@') === -1) { throw new Error('Invalid URI'); } this._id = uuid.v4(); this._direction = 'outgoing'; - this._remoteIdentity = new Identity(uri); + this._remoteIdentity = new utils.Identity(uri); const pcConfig = options.pcConfig || {iceServers:[]}; const mediaConstraints = options.mediaConstraints || {audio: true, video: true}; const offerOptions = options.offerOptions; const localStream = options.localStream || null; // Create the RTCPeerConnection this._initRTCPeerConnection(pcConfig); utils.getUserMedia(mediaConstraints, localStream) .then((stream) => { this._pc.addStream(stream); this.emit('localStreamAdded', stream); utils.createLocalSdp(this._pc, 'offer', offerOptions) .then((sdp) => { DEBUG('Local SDP: %s', sdp); this._sendCall(uri, sdp); }) .catch((reason) => { DEBUG(reason); this._localTerminate(reason); }); }) .catch(function(reason) { DEBUG(reason); this._localTerminate(reason); }); } _initIncoming(id, caller, sdp) { this._id = id; this._remoteIdentity = new Identity(caller.uri, caller.display_name); this._incomingSdp = sdp; this._direction = 'incoming'; this._state = 'incoming'; DEBUG('Remote SDP: %s', sdp); } _handleEvent(message) { DEBUG('Call event: %o', message); switch (message.event) { case 'state': const oldState = this._state; const newState = message.data.state; this._state = newState; let data = {}; if (newState === 'accepted' && this._direction === 'outgoing') { let sdp = utils.mungeSdp(message.data.sdp); DEBUG('Remote SDP: %s', sdp); this._pc.setRemoteDescription( new rtcninja.RTCSessionDescription({type: 'answer', sdp: sdp}), // success () => { DEBUG('Call accepted'); this.emit('stateChanged', oldState, newState, data); }, // failure (error) => { DEBUG('Error accepting call: %s', error); this.terminate(); } ); } else { if (newState === 'terminated') { data.reason = message.data.reason; this._terminated = true; this._closeRTCPeerConnection(); } this.emit('stateChanged', oldState, newState, data); } break; default: break; } } _initRTCPeerConnection(pcConfig) { if (this._pc !== null) { throw new Error('RTCPeerConnection already initialized'); } this._pc = new rtcninja.RTCPeerConnection(pcConfig); this._pc.onaddstream = (event, stream) => { DEBUG('Stream added'); this.emit('streamAdded', stream); }; this._pc.onicecandidate = (event) => { if (event.candidate !== null) { DEBUG('New ICE candidate %o', event.candidate); } else { DEBUG('ICE candidate gathering finished'); } this._sendTrickle(event.candidate); }; } _sendRequest(req, cb) { this._account._sendRequest(req, cb); } _sendCall(uri, sdp) { let req = { sylkrtc: 'session-create', account: this.account.id, session: this.id, uri: uri, sdp: sdp }; this._sendRequest(req, (error) => { if (error) { DEBUG('Call error: %s', error); this._localTerminate(error); } }); } _sendTerminate() { let req = { sylkrtc: 'session-terminate', session: this.id }; this._sendRequest(req, (error) => { if (error) { DEBUG('Error terminating call: %s', error); this._localTerminate(error); } this._terminated = true; }); setTimeout(() => { if (!this._terminated) { DEBUG('Timeout terminating call'); this._localTerminate('200 OK'); } this._terminated = true; }, 150); } _sendTrickle(candidate) { let req = { sylkrtc: 'session-trickle', session: this.id, candidates: candidate !== null ? [candidate] : [], }; this._sendRequest(req, null); } _sendAnswer(sdp) { let req = { sylkrtc: 'session-answer', session: this.id, sdp: sdp }; this._sendRequest(req, (error) => { if (error) { DEBUG('Answer error: %s', error); this.terminate(); } }); } _closeRTCPeerConnection() { DEBUG('Closing RTCPeerConnection'); if (this._pc !== null) { for (let stream of this._pc.getLocalStreams()) { rtcninja.closeMediaStream(stream); } for (let stream of this._pc.getRemoteStreams()) { rtcninja.closeMediaStream(stream); } this._pc.close(); this._pc = null; if (this._dtmfSender !== null) { this._dtmfSender.removeEventListener('tonechange', this._onDtmf); this._dtmfSender = null; } } } _localTerminate(error) { if (this._terminated) { return; } DEBUG('Local terminate'); this._account._calls.delete(this.id); this._terminated = true; const oldState = this._state; const newState = 'terminated'; let data = { reason: error.toString() }; this._closeRTCPeerConnection(); this.emit('stateChanged', oldState, newState, data); } _onDtmf(event) { DEBUG('Sent DTMF tone %s', event.tone); this.emit('dtmfToneSent', event.tone); } } -export { Call, Identity }; +export { Call }; diff --git a/lib/conference.js b/lib/conference.js new file mode 100644 index 0000000..3a65649 --- /dev/null +++ b/lib/conference.js @@ -0,0 +1,494 @@ +'use strict'; + +import debug from 'debug'; +import uuid from 'node-uuid'; +import rtcninja from 'rtcninja'; +import utils from './utils'; + +import { EventEmitter } from 'events'; + +const DEBUG = debug('sylkrtc:Conference'); + + +class Participant extends EventEmitter { + constructor(publisherId, identity, conference) { + super(); + this._id = uuid.v4(); + this._publisherId = publisherId; + this._identity = identity; + this._conference = conference; + this._state = null; + this._pc = null; + } + + get id() { + return this._id; + } + + get identity() { + return this._identity; + } + + get conference() { + return this._conference; + } + + get state() { + return this._state; + } + + get streams() { + if (this._pc !== null) { + return this._pc.getRemoteStreams(); + } else { + return []; + } + } + + attach() { + if (this._state !== null) { + return; + } + this._setState('progress'); + this._sendAttach(); + } + + detach() { + if (this._state !== null) { + this._sendDetach(); + } + } + + _setState(newState) { + const oldState = this._state; + this._state = newState; + DEBUG(`Participant ${this.id} state change: ${oldState} -> ${newState}`); + this.emit('stateChanged', oldState, newState); + } + + _handleOffer(offerSdp) { + DEBUG('Handling SDP for participant offer: %s', offerSdp); + + // Create the RTCPeerConnection + const pcConfig = this.conference._pcConfig; + const pc = new rtcninja.RTCPeerConnection(pcConfig); + pc.onaddstream = (event, stream) => { + DEBUG('Stream added'); + this.emit('streamAdded', stream); + }; + pc.onicecandidate = (event) => { + if (event.candidate !== null) { + DEBUG('New ICE candidate %o', event.candidate); + } else { + DEBUG('ICE candidate gathering finished'); + } + this._sendTrickle(event.candidate); + }; + this._pc = pc; + + // no need for a local stream since we are only going to receive media here + pc.setRemoteDescription( + new rtcninja.RTCSessionDescription({type: 'offer', sdp: offerSdp}), + // success + () => { + utils.createLocalSdp(pc, 'answer') + .then((sdp) => { + DEBUG('Local SDP: %s', sdp); + this._sendAnswer(sdp); + }) + .catch((reason) => { + DEBUG(reason); + this._close(); + }); + }, + // failure + (error) => { + DEBUG('Error setting remote description: %s', error); + this._close(); + } + ); + } + + _sendAttach() { + const req = { + sylkrtc: 'videoroom-ctl', + session: this.conference.id, + option: 'feed-attach', + feed_attach: { + session: this.id, + publisher: this._publisherId + } + }; + DEBUG('Sending request: %o', req); + this.conference._sendRequest(req, (error) => { + if (error) { + DEBUG('Error attaching to participant %s: %s', this._publisherId, error); + } + }); + } + + _sendDetach() { + const req = { + sylkrtc: 'videoroom-ctl', + session: this.conference.id, + option: 'feed-detach', + feed_detach: { + session: this.id + } + }; + DEBUG('Sending request: %o', req); + this.conference._sendRequest(req, (error) => { + if (error) { + DEBUG('Error detaching to participant %s: %s', this._publisherId, error); + } + this._close(); + }); + } + + _sendTrickle(candidate) { + const req = { + sylkrtc: 'videoroom-ctl', + session: this.conference.id, + option: 'trickle', + trickle: { + session: this.id, + candidates: candidate !== null ? [candidate] : [] + } + }; + this.conference._sendRequest(req, null); + } + + _sendAnswer(sdp) { + const req = { + sylkrtc: 'videoroom-ctl', + session: this.conference.id, + option: 'feed-answer', + feed_answer: { + session: this.id, + sdp: sdp + } + }; + this.conference._sendRequest(req, (error) => { + if (error) { + DEBUG('Answer error: %s', error); + this._close(); + } + }); + } + + _close() { + DEBUG('Closing Participant RTCPeerConnection'); + if (this._pc !== null) { + for (let stream of this._pc.getLocalStreams()) { + rtcninja.closeMediaStream(stream); + } + for (let stream of this._pc.getRemoteStreams()) { + rtcninja.closeMediaStream(stream); + } + this._pc.close(); + this._pc = null; + this._setState(null); + } + } +} + + +class ConferenceCall extends EventEmitter { + constructor(account) { + super(); + this._account = account; + this._id = null; + this._pc = null; + this._participants = new Map(); + this._terminated = false; + this._state = null; + this._localIdentity = new utils.Identity(account.id, account.displayName); + this._remoteIdentity = null; + this._pcConfig = null; // saved on initialize, used later for subscriptions + } + + get account() { + return this._account; + } + + get id() { + return this._id; + } + + get direction() { + // make this object API compatible with `Call` + return 'outgoing'; + } + + get state() { + return this._state; + } + + get localIdentity() { + return this._localIdentity; + } + + get remoteIdentity() { + return this._remoteIdentity; + } + + get participants() { + return Array.from(this._participants.values()); + } + + getLocalStreams() { + if (this._pc !== null) { + return this._pc.getLocalStreams(); + } else { + return []; + } + } + + getRemoteStreams() { + let streams = []; + for (let participant of this._participants) { + streams = streams.contact(participant.streams); + } + return streams; + } + + terminate() { + if (this._terminated) { + return; + } + DEBUG('Terminating conference'); + this._sendTerminate(); + } + + // Private API + + _initialize(uri, options={}) { + if (uri.indexOf('@') === -1) { + throw new Error('Invalid URI'); + } + + if (this._id !== null) { + throw new Error('Already initialized'); + } + + this._id = uuid.v4(); + this._remoteIdentity = new utils.Identity(uri); + + options = Object.assign({}, options); + const pcConfig = options.pcConfig || {iceServers:[]}; + this._pcConfig = pcConfig; + const mediaConstraints = options.mediaConstraints || {}; + // force audio and video + mediaConstraints.audio = true; + mediaConstraints.video = true; + const offerOptions = options.offerOptions || {}; + // only send audio / video through the publisher connection + offerOptions.offerToReceiveAudio = false; + offerOptions.offerToReceiveVideo = false; + delete offerOptions.mandatory; + + const localStream = options.localStream || null; + + // Create the RTCPeerConnection + this._pc = new rtcninja.RTCPeerConnection(pcConfig); + this._pc.onicecandidate = (event) => { + if (event.candidate !== null) { + DEBUG('New ICE candidate %o', event.candidate); + } else { + DEBUG('ICE candidate gathering finished'); + } + this._sendTrickle(event.candidate); + }; + + utils.getUserMedia(mediaConstraints, localStream) + .then((stream) => { + this._pc.addStream(stream); + this.emit('localStreamAdded', stream); + DEBUG('Offer options: %o', offerOptions); + utils.createLocalSdp(this._pc, 'offer', offerOptions) + .then((sdp) => { + DEBUG('Local SDP: %s', sdp); + this._sendJoin(sdp); + }) + .catch((reason) => { + this._localTerminate(reason); + }); + }) + .catch((reason) => { + this._localTerminate(reason); + }); + } + + _handleEvent(message) { + DEBUG('Conference event: %o', message); + switch (message.event) { + case 'state': + const oldState = this._state; + const newState = message.data.state; + this._state = newState; + let data = {}; + let participant; + + if (newState === 'accepted') { + let sdp = utils.mungeSdp(message.data.sdp); + DEBUG('Remote SDP: %s', sdp); + this._pc.setRemoteDescription( + new rtcninja.RTCSessionDescription({type: 'answer', sdp: sdp}), + // success + () => { + DEBUG('Conference accepted'); + this.emit('stateChanged', oldState, newState, data); + }, + // failure + (error) => { + DEBUG('Error processing conference accept: %s', error); + this.terminate(); + } + ); + } else { + if (newState === 'terminated') { + data.reason = message.data.reason; + this._terminated = true; + this._close(); + } + this.emit('stateChanged', oldState, newState, data); + } + break; + case 'initial_publishers': + // this comes between 'accepted' and 'established' states + for (let p of message.data.publishers) { + if (!this._participants.has(p.id)) { + participant = new Participant(p.id, new utils.Identity(p.uri, p.display_name), this); + this._participants.set(participant.id, participant); + } + } + break; + case 'publishers_joined': + for (let p of message.data.publishers) { + if (!this._participants.has(p.id)) { + DEBUG('Participant joined: %o', p); + participant = new Participant(p.id, new utils.Identity(p.uri, p.display_name), this); + this._participants.set(participant.id, participant); + this.emit('participantJoined', participant); + } + } + break; + case 'publishers_left': + for (let pId of message.data.publishers) { + for (participant of this._participants.values()) { + if (pId === participant._publisherId) { + this._participants.delete(participant.id); + this.emit('participantLeft', participant); + } + } + } + break; + case 'feed_attached': + participant = this._participants.get(message.data.subscription); + if (participant) { + participant._handleOffer(message.data.sdp); + } + break; + case 'feed_established': + participant = this._participants.get(message.data.subscription); + if (participant) { + participant._setState('established'); + } + break; + default: + break; + } + } + + _sendJoin(sdp) { + const req = { + sylkrtc: 'videoroom-join', + account: this.account.id, + session: this.id, + uri: this.remoteIdentity.uri, + sdp: sdp + }; + DEBUG('Sending request: %o', req); + this._sendRequest(req, (error) => { + if (error) { + this._localTerminate(error); + } + }); + } + + _sendTerminate() { + const req = { + sylkrtc: 'videoroom-terminate', + session: this.id + }; + this._sendRequest(req, (error) => { + if (error) { + DEBUG('Error terminating conference: %s', error); + this._localTerminate(error); + } + this._terminated = true; + }); + setTimeout(() => { + if (!this._terminated) { + DEBUG('Timeout terminating call'); + this._localTerminate(''); + } + this._terminated = true; + }, 150); + } + + _sendTrickle(candidate) { + const req = { + sylkrtc: 'videoroom-ctl', + session: this.id, + option: 'trickle', + trickle: { + candidates: candidate !== null ? [candidate] : [] + } + }; + this._sendRequest(req, null); + } + + _sendRequest(req, cb) { + this._account._sendRequest(req, cb); + } + + _close() { + DEBUG('Closing RTCPeerConnection'); + if (this._pc !== null) { + for (let stream of this._pc.getLocalStreams()) { + rtcninja.closeMediaStream(stream); + } + for (let stream of this._pc.getRemoteStreams()) { + rtcninja.closeMediaStream(stream); + } + this._pc.close(); + this._pc = null; + } + const participants = this.participants; + this._participants = []; + for (let p of participants) { + p._close(); + } + } + + _localTerminate(reason) { + if (this._terminated) { + return; + } + DEBUG(`Local terminate, reason: ${reason}`); + this._account._confCalls.delete(this.id); + this._terminated = true; + const oldState = this._state; + const newState = 'terminated'; + const data = { + reason: reason.toString() + }; + this._close(); + this.emit('stateChanged', oldState, newState, data); + } + +} + + +export { ConferenceCall }; diff --git a/lib/connection.js b/lib/connection.js index 00cbf63..b04990a 100644 --- a/lib/connection.js +++ b/lib/connection.js @@ -1,315 +1,324 @@ 'use strict'; import bowser from 'bowser'; import debug from 'debug'; import uuid from 'node-uuid'; import { EventEmitter } from 'events'; import { setImmediate } from 'timers'; import { w3cwebsocket as W3CWebSocket } from 'websocket'; import { Account } from './account'; const SYLKRTC_PROTO = 'sylkRTC-1'; const DEBUG = debug('sylkrtc:Connection'); const MSECS = 1000; const INITIAL_DELAY = 0.5 * MSECS; const MAX_DELAY = 64 * MSECS; // compute a string for our well-known platforms let platform; if (bowser.iphone) { platform = 'iPhone'; } else if (bowser.ipad) { platform = 'iPad'; } else if (bowser.ipod) { platform = 'iPod'; } else if (bowser.android) { platform = 'Android'; } else if (bowser.mac) { platform = 'macOS'; } else if (bowser.linux) { platform = 'Linux'; } else if (bowser.windows) { platform = 'Windows'; } else { platform = 'Unknown'; } if (bowser.osversion) { platform = `${platform} ${bowser.osversion}`; } let browser = bowser.name; if (bowser.version) { browser = `${browser} ${bowser.version}`; } const USER_AGENT = `SylkRTC (${browser} on ${platform})`; class Connection extends EventEmitter { constructor(options = {}) { if (!options.server) { throw new Error('\"server\" must be specified'); } super(); this._wsUri = options.server; this._sock = null; this._state = null; this._closed = false; this._timer = null; this._delay = INITIAL_DELAY; this._accounts = new Map(); this._requests = new Map(); } get state() { return this._state; } close() { if (this._closed) { return; } this._closed = true; if (this._timer) { clearTimeout(this._timer); this._timer = null; } if (this._sock) { this._sock.close(); this._sock = null; } else { setImmediate(() => { this._setState('closed'); }); } } addAccount(options = {}, cb = null) { if (typeof options.account !== 'string' || typeof options.password !== 'string') { throw new Error('Invalid options, \"account\" and \"password\" must be supplied'); } if (this._accounts.has(options.account)) { throw new Error('Account already added'); } let acc = new Account(options, this); // add it early to the set so we don't add it more than once, ever this._accounts.set(acc.id, acc); let req = { sylkrtc: 'account-add', account: acc.id, password: acc.password, display_name: acc.displayName, user_agent: USER_AGENT }; this._sendRequest(req, (error) => { if (error) { DEBUG('add_account error: %s', error); this._accounts.delete(acc.id); acc = null; } if (cb) { cb(error, acc); } }); } removeAccount(account, cb=null) { const acc = this._accounts.get(account.id); if (account !== acc) { throw new Error('Unknown account'); } // delete the account from the mapping, regardless of the result this._accounts.delete(account.id); let req = { sylkrtc: 'account-remove', account: acc.id }; this._sendRequest(req, (error) => { if (error) { DEBUG('remove_account error: %s', error); } if (cb) { cb(); } }); } reconnect() { if (this._state === 'disconnected') { clearTimeout(this._timer); this._delay = INITIAL_DELAY; this._timer = setTimeout(() => { this._connect(); }, this._delay); } } // Private API _initialize() { if (this._sock !== null) { throw new Error('WebSocket already initialized'); } if (this._timer !== null) { throw new Error('Initialize is in progress'); } DEBUG('Initializing'); if (process.browser) { window.addEventListener('beforeunload', () => { if (this._sock !== null) { let noop = function() {}; this._sock.onerror = noop; this._sock.onmessage = noop; this._sock.onclose = noop; this._sock.close(); } }); } this._timer = setTimeout(() => { this._connect(); }, this._delay); } _connect() { DEBUG('WebSocket connecting'); this._setState('connecting'); this._sock = new W3CWebSocket(this._wsUri, SYLKRTC_PROTO); this._sock.onopen = () => { DEBUG('WebSocket connection open'); this._onOpen(); }; this._sock.onerror = () => { DEBUG('WebSocket connection got error'); }; this._sock.onclose = (event) => { DEBUG('WebSocket connection closed: %d: (reason=\"%s\", clean=%s)', event.code, event.reason, event.wasClean); this._onClose(); }; this._sock.onmessage = (event) => { DEBUG('WebSocket received message: %o', event); this._onMessage(event); }; } _sendRequest(req, cb) { const transaction = uuid.v4(); req.transaction = transaction; if (this._state !== 'ready') { setImmediate(() => { cb(new Error('Connection is not ready')); }); return; } this._requests.set(transaction, {req: req, cb: cb}); this._sock.send(JSON.stringify(req)); } _setState(newState) { DEBUG('Set state: %s -> %s', this._state, newState); const oldState = this._state; this._state = newState; this.emit('stateChanged', oldState, newState); } // WebSocket callbacks _onOpen() { clearTimeout(this._timer); this._timer = null; this._delay = INITIAL_DELAY; this._setState('connected'); } _onClose() { this._sock = null; if (this._timer) { clearTimeout(this._timer); this._timer = null; } // remove all accounts, the server no longer has them anyway this._accounts.clear(); this._setState('disconnected'); if (!this._closed) { this._delay = this._delay * 2; if (this._delay > MAX_DELAY) { this._delay = INITIAL_DELAY; } DEBUG('Retrying connection in %s seconds', this._delay / MSECS); this._timer = setTimeout(() => { this._connect(); }, this._delay); } else { this._setState('closed'); } } _onMessage(event) { let message = JSON.parse(event.data); if (typeof message.sylkrtc === 'undefined') { DEBUG('Unrecognized message received'); return; } DEBUG('Received \"%s\" message: %o', message.sylkrtc, message); if (message.sylkrtc === 'event') { DEBUG('Received event: \"%s\"', message.event); switch (message.event) { case 'ready': this._setState('ready'); break; default: break; } } else if (message.sylkrtc === 'account_event') { let acc = this._accounts.get(message.account); if (!acc) { DEBUG('Account %s not found', message.account); return; } acc._handleEvent(message); } else if (message.sylkrtc === 'session_event') { const sessionId = message.session; for (let acc of this._accounts.values()) { let call = acc._calls.get(sessionId); if (call) { call._handleEvent(message); break; } } + } else if (message.sylkrtc === 'videoroom_event') { + const confId = message.session; + for (let acc of this._accounts.values()) { + let confCall = acc._confCalls.get(confId); + if (confCall) { + confCall._handleEvent(message); + break; + } + } } else if (message.sylkrtc === 'ack' || message.sylkrtc === 'error') { const transaction = message.transaction; const data = this._requests.get(transaction); if (!data) { DEBUG('Could not find transaction %s', transaction); return; } this._requests.delete(transaction); DEBUG('Received \"%s\" for request: %o', message.sylkrtc, data.req); if (data.cb) { if (message.sylkrtc === 'ack') { data.cb(null); } else { data.cb(new Error(message.error)); } } } } } export { Connection }; diff --git a/lib/utils.js b/lib/utils.js index fa6ed6d..158c56c 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -1,111 +1,135 @@ 'use strict'; import rtcninja from 'rtcninja'; import transform from 'sdp-transform'; +class Identity { + constructor(uri, displayName='') { + this._uri = uri; + this._displayName = displayName; + } + + get uri() { + return this._uri; + } + + get displayName() { + return this._displayName; + } + + toString() { + if (!this._displayName) { + return this._uri; + } else { + return `${this._displayName} <${this._uri}>`; + } + } +} + + function getUserMedia(mediaConstraints, localStream=null) { if (localStream !== null) { return Promise.resolve(localStream); } let p = new Promise(function(resolve, reject) { rtcninja.getUserMedia( mediaConstraints, //success function(stream) { resolve(stream); }, // failure function(error) { reject('Error getting user media: %s', error); } ); }); return p; } function createLocalSdp(pc, type, options) { if (type !== 'offer' && type !== 'answer') { return Promise.reject('type must be "offer" or "answer", but "' +type+ '" was given'); } let p = new Promise(function(resolve, reject) { let createFunc; if (type === 'offer' ) { createFunc = pc.createOffer; } else { createFunc = pc.createAnswer; } createFunc.call( pc, // success function(desc) { pc.setLocalDescription( desc, // success function() { resolve(mungeSdp(pc.localDescription.sdp)); }, // failure function(error) { reject('Error setting local description: ' + error); } ); }, // failure function(error) { reject('Error creating local SDP: ' + error); }, options ); }); return p; } function mungeSdp(sdp) { let parsedSdp = transform.parse(sdp); let h264payload = null; let hasProfileLevelId = false; // try to fix H264 support for (let media of parsedSdp.media) { if (media.type === 'video') { for (let rtp of media.rtp) { if (rtp.codec === 'H264') { h264payload = rtp.payload; break; } } if (h264payload !== null) { for (let fmtp of media.fmtp) { if (fmtp.payload === h264payload && fmtp.config.indexOf('profile-level-id') !== -1) { hasProfileLevelId = true; break; } } if (!hasProfileLevelId) { media.fmtp.push({payload: h264payload, config: 'profile-level-id=42e01f;packetization-mode=1;level-asymmetry-allowed=1'}); } break; } } } // remove bogus rtcp-fb elements for (let media of parsedSdp.media) { let payloads = String(media.payloads).split(' '); if (media.rtcpFb) { media.rtcpFb = media.rtcpFb.filter((item) => { return payloads.indexOf(String(item.payload)) !== -1; }); } } return transform.write(parsedSdp); } -export default { getUserMedia, createLocalSdp, mungeSdp }; +export default { Identity, getUserMedia, createLocalSdp, mungeSdp };