/**
* @file Defines a single WebRTC client (peer-to-peer connection) interface.
*
* @version 0.4.1
*/
/**
* @class A WebRTC client interface used to connect to and communicate
* with a single peer.
* @extends EventDispatcher
*/
class WebRTCClient extends EventDispatcher {
//Event definitions:
/**
* An ICE candidate has been received from one of the STUN servers,
*
* @event WebRTCClient#icecandidate
* @type {Event}
* @property {RTCIceCandidate} candidate The received candidate.
* @property {RTCSessionDescription} description The local session description including
* the <code>candidate</code> data.
*/
/**
* A received offer generated by an initiating peer has been received, processed,
* and an answer has been generated.
*
* @event WebRTCClient#offer
* @type {Event}
* @property {Object} offer The received offer as sent by the initiating peer.
* @property {Object} answer The generated answer.
*/
/**
* A received answer generated by an responding peer has been received and processed.
*
* @event WebRTCClient#answer
* @type {Event}
* @property {Object} answer The received answer generated by the responding peer.
*/
/**
* The data channel ({@link dataChannel}) created by the initiating peer has been created
* locally. Note that this even will not be dispatched if this instance is the initiating peer.
*
* @event WebRTCClient#remotechannel
* @type {Event}
*/
/**
* The data channel ({@link dataChannel}) has been successfully connected and
* bi-directional communication can now take place (e.g. using the {@link sendMessage}
* function).
*
* @event WebRTCClient#peerconnect
* @type {Event}
*/
/**
* The peer connection ({@link peerConnection}) or data channel ({@link dataChannel})
* has disconnected.
*
* @event WebRTCClient#peerdisconnect
* @type {Event}
*/
/**
* The data channel ({@link dataChannel}) has received a message from a
* connected peer.
*
* @event WebRTCClient#message
* @type {Event}
*
* @property {*} message The received message.
*/
/**
* The private ID of this connection has changed and a notification
* has been sent to the connected peer.
*
* @event WebRTCClient#privateid
* @type {Event}
*
* @property {String} privateID The new private ID for this connection.
*/
/**
* Creates a new instance of WebRTCClient.
*
* @param {Object} [signallingServer=null] A reference to the signalling
* server or mechanism for this instance to use during connection
* attempts and negotiations. If null, the [router]{@link WebRTCClient@router}
* property must be set manually prior to attempting a connection.
*/
constructor(p2pRouter=null) {
super();
if (p2pRouter != null) {
this.router = p2pRouter;
}
}
/**
* @property {Array|RTCIceServer} iceServers An indexed list of ICE/STUN servers
* to use when creating a new connection.<br/>
* Additional servers:<br/>
* https://gist.github.com/zziuni/3741933
* http://olegh.ftp.sh/public-stun.txt
* https://gist.github.com/mondain/b0ec1cf5f60ae726202e
* @readonly
*/
get iceServers() {
var serverList = [
{urls:["stun.l.google.com:19302",
"stun1.l.google.com:19302",
"stun2.l.google.com:19302",
"stun3.l.google.com:19302",
"stun4.l.google.com:19302"]
},
{urls:"stunserver.org"},
{urls:"stun.stunprotocol.org:3478"}
]
}
/**
* @property {Object} connectionConfig A <code>RTCPeerConnection</code> configuration
* object used to configure the instance.
* @readonly
*/
get connectionConfig() {
var config = new Object();
config.iceServers = this.iceServers;
}
/**
* @property {RTCPeerConnection} peerConnection=null The peer connection object being
* used by this instance. This property is set either when {@link createPeerConnection}
* is invoked or when {@link setRemoteDescription} is invoked. Returns <code>null</code>
* if no connection object currently exists.
* @readonly
*/
get peerConnection() {
if (this["_peerConnection"] == undefined) {
this._peerConnection = null;
}
return (this._peerConnection);
}
/**
* @property {String} channelName="p2pchannel" The default data channel name used by this
* instance.
* @readonly
*/
get channelName() {
return ("p2pchannel");
}
/**
* @property {Object} router=null The P2P router instance to use as a signalling mechanism
* for exchanging messages during connection and negotiation attempts.
*/
get router() {
if (this._router == undefined) {
this._router = null;
}
return (this._router);
}
set router(ssSet) {
this._router = ssSet;
}
/**
* @property {String} peerID The private ID of the remote peer to which this
* instance is connecting or connected.
*/
get peerPID () {
if (this._peerPID == undefined) {
this._peerPID = null;
}
return (this._peerPID);
}
/**
* @property {RTCDataChannel} dataChannel The main data channel used to communicate
* with a connected peer. Returns <code>null</code> if no data channel has been established.
* @readonly
*/
get dataChannel() {
if (this["_dataChannel"] == undefined) {
this._dataChannel = null;
}
return (this._dataChannel);
}
/**
* Adds the standard RTCDataChannel handlers to the {@link dataChannel} instance.
*
* @private
*/
addChannelHandlers() {
if (this.dataChannel == null) {
throw (new Error("No data channel established to which to attach listeners."));
}
this.dataChannel._client = this;
this.dataChannel.addEventListener("open", this.onChannelOpen);
this.dataChannel.addEventListener("close", this.onChannelClose);
this.dataChannel.addEventListener("message", this.onChannelMessage);
}
/**
* Begins checking the state of the {@link WebRTCClient#peerConnection} by calling
* {@link checkConnectionState} at regular intervals.
*
* @param {Number} checkInterval The millisecond interval at which to check the connection
* state.
* @private
* @todo Investigate further to see if there's a better way to detect disconnections
*/
startConnectionStateCheck(checkInterval=1000) {
this._stateCheckInterval = setInterval (this.checkConnectionState, checkInterval, this);
}
/**
* Function invoked at regular interval ticks to check the state of the
* {@link WebRTCClient#peerConnection} connection state and fire
* a "disconnect" event when a disconnection is detected.
*
* @param {WebRTCClient} context The execution context in which to invoke the
* check.
* @fires WebRTCClient#disconnect
* @private
*/
checkConnectionState(context) {
if (context.peerConnection.iceConnectionState == "disconnected") {
clearInterval(context._stateCheckInterval);
var newEvent = new Event("peerdisconnect");
context.dispatchEvent(newEvent);
}
}
/**
* Attempts to establish a direct peer connection over WebRTC.
*
* @param {String} privateID The private ID of the peer to connect to,
* as assigned by the {@link P2PRouter} or other external ID manager.
* @param {Object} [config=null] A RTCPeerConnection configuration object. If
* omitted or null, the [connectionConfig]{@link WebRTCClient#connectionConfig} object is used.
*
* @async
*/
async connect(privateID, config=null) {
if (config == null) {
config = this.connectionConfig;
}
this._peerPID = privateID;
this._peerConnection = new RTCPeerConnection(config); //create initiating connection
this.peerConnection._client = this; //store reference for event handlers
this.peerConnection.addEventListener("negotiationneeded", this.onConnectionNegotiate);
this.peerConnection.addEventListener("icecandidate", this.onICECandidate);
this.router.addEventListener("message", this.onSignalMessage, this);
this._dataChannel = this.peerConnection.createDataChannel(this.channelName);
this.addChannelHandlers();
return (true);
}
/**
* Changes the private ID associated with this connection. Any attached
* peer is notified of this changed via a <code>session</code> message.
*
* @param {String} newPrivateID The new private ID to set for this connection.
*
* @return {Promise} The promise resolves with <code>true</code> if the private
* ID was successfully changed, otherwise it rejects with <code>false</code>.
*
* @async
*/
async changePrivateID(newPrivateID) {
var JSONObj = buildJSONRPC("notification");
JSONObj.result.type = "session";
JSONObj.result.change = new Object();
JSONObj.result.change.oldPrivateID = this.privateID;
JSONObj.result.change.newPrivateID = newPrivateID;
this.dataChannel.send(JSON.stringify(JSONObj)); //don't wait for response
this._privateID = newPrivateID;
var event = new Event("privateid");
event.privateID = newPrivateID;
this.dispatchEvent(event);
return (true);
}
/**
* Sets a remote offer SDP received from an initiating peer during a connection
* negotiation.
*
* @param {String} privateID The private ID of the peer sending the offer SDP.
* @param {Object|String} descData The SDP data sent by the <code>privateID</code>
* If this is a string, it's assumed to be stringified JSON data which will be
* parsed before being processed.
*
* @fires WebRTCClient#offer
* @async
* @private
*/
async setRemoteOffer(privateID, descData) {
if (typeof(descData) == "string") {
var description = new RTCSessionDescription(JSON.parse(descData));
} else {
description = new RTCSessionDescription(descData);
}
this._peerPID = privateID;
this._peerConnection = new RTCPeerConnection(this.connectionConfig); //create responding connection
this.peerConnection.addEventListener("icecandidate", this.onICECandidate);
this.peerConnection._client = this; //store reference for event handlers
this.peerConnection.ondatachannel = this.onDataChannelEstablished; //use initiating data channel
this.router.addEventListener("message", this.onSignalMessage, this);
var result = await this.peerConnection.setRemoteDescription(description);
var answerDesc = await this.peerConnection.createAnswer();
result = await this.peerConnection.setLocalDescription(answerDesc);
var answer = this.peerConnection.localDescription;
this.sendSignalMessage("webrtcanswer", {
answer: answer
});
var event = new Event("offer");
event.offer = result;
event.answer = answer;
this.dispatchEvent(event);
return (answer);
}
/**
* Sends a peer-to-peer "direct" message to the connected peer.
*
* @param {*} data The data / message to send.
* @param {Object|Array} recipients An object or an array of recipient private
* IDs. If this parameter is an object, one or more additional properties are
* expected:
* @param {Array} [recipients.rcp] An indexed array of recipient private IDs.
* If this list is provided as the <code>recipients</code> parameter this
* structure is dynamicaly generated before sending.
*
* @return {Promise} An asynchronous Promise that will contain the result of
* the send or reject with an <code>Error</code> object on failure.
* @async
* @TODO Update functionality to support message signing and forwarding for peer-to-peer meshes.
*/
async send(data, recipients=null) {
if (typeof(recipients) != "object") {
throw (new Error(`"recipients" parameter must be an array!`));
}
if (typeof(recipients["length"]) == "number") {
//recipients is an array so add it to the "rcp" property
var WSSRecpObj = new Object();
WSSRecpObj.rcp = recipients;
} else {
//recipients is al_wssReady an object so assume everything is in place
WSSRecpObj = recipients;
}
var JSONObj = buildJSONRPC("notification"); //don't include id
JSONObj.result.type = "direct";
JSONObj.result.data = data;
this.dataChannel.send(JSON.stringify(JSONObj));
return (this.createMessageSentEvent());
}
/**
* Sends a peer-to-peer "broadcast" message to the connected peer.
*
* @param {*} data The data / message to broadcast.
* @param {Object|Array} [excludeRecipients=null] NOT CURRENTLY IMPLEMENTED.
*
* @return {Promise} An asynchronous Promise that will contain the result of
* the send or reject with an <code>Error</code> object on failure.
* @async
* @TODO Update functionality to support message signing and forwarding for peer-to-peer meshes
*/
async broadcast (data, excludeRecipients=null) {
var JSONObj = buildJSONRPC("notification"); //don't include id
JSONObj.result.type = "broadcast";
JSONObj.result.data = data;
this.dataChannel.send(JSON.stringify(JSONObj));
return (this.createMessageSentEvent());
}
/**
* Sends a peer-originating "update" message to the connected peer,
* mimicking an API server "update" message.
*
* @param {String|Object} data The message to send. If the message is
* not a string it is stringified into JSON string data.
*
* @todo Test this functionality
*/
sendUpdate (data) {
var JSONObj = buildJSONRPC("notification"); //don't include id
JSONObj.result.type = "update";
JSONObj.result.data = data;
this.dataChannel.send(JSON.stringify(JSONObj));
}
/**
* Creates a message-sent response event that mimics one received
* when messages are sent to peers over WebSocket Sessions.
*
* @return {Event} A message-sent event mimicking that received from
* a WebSocket Sessions server after a peer message was sent.
*/
createMessageSentEvent() {
var responseEvent = new Event("message");
Object.defineProperty(responseEvent, "target", {writable: true});
Object.defineProperty(responseEvent, "currentTarget", {writable: true});
responseEvent.target = this;
responseEvent.currentTarget = this;
var responseObj = buildJSONRPC("result", {"id":uniqueRPCID()});
responseObj = new Object();
responseObj.message = "ok";
responseEvent.data = responseObj;
}
/**
* Sends a signalling message to the potential peer using the {@link P2PRouter}.
*
* @param {String} messageType The type of signalling message to send. See the
* [onSignalMessage]{@link WebRTCClient#onSignalMessage} function for supported
* message types.
* @param {*} [data=null] Optional data to include with the signalling message.
* @param {Boolean} internal=true If true, the message is handled by this WebRTCClient
* instance. If false, it's handled by the {@link P2PRouter} instance (typically this
* is just the negotiation-start message).
*/
sendSignalMessage(messageType, data=null, internal=true) {
if (internal == false) {
//router-based (external) signalling message
var message = this.router.buildRouterMessage(messageType);
} else {
//instance-based (internal) signalling message
message = this.buildSignalMessage(messageType);
}
if (data != null) {
for (var item in data) {
if (item != "type") {
message[item] = data[item];
}
}
}
this.router.send(message, [this.peerPID]);
}
/**
* Sends an API request using the [dataChannel]{@link WebRTCClient#dataChannel}.
* <b>NOT YET IMPLEMENTED</b>
*
* @param {Object} requestObj The JSON-RPC 2.0 request to send.
*
* @return {Promise} An asynchronous promise that will resolve with the JSON-RPC 2.0
* API response.
*/
request(requestObj) {
var promise = new Promise((resolve, reject) => {
reject (new Error("Function not implemented yet."));
})
return (promise);
}
/**
* Builds a WebRTCClient signalling message.
*
* @param {String} messageType The signalling message type to create.
*
* @return {Object} A signalling message object containing the <code>webRTCSignalMsg</code>
* property which contains the message type.
*/
buildSignalMessage(messageType) {
var messageObj = new Object();
messageObj.webRTCSignalMsg = messageType;
return (messageObj);
}
/**
* Verifies if a supplied message event object contains a valid WebRTC signal message.
*
* @param {Event} event The "message" event, as usually dispatched by the
* peer-to-peer interface, to examine.
*
* @return {Boolean} True if the event contains a valid WebRTC signal message
* (though its type may not be supported).
* @private
*/
isSignalMsgEvent(event) {
try {
if (typeof(event["data"]) != "object") {
//not sure what this is
return (false);
}
if (typeof(event.data["result"]) != "object") {
//may not be a JSON-RPC message
return (false);
}
if (typeof(event.data.result["data"]) != "object") {
//not a router-formatted message
return (false);
}
return (this.isSignalMessage(event.data.result.data));
} catch (err) {
return (false);
}
}
/**
* Verifies if a supplied object is a valid WebRTC signal message.
*
* @param {Object} message The object to examine.
*
* @return {Boolean} True if the object seems to be a valid WebRTC signal message
* (though it may not be supported).
* @private
*/
isSignalMessage(message) {
if ((message["webRTCSignalMsg"] == undefined) || (message["webRTCSignalMsg"] == null) || (message["webRTCSignalMsg"] == "")) {
//not a P2PRouter message or it's blank (mo message type)
return (false);
}
return (true);
}
/**
* Handles any "message" events received on the signalling transport. Any non-signalling messages
* are ignored.
*
* @param {Event} event A message event received from the signalling transport.
*
* @private
*/
async onSignalMessage(event) {
if (this.isSignalMsgEvent(event)) {
var fromPID = event.data.result.from;
var msgData = event.data.result.data;
var messageType = msgData.webRTCSignalMsg;
switch (messageType) {
case "webrtcnegotiate":
event.target._client = this;
this.onConnectionNegotiate(event);
break;
case "webrtcanswer":
var result = await this.peerConnection.setRemoteDescription(msgData.answer);
var newEvent = new Event("answer");
newEvent.answer = msgData.answer;
this.dispatchEvent(newEvent);
break;
case "webrtcicecandidate":
var candidate = new RTCIceCandidate(msgData.ice);
this.peerConnection.addIceCandidate(candidate);
break;
case "webrtcicecomplete":
this.peerConnection.addIceCandidate(null);
break;
default:
//not a recognized signalling message type
break;
}
} else {
//not a signalling message
return (false);
}
return (true);
}
/**
* Handles "negotiationneeded" events for the [peerConnection]{@link WebRTCClient#peerConnection}.
*
* @param {Event} event A "negotiationneeded" RTCPeerConnection event.
*
* @private
*/
async onConnectionNegotiate(event) {
var wrtcClient = event.target._client;
wrtcClient.peerConnection.createOffer().then(offer => {
wrtcClient.peerConnection.setLocalDescription(offer).then(result => {
wrtcClient.sendSignalMessage("peerconnectreq", {
transportType: "webrtc",
offer: offer,
options: wrtcClient.router.constructor.supportedTransports.options
}, false);
});
}).catch(err => {
console.error (err);
});
}
/**
* Handles "icecandidate" events for the [peerConnection]{@link WebRTCClient#peerConnection}.
*
* @param {Event} event A "icecandidate" RTCPeerConnection event.
*
* @fires WebRTCClient#icecandidate
* @private
*/
onICECandidate(event) {
var wrtcClient = event.target._client;
if ((event.target.iceGatheringState == "complete") || (event.candidate == null)) {
wrtcClient.sendSignalMessage("webrtcicecomplete");
} else if (event["candidate"] != undefined) {
var ice = event.candidate;
wrtcClient.sendSignalMessage("webrtcicecandidate", {"ice":ice});
var newEvent = new Event("icecandidate");
newEvent.candidate = ice;
newEvent.description = ice.description;
wrtcClient.dispatchEvent(newEvent);
}
}
/**
* Handles "iceconnectionstatechange" events for the [peerConnection]{@link WebRTCClient#peerConnection}.
*
* @param {Event} event A "iceconnectionstatechange" RTCPeerConnection event.
*
* @fires WebRTCClient#peerdisconnect
* @private
*/
onIceConnectionStateChange(event) {
if (this.peerConnection.iceConnectionState == "disconnected") {
var newEvent = new Event("peerdisconnect");
this.dispatchEvent(newEvent);
}
}
/**
* Event handler invoked when the initiating peer's channel has been locally established.
*
* @param {Event} event A <code>RTCPeerConnection</code> event.
*
* @fires WebRTCClient#remotechannel
* @private
*/
onDataChannelEstablished(event) {
var wrtcClient = event.target._client;
wrtcClient._dataChannel = event.channel;
wrtcClient.addChannelHandlers();
var newEvent = new Event("remotechannel");
wrtcClient.dispatchEvent(newEvent);
}
/**
* Event handler invoked when the {@link dataChannel} connects.
*
* @param {Event} event A <code>RTCPeerConnection</code> event.
*
* @fires WebRTCClient#peerconnect
* @private
*/
onChannelOpen(event) {
var wrtcClient = event.target._client;
if (event.target.readyState == "open") {
wrtcClient.startConnectionStateCheck();
wrtcClient.addEventListener("iceconnectionstatechange", wrtcClient.onIceConnectionStateChange, wrtcClient);
var newEvent = new Event("peerconnect");
wrtcClient.dispatchEvent(newEvent);
}
}
/**
* Event handler invoked when the {@link dataChannel} disconnects.
*
* @param {Event} event A <code>RTCDataChannel</code> event.
*
* @fires WebRTCClient#peerdisconnect
* @private
*/
onChannelClose(event) {
var wrtcClient = event.target._client;
var newEvent = new Event("peerdisconnect");
wrtcClient.dispatchEvent(newEvent);
}
/**
* Event handler invoked when the {@link dataChannel} receives a message
* from a connected peer.
*
* @param {Event} event A <code>RTCDataChannel</code> event.
*
* @fires WebRTCClient#message
* @private
*/
onChannelMessage(event) {
var wrtcClient = event.target._client;
try {
var dataObj = JSON.parse(event.data);
switch (dataObj.result.type) {
case "broadcast":
var newEvent = new Event("message");
newEvent.data = dataObj;
wrtcClient.dispatchEvent(newEvent);
break;
case "direct":
newEvent = new Event("message");
newEvent.data = dataObj;
wrtcClient.dispatchEvent(newEvent);
break;
case "update":
newEvent = new Event("update");
newEvent.data = dataObj;
wrtcClient.dispatchEvent(newEvent);
break;
case "session":
if (typeof(dataObj.result.change) == "object") {
//in the future we may support different types of session updates
event = new Event("peerpid");
event.data = dataObj;
wrtcClient.peerID = dataObj.result.newPrivateID;
wrtcClient.dispatchEvent(event);
} else {
//unhandled session message
}
break;
default:
console.error ("Unrecognized messaged type \""+dataObj.result.type+"\"");
//unhandled message type
break;
}
} catch (err) {
console.error(err);
}
}
/**
* Disconnects the WebRTC client connection and removes any event listeners.
*
* @async
*/
async disconnect() {
try {
this.router.removeEventListener("message", this.onSignalMessage);
} catch (err) {
}
try {
this.dataChannel.removeEventListener("open", this.onChannelOpen);
this.dataChannel.removeEventListener("close", this.onChannelClose);
this.dataChannel.removeEventListener("message", this.onChannelMessage);
delete this.dataChannel._client;
} catch (err) {
}
try {
this.peerConnection.removeEventListener("message", this.handleSocketMessage);
this.peerConnection.removeEventListener("close", this.handleSocketClose);
this.peerConnection.removeEventListener("negotiationneeded", this.onConnectionNegotiate);
this.peerConnection.removeEventListener("icecandidate", this.onICECandidate);
this.peerConnection.removeEventListener("iceconnectionstatechange", this.onIceConnectionStateChange);
this.peerConnection.close();
delete this.peerConnection._client;
this._peerConnection = null;
} catch (err) {
}
return (true);
}
toString() {
return ("WebRTCClient");
}
}