diff --git a/README.md b/README.md index 96a8e35..530fae2 100644 --- a/README.md +++ b/README.md @@ -1,258 +1,262 @@ # sylkrtc.js JavaScript library implementing the API for communicating with [SylkServer's](http://sylkserver.com) WebRTC gateway application. ## 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*. 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 +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}}); #### 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 #### 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.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). ### 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 134626c..68ca127 100644 --- a/lib/account.js +++ b/lib/account.js @@ -1,129 +1,135 @@ 'use strict'; import debug from 'debug'; import transform from 'sdp-transform'; import { md5 } from 'blueimp-md5'; import { EventEmitter } from 'events'; import { Call, Identity } from './call'; 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(); } get id() { return this._id; } get password() { return this._password; } + get displayName() { + return displayName; + } + get registrationState() { return this._registrationState; } register() { let req = { sylkrtc: 'account-register', - account: this._id + account: this._id, + display_name: this._displayName, }; 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; } // 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 483d628..77d757b 100644 --- a/lib/call.js +++ b/lib/call.js @@ -1,465 +1,463 @@ 'use strict'; import debug from 'debug'; import uuid from 'node-uuid'; import rtcninja from 'rtcninja'; import transform from 'sdp-transform'; 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 = null; + this._localIdentity = new Identity(account.id, account.displayName); this._remoteIdentity = null; } 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 self = this; 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); if (localStream !== null) { // Use the provided stream userMediaSucceeded(localStream); } else { // Get the user media rtcninja.getUserMedia( mediaConstraints, userMediaSucceeded, userMediaFailed ); } function userMediaSucceeded(stream) { // adding a local stream doesn't trigger the 'onaddstream' callback self._pc.addStream(stream); self.emit('localStreamAdded', stream); self._pc.setRemoteDescription( new rtcninja.RTCSessionDescription({type: 'offer', sdp: self._incomingSdp}), // success function() { self._createLocalSDP( 'answer', answerOptions, // success function(sdp) { DEBUG('Local SDP: %s', sdp); self._sendAnswer(sdp); }, // failure function(error) { DEBUG('Error creating local SDP: %s', error); self.terminate(); } ); }, // failure function(error) { DEBUG('Error setting remote description: %s', error); self.terminate(); } ); } function userMediaFailed(error) { DEBUG('Error getting user media: %s', error); self.terminate(); } } terminate() { if (this._terminated) { return; } DEBUG('Terminating call'); this._sendTerminate(); } // Private API _initOutgoing(uri, options={}) { if (uri.indexOf('@') === -1) { throw new Error('Invalid URI'); } this._id = uuid.v4(); this._direction = 'outgoing'; - this._localIdentity = new Identity(this._account.id); this._remoteIdentity = new Identity(uri); const self = this; 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); if (localStream !== null) { // Use the provided stream userMediaSucceeded(localStream); } else { // Get the user media rtcninja.getUserMedia( mediaConstraints, userMediaSucceeded, userMediaFailed ); } function userMediaSucceeded(stream) { // adding a local stream doesn't trigger the 'onaddstream' callback self._pc.addStream(stream); self.emit('localStreamAdded', stream); self._createLocalSDP( 'offer', offerOptions, // success function(sdp) { DEBUG('Local SDP: %s', sdp); self._sendCall(uri, sdp); }, // failure function(error) { DEBUG('Error creating local SDP: %s', error); self._localTerminate(error); } ); } function userMediaFailed(error) { DEBUG('Error getting user media: %s', error); self._localTerminate(error); } } _initIncoming(id, caller, sdp) { this._id = id; - this._localIdentity = new Identity(this._account.id); this._remoteIdentity = new Identity(caller.uri, caller.display_name); this._incomingSdp = sdp; this._direction = 'incoming'; this._state = 'incoming'; } _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') { const self = this; this._pc.setRemoteDescription( new rtcninja.RTCSessionDescription({type: 'answer', sdp: message.data.sdp}), // success function() { DEBUG('Call accepted'); self.emit('stateChanged', oldState, newState, data); }, // failure function(error) { DEBUG('Error accepting call: %s', error); self.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'); } const self = this; this._pc = new rtcninja.RTCPeerConnection(pcConfig); this._pc.onaddstream = function(event, stream) { DEBUG('Stream added'); self.emit('streamAdded', stream); }; this._pc.onicecandidate = function(event) { let candidate = null; if (event.candidate !== null) { candidate = { 'candidate': event.candidate.candidate, 'sdpMid': event.candidate.sdpMid, 'sdpMLineIndex': event.candidate.sdpMLineIndex }; DEBUG('New ICE candidate %o', candidate); } self._sendTrickle(candidate); }; } _createLocalSDP(type, options, onSuccess, onFailure) { const self = this; if (type === 'offer') { this._pc.createOffer( // success createSucceeded, // failure failure, // options options ); } else if (type === 'answer') { this._pc.createAnswer( // success createSucceeded, // failure failure, // options options ); } else { throw new Error('type must be "offer" or "answer", but "' +type+ '" was given'); } function createSucceeded(desc) { self._pc.setLocalDescription( desc, // success function() { onSuccess(self._fixLocalSdp(self._pc.localDescription.sdp)); }, // failure failure ); } function failure(error) { onFailure(error); } } _fixLocalSdp(sdp) { let parsedSdp = transform.parse(sdp); let h264payload = null; let hasProfileLevelId = false; 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; } } } return transform.write(parsedSdp); } _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 }; const self = this; this._sendRequest(req, function(error) { if (error) { DEBUG('Call error: %s', error); self._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; }, 750); } _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 }; const self = this; this._sendRequest(req, function(error) { if (error) { DEBUG('Answer error: %s', error); self.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; } } _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); } } export { Call, Identity };