UNPKG

webrtc-mesh

Version:

using a signaler, mesh between node, browsers and other applications

521 lines (441 loc) 15 kB
/* WEBRTC MESH Module Author: Jachen Duschletta October 2020 - COVID-19 Pandemic still ongoing Topologies: Mesh 100% Purpose: Using WebRTC create a datachannel layer between all peers connecting to the same signaling server. This may be your application server or a microservice. */ /* The idea is to announce yourself to any other peers and to listen to new arrivals. Once someone new arrives, we send them an offer to accept a data channel connection. After some back and forth, we are connected and can freely communicate without needing a server. (TURN servers are not currently supported ) Some of this code is from Mozilla Developer Network, but the rest is mostly my own homegrown hacks over the webRTC API built into browsers */ /* node specific version due to 2 things: - Dependency to simulate webrtc and websockets - Exports since node doesn't support modules out of the box yet */ const util = require('util'); var TextEncoder = util.TextEncoder; const wrtc = require('wrtc'); const WebSocket = require('websocket').w3cwebsocket; var RTCPeerConnection = wrtc.RTCPeerConnection; var { Duplex } = require('stream'); exports.Mesh = function Mesh (config) { /* To identify yourself to the signaling server and to others We generate a random UUID as our peer ID. This is not expected to be the same for every connection, it's supposed to serve as a changing uuid that other clients can hold in memory and on the app side could associate to a specific user. */ const peerId = generatePID(); /* The appKey serves as a filter. This way the websocket server used could send multiple meshs over the same server, without having to worry about routing or filtering broadcasts. Instead we just assume we get flooded on the client and ignore what we don't want. */ const appKey = config.appKey || 'mesh'; const debug = config.debug || false; /* An array of callback function that are called upon arrival of new messages. */ var messageListener = []; /* An array of writable streams we write to */ var pipedListener = []; /* An array of callback functions subscribed to the 'open datachannel event' */ var openListener = []; /* Globals for State Management */ // map of PID containing status objects and connection reference, key = pid var peers = new Map(); // queue of 'to connect to' peer ids (pid) var queue = []; /* initialize encoder / decoder interface to turn strings into arraybuffer. The reason we do this is so we can stay binary across the wire, to make handling it easier on each side. */ var encoder = new TextEncoder(); var decoder = new TextDecoder(); /* We use UUID v4 algo for the uuid. */ function generatePID () { return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxx'.replace(/[xy]/g, function(c) { var r = Math.random() * 16 | 0, v = c == 'x' ? r : (r & 0x3 | 0x8); return v.toString(16); }) } /* Then we connect to the websocket signaling server using the 'json' (string or binary both supported) */ var ws = new WebSocket(config.url,'binary') /* When the connection opens we want to broadcast ourselves to the other peers */ ws.addEventListener('open', () => { // Announce your peer ID to all the peers var data = { broadcast: true, announce: true, from: peerId, msg: "I am " + peerId, key: appKey, } var msg = JSON.stringify(data); ws.send(msg) }); /* The server will respond or send messages to our peer and we need to handle that Below function handles incoming messages. Each message type needs a slightly different handler. */ ws.addEventListener('message', event => { //console.debug(event.data); // receive the data into an object var msg = JSON.parse(event.data); if (msg.from == peerId || msg.length == 0 || msg.dam || msg.key != appKey) { return; } //don't listen to yourself if(debug){console.log(msg.to, msg.to == peerId, msg);} // if we receive a peer announcement, add them to the list to try to connect to if (msg.announce) { if(debug){console.log('received announcement', msg);} if(msg.from == undefined) { return;} // add this new peer to our list of peers add(msg.from); connect(); // if we receive an offer, let's handle it } else if (msg.offer) { if(msg.to == peerId){ if(debug){console.log('received offer from ', msg.from, msg);} handleOffer(msg); } // if we receive an answer, let's handle it } else if (msg.answer) { if(msg.to == peerId) { if(debug){console.log('received answer from ', msg.from, msg);} handleAnswer(msg); } } else if (msg.candidate) { if(msg.to == peerId) { if(debug){console.log('received candidate from ', msg.from, msg);} handleCandidates(msg); } } else if (msg.left) { // remove this peer from our list config.leavehandler(msg.from); if(debug){console.log('removing', msg);} remove(msg.from); } }); /* Define add and remove from the list */ // add a new pid to the list of items function add (pid) { if(debug){console.log('called add');} // check if we already have this item var haveIt = peers.has(pid) if(debug){console.warn('we have this peer:', pid, haveIt);} if(!haveIt) { // add new peer peers.set(pid, {status:'new'}); queue.push(pid); } } // remove a pid from the list of items function remove (pid) { if(debug){console.log('called remove', pid);} // check if we already have this item var haveIt = peers.has(pid) if(haveIt) { var peer = peers.get(pid); peer.conn.close(); peer.status = 'closed'; if(debug){console.log('removed', pid);} // remove new peer peers.delete(pid); } } // send offers to connections function connect () { if(debug){console.log('called connect');} // loop over the queue of waiting peers and while (queue.length>0) { // TODO: Check if peer already exists before re-doing // for each peer get pid var pid = queue.shift(); var peer = createRTC(pid); peers.status = 'connecting'; // update status into map peers.set(pid, peer); if(debug){console.log("updated status for pid", pid);} // start connection connectRTC(pid, peer.conn) } } async function connectRTC (pid, pc) { if (debug) console.log('connecting to ', pid); // setup datachannel await setDataChannel(pc, pid) await createOffer(pc, pid) } /* create and set datachannel for this peer connection */ async function setDataChannel (pc, pid) { if(debug){console.log("opening connection to ", pid);} var channel = await pc.createDataChannel('mesh'); channel.binaryType = 'arraybuffer'; channel.onmessage = handleDataChannelMessage; channel.onopen = handleDataChannelOpen; channel.onclose = handleDataChannelClose; if (debug) console.log('created data channel to', pid); // add it to peers info var peer = peers.get(pid); peer.channel = channel; peers.set(pid, peer); } /* Handle Data Channel Events (open, message, close etc) We need them again later */ function handleDataChannelOpen (ev) { try { if(debug){console.log('channel opened', ev);} var data = { type: "handshake", data: peerId }; var msg = JSON.stringify(data); var encMsg = encoder.encode("HSK"+msg); var buffer = encMsg.buffer; channel.send(buffer); } catch (e) { if(debug){console.log(e);} } openListener.forEach((item, i) => { item(ev) }); } function handleDataChannelMessage (msg) { try{ if(debug){console.warn('received MESSAGE=>:', msg);} if(msg.type == "handshake") { var pid = msg.data; var peer = peers.get(pid); peer.channel = channel; peer.status = 'connected'; peers.set(pid, peer); return ; } } catch (e) { if(debug){console.log(e);} } messageListener.forEach((item, i) => { item(msg); }); pipedListener.forEach((item, i) => { item(Buffer.from(msg.data)); }); } function handleDataChannelClose (ev) { if (debug) console.log('channel closed', ev); } //TODO: react to close event and make sure it was wanted /* Create an offer for any peer that arrives after us */ async function createOffer (pc, pid) { var offer = await pc.createOffer(); var plain = JSON.stringify(offer); await pc.setLocalDescription(offer); if(debug){console.log('created Offer for ', pid, pc.signalingState);} var data = { broadcast:true, to: pid, from: peerId, offer: true, data: offer, key: appKey, }; var msg = JSON.stringify(data); ws.send(msg) } /* When we receive an offer, let's handle it */ async function handleOffer (msg) { //do we know the peer yet? if(peers.has(msg.from)) { var peer = peers.get(msg.from); var pc = peer.conn; var pid = msg.from; // the other guy //check if we already have a remote description if(debug){console.warn('received offer', msg, pc.currentRemoteDescription);} if(pc.currentRemoteDescription == null && msg.data != null && typeof msg.data == 'object') { await pc.setRemoteDescription(msg.data); if(debug){console.log('received Offer from other', pc.signalingState);} var answer = await pc.createAnswer(); pc.setLocalDescription(answer); if(debug){console.log('create Answer', pc.signalingState);} var data = { broadcast:true, to: pid, from: peerId, answer: true, data: answer, key: appKey, }; var msg = JSON.stringify(data); ws.send(msg) } } else { if(debug){console.log('create first for ', msg.from, msg);} // if we never met this guy, we need to first create a new connection for him add(msg.from); var peer = createRTC(msg.from); peers.set(msg.from, peer); handleOffer(msg); } } /* When we receive an answer we need to handle it */ async function handleAnswer (msg) { var peer = peers.get(msg.from); var pc = peer.conn; var pid = msg.from; //check if we already have a remote description if(pc.currentRemoteDescription != null || msg.data == null) { return; } await pc.setRemoteDescription(msg.data); if(debug){console.log('set Answer to Remote Desc', pc.signalingState);} } /* When we receive candidates we need to handle them */ async function handleCandidates (msg) { var peer = peers.get(msg.from); var pc = peer.conn; var pid = msg.from; if (msg.data != null && typeof msg.data == 'object'){ await pc.addIceCandidate(msg.data); if(debug){console.log('added candidate ', pc.signalingState);} } } /* Create RTC connection */ function createRTC (pid) { if(debug){console.log('creating rtc for ', pid);} // get status object var peer = peers.get(pid); // create peer connection var pc = new RTCPeerConnection({ sdpSemantics: "unified-plan", iceCandidatePoolSize: 2, iceServers: [{ urls: [ "stun:stun.stunprotocol.org:3478", "stun:stun.l.google.com:19302", "stun:stun1.l.google.com:19302", "stun:stun2.l.google.com:19302", "stun:stun3.l.google.com:19302", "stun:stun4.l.google.com:19302", /*"stun:stun01.sipphone.com", "stun:stun.ekiga.net", "stun:stun.fwdnet.net", "stun:stun.ideasip.com", "stun:stun.iptel.org", "stun:stun.rixtelecom.se", "stun:stun.schlund.de", "stun:stunserver.org", "stun:stun.softjoys.com", "stun:stun.voiparound.com", "stun:stun.voipbuster.com", "stun:stun.voipstunt.com", "stun:stun.voxgratia.org", "stun:stun.xten.com",//*/ ] } ] }); // update status peer.status = 'connecting'; // add peer connection to status object peer.conn = pc; // setup listeners for pc, reuse from above for channels pc.ondatachannel = async function (e) { if (debug) console.log('received a datachannel', e); var channel = e.channel; channel.binaryType = 'arraybuffer'; channel.onmessage = handleDataChannelMessage; channel.onopen = handleDataChannelOpen; channel.onclose = handleDataChannelClose; }; pc.onicecandidate = function (e) { //var can = JSON.stringify(e.candidate); var data = { broadcast: true, candidate: true, data: e.candidate, to: pid, from: peerId, key: appKey, } var msg = JSON.stringify(data); ws.send(msg) }; pc.oniceconnectionstatechange = () => { if(debug){console.log('ice connection state', pc.iceConnectionState);} if (pc.iceConnectionState === "failed") { pc.restartIce(); } }; // update status into map peers.set(pid, peer); if(debug){console.log("updated status for pid", pid);} return peer; } return { /* Interface */ getPeerList: () => { return peers; }, printPeers: () => { console.warn('Printing Peers'); peers.forEach((key, val)=>{console.warn(key, val)}) }, getPeer: (pid) => { return peers.get(pid); }, getPeerId: () => { return peerId; }, // send an object to all connected people sendToAll: (data) => { peers.forEach((item, i) => { if(item.channel.readyState == 'open') { item.channel.send(data); } }); }, onNewPeer: (cb) => { openListener.push(cb) }, leave: () => { var data = { broadcast: true, left: true, from: peerId, msg: "bye from " + peerId, key: appKey, } var msg = JSON.stringify(data); ws.send(msg) }, //TODO: is there a node event that can fire this on CTRL-C exit? pipe: (cb) => { pipedListener.push(cb) }, data: (cb) => { messageListener.push(cb) }, /* Node specific interface */ getStream: () => { return new Duplex({ write(chunk, encoding, callback) { peers.forEach((item, i) => { try { if(item.channel && item.channel.readyState == 'open') { item.channel.send(chunk); } } catch (e) { if(debug){ console.log('stream', e)} } }); callback(); }, read() { }, }) } } }