trystero
Version:
Serverless WebRTC matchmaking for painless P2P
200 lines (168 loc) • 5.27 kB
JavaScript
import {alloc} from './utils.js'
const iceTimeout = 5000
const iceStateEvent = 'icegatheringstatechange'
const filterTrickle = sdp => sdp.replace(/a=ice-options:trickle\s\n/g, '')
export default (initiator, {rtcConfig, rtcPolyfill, turnConfig}) => {
const pc = new (rtcPolyfill || RTCPeerConnection)({
iceServers: defaultIceServers.concat(turnConfig || []),
...rtcConfig
})
const handlers = {}
const setupDataChannel = channel => {
channel.binaryType = 'arraybuffer'
channel.bufferedAmountLowThreshold = 0xffff
channel.onmessage = e => handlers.data?.(e.data)
channel.onopen = () => handlers.connect?.()
channel.onclose = () => handlers.close?.()
channel.onerror = err => handlers.error?.(err)
}
const waitForIceGathering = async pc => {
if (!pc.localDescription) {
throw new Error('No local description available')
}
await Promise.race([
new Promise(resolve => {
const checkState = () => {
if (pc.iceGatheringState === 'complete') {
pc.removeEventListener(iceStateEvent, checkState)
resolve()
}
}
pc.addEventListener(iceStateEvent, checkState)
checkState()
}),
new Promise(resolve => setTimeout(resolve, iceTimeout))
])
return {
type: pc.localDescription.type,
sdp: filterTrickle(pc.localDescription.sdp)
}
}
let makingOffer = false
let dataChannel = null
let ignoreOffer = false
if (initiator) {
dataChannel = pc.createDataChannel('data')
setupDataChannel(dataChannel)
} else {
pc.ondatachannel = ({channel}) => {
dataChannel = channel
setupDataChannel(channel)
}
}
pc.onnegotiationneeded = async () => {
try {
makingOffer = true
await pc.setLocalDescription()
const offer = await waitForIceGathering(pc)
handlers.signal?.({type: offer.type, sdp: filterTrickle(offer.sdp)})
} catch (err) {
handlers.error?.(err)
} finally {
makingOffer = false
}
}
pc.onconnectionstatechange = () => {
if (['disconnected', 'failed', 'closed'].includes(pc.connectionState)) {
handlers.close?.()
}
}
pc.ontrack = e => {
handlers.track?.(e.track, e.streams[0])
handlers.stream?.(e.streams[0])
}
pc.onremovestream = event => {
handlers.stream?.(event.stream, {removed: true})
}
return {
created: Date.now(),
connection: pc,
get channel() {
return dataChannel
},
get isDead() {
return pc.connectionState === 'closed'
},
async signal(sdp) {
if (dataChannel?.readyState === 'open') {
if (sdp.type === 'offer' || pc.signalingState !== 'stable') {
await pc.setRemoteDescription(sdp)
if (sdp.type === 'offer') {
await pc.setLocalDescription()
const answer = await waitForIceGathering(pc)
handlers.signal?.({type: answer.type, sdp: answer.sdp})
return {type: answer.type, sdp: answer.sdp}
}
}
return
}
try {
if (sdp.type === 'offer') {
if (makingOffer || pc.signalingState !== 'stable') {
ignoreOffer = !initiator
if (ignoreOffer) {
return
}
}
await pc.setRemoteDescription(sdp)
await pc.setLocalDescription()
const answer = await waitForIceGathering(pc)
const answerSdp = filterTrickle(answer.sdp)
handlers.signal?.({type: answer.type, sdp: answerSdp})
return {type: answer.type, sdp: answerSdp}
} else if (
sdp.type === 'answer' &&
(pc.signalingState === 'have-local-offer' ||
pc.signalingState === 'have-remote-offer')
) {
await pc.setRemoteDescription(sdp)
}
} catch (err) {
handlers.error?.(err)
}
},
sendData: data => dataChannel.send(data),
destroy: () => {
if (dataChannel) {
dataChannel.close()
}
pc.close()
},
setHandlers: newHandlers => Object.assign(handlers, newHandlers),
offerPromise: initiator
? new Promise(res => {
const handler = sdp => {
if (sdp.type === 'offer') {
res(sdp)
}
}
handlers.signal = handler
})
: Promise.resolve(),
addStream: stream => {
stream.getTracks().forEach(track => pc.addTrack(track, stream))
},
removeStream: stream => {
pc.getSenders()
.filter(sender => stream.getTracks().includes(sender.track))
.forEach(sender => pc.removeTrack(sender))
},
addTrack: (track, stream) => pc.addTrack(track, stream),
removeTrack: track => {
const sender = pc.getSenders().find(s => s.track === track)
if (sender) {
pc.removeTrack(sender)
}
},
replaceTrack: async (oldTrack, newTrack) => {
const sender = pc.getSenders().find(s => s.track === oldTrack)
if (sender) {
await sender.replaceTrack(newTrack)
}
}
}
}
export const defaultIceServers = [
...alloc(3, (_, i) => `stun:stun${i || ''}.l.google.com:19302`),
'stun:global.stun.twilio.com:3478'
].map(url => ({urls: url}))