Last active
August 7, 2024 20:10
-
-
Save James-E-A/e3aac419379aca3ff97d30de992574a2 to your computer and use it in GitHub Desktop.
WebRTC simple perfect negotiation wrapper
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// To use the functions exported by this module, | |
// you must already have an existing_channel | |
// connecting the two endpoints -- a so-called | |
// "signaling server" -- before you can establish | |
// a WebRTC connection. | |
/* | |
const config = { iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] }; | |
const _signaling_server = new MessageChannel(); | |
_signaling_server.port1.start(); | |
_signaling_server.port2.start(); | |
// On client 1: | |
establishRtcDataChannel("test123", true, false, config, _signaling_server.port1) | |
.then((chan_a) => { | |
chan_a.onmessage = (event) => alert(`Bob says: ${event.data}`); | |
console.debug(chan_a); | |
chan_a.send("Hello over WebRTC!"); | |
}); | |
// On client 2: | |
establishRtcDataChannel("test123", false, true, config, _signaling_server.port2) | |
.then((chan_b) => { | |
chan_b.onmessage = (event) => alert(`Alice says: ${event.data}`); | |
console.debug(chan_b); | |
}); | |
*/ | |
/* | |
AS AN ALTERNATIVE TO USING THIS MODULE: | |
You can use "sneakernet" to bootstrap your connection manually. | |
To do so: | |
- Create a datachannel on client 1, | |
- then create an offer, | |
- then set the offer as the local description, | |
- then WAIT until you get an icecandidate event.candidate === null, | |
- then event.target.localDescription will be a sneakernet-friendly offer. | |
- Transmit the sneakernet-friendly offer to client 1. | |
- On client 2, set the sneakernet-friendly offer as the remote description, | |
- then create an answer, | |
- then set the answer as the local description, | |
- then WAIT until you get an icecandidate event.candidate === null, | |
- then event.target.localDescription will be a sneakernet-friendly answer. | |
- Transmit the sneakernet-friendly offer to client 1, | |
- and start listening for the datachannel event on client 2. | |
- On client 1, set the sneakernet-friendly answer as the remote description. | |
*/ | |
export async function establishRtcDataChannel(label, active, polite, config, existing_channel) { | |
const con = (existing_channel instanceof RTCPeerConnection) ? existing_channel : createRtcPeerConnection(config, existing_channel, polite); | |
const chan = active ? con.createDataChannel(label) : await new Promise((resolve) => { | |
const settled = new AbortController(); | |
con.addEventListener('datachannel', (event) => { | |
if ( label !== undefined && event.channel.label !== label ) | |
return; | |
resolve(event.channel); | |
settled.abort(null); | |
}, { signal: settled.signal }); | |
con.addEventListener('connectionstatechange', (event) => { | |
if ( event.target.connectionstate === 'closed' ) { | |
reject(new DOMException("RTCPeerConnection closed", 'NetworkError')); | |
settled.abort(); | |
} | |
}, { signal: settled.signal }); | |
}); | |
await new Promise((resolve) => { | |
const settled = new AbortController(); | |
chan.addEventListener('open', () => { | |
resolve(); | |
settled.abort(null) | |
}, { signal: settled.signal }); | |
chan.addEventListener('error', (event) => { | |
reject(event.error); | |
settled.abort() | |
}, { signal: settled.signal }); | |
}); | |
return chan; | |
} | |
export function createRtcPeerConnection(config, existing_channel, polite) { | |
// Heavily based on https://blog.mozilla.org/webrtc/perfect-negotiation-in-webrtc/ | |
const con = new RTCPeerConnection(config); | |
const _closed = new AbortController(); | |
con.addEventListener('signalingstatechange', (event) => { | |
if ( con.signalingState === 'closed' ) | |
_closed.abort(); | |
}, { signal: _closed.signal }); | |
let makingOffer = false; | |
con.addEventListener('negotiationneeded', (event) => { | |
makingOffer = true; | |
con.createOffer() | |
.then(async (offer) => { | |
if ( con.signalingState !== 'stable' ) | |
return; | |
await con.setLocalDescription(offer); | |
existing_channel.postMessage({ | |
type: 'sdp', | |
value: _plainObjectify(con.localDescription) | |
}); | |
}) | |
.finally(() => { | |
makingOffer = false; | |
}); | |
}, { signal: _closed.signal }); | |
con.addEventListener('icecandidate', (event) => { | |
if ( event.candidate ) { | |
existing_channel.postMessage({ | |
type: 'icecandidate', | |
value: _plainObjectify(event.candidate) | |
}); | |
} | |
}, { signal: _closed.signal }); | |
let ignoringOffers = false; | |
existing_channel.addEventListener('message', async (event) => { | |
const message = event.data; | |
switch ( message.type ) { | |
case 'sdp': | |
const sdp = message.value; | |
const glare = sdp.type === 'offer' && ( makingOffer || con.signalingState !== 'stable' ); | |
ignoringOffers = !polite && glare; | |
if ( ignoringOffers ) | |
return; | |
await Promise.all([ | |
glare ? con.setLocalDescription({ type: 'rollback' }) : undefined, | |
con.setRemoteDescription(sdp) | |
]); | |
if ( sdp.type === 'offer' ) { | |
await con.setLocalDescription(await con.createAnswer()); | |
existing_channel.postMessage({ | |
type: 'sdp', | |
value: _plainObjectify(con.localDescription) | |
}); | |
} | |
break; | |
case 'icecandidate': | |
const candidate = message.value; | |
try { | |
con.addIceCandidate(candidate); | |
} catch (error) { | |
// during perfect negotiation blackouts, we still want to try to trickle ICE candidates (right?) | |
// but, if they can't be added, that's 100% expected and OK behavior. | |
if (ignoringOffers) | |
console.debug(error); | |
else | |
throw error; | |
} | |
break; | |
} | |
}, { signal: _closed.signal }); | |
return con; | |
} | |
function _plainObjectify(o) { | |
return JSON.parse(JSON.stringify(o)); | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment