Skip to content

Instantly share code, notes, and snippets.

@James-E-A
Last active August 7, 2024 20:10
Show Gist options
  • Save James-E-A/e3aac419379aca3ff97d30de992574a2 to your computer and use it in GitHub Desktop.
Save James-E-A/e3aac419379aca3ff97d30de992574a2 to your computer and use it in GitHub Desktop.
WebRTC simple perfect negotiation wrapper
// 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