trystero
Version:
Serverless WebRTC matchmaking for painless P2P
291 lines (235 loc) • 7.07 kB
JavaScript
import {decrypt, encrypt, genKey, sha1} from './crypto.js'
import initPeer from './peer.js'
import room from './room.js'
import {
all,
alloc,
fromJson,
libName,
mkErr,
noOp,
selfId,
toJson,
topicPath
} from './utils.js'
const poolSize = 20
const announceIntervalMs = 5_333
const offerTtl = 57_333
export default ({init, subscribe, announce}) => {
const occupiedRooms = {}
let didInit = false
let initPromises
let offerPool
let offerCleanupTimer
return (config, roomId, onJoinError) => {
const {appId} = config
if (occupiedRooms[appId]?.[roomId]) {
return occupiedRooms[appId][roomId]
}
const pendingOffers = {}
const connectedPeers = {}
const rootTopicPlaintext = topicPath(libName, appId, roomId)
const rootTopicP = sha1(rootTopicPlaintext)
const selfTopicP = sha1(topicPath(rootTopicPlaintext, selfId))
const key = genKey(config.password || '', appId, roomId)
const withKey = f => async signal => ({
type: signal.type,
sdp: await f(key, signal.sdp)
})
const toPlain = withKey(decrypt)
const toCipher = withKey(encrypt)
const makeOffer = () => initPeer(true, config)
const connectPeer = (peer, peerId, relayId) => {
if (connectedPeers[peerId]) {
if (connectedPeers[peerId] !== peer) {
peer.destroy()
}
return
}
connectedPeers[peerId] = peer
onPeerConnect(peer, peerId)
pendingOffers[peerId]?.forEach((peer, i) => {
if (i !== relayId) {
peer.destroy()
}
})
delete pendingOffers[peerId]
}
const disconnectPeer = (peer, peerId) => {
if (connectedPeers[peerId] === peer) {
delete connectedPeers[peerId]
}
}
const prunePendingOffer = (peerId, relayId) => {
if (connectedPeers[peerId]) {
return
}
const offer = pendingOffers[peerId]?.[relayId]
if (offer) {
delete pendingOffers[peerId][relayId]
offer.destroy()
}
}
const getOffers = n => {
offerPool.push(...alloc(n, makeOffer))
return all(
offerPool
.splice(0, n)
.map(peer =>
peer.offerPromise.then(toCipher).then(offer => ({peer, offer}))
)
)
}
const handleJoinError = (peerId, sdpType) =>
onJoinError?.({
error: `incorrect password (${config.password}) when decrypting ${sdpType}`,
appId,
peerId,
roomId
})
const handleMessage = relayId => async (topic, msg, signalPeer) => {
const [rootTopic, selfTopic] = await all([rootTopicP, selfTopicP])
if (topic !== rootTopic && topic !== selfTopic) {
return
}
const {peerId, offer, answer, peer} =
typeof msg === 'string' ? fromJson(msg) : msg
if (peerId === selfId || connectedPeers[peerId]) {
return
}
if (peerId && !offer && !answer) {
if (pendingOffers[peerId]?.[relayId]) {
return
}
const [[{peer, offer}], topic] = await all([
getOffers(1),
sha1(topicPath(rootTopicPlaintext, peerId))
])
pendingOffers[peerId] ||= []
pendingOffers[peerId][relayId] = peer
setTimeout(
() => prunePendingOffer(peerId, relayId),
announceIntervals[relayId] * 0.9
)
peer.setHandlers({
connect: () => connectPeer(peer, peerId, relayId),
close: () => disconnectPeer(peer, peerId)
})
signalPeer(topic, toJson({peerId: selfId, offer}))
} else if (offer) {
const myOffer = pendingOffers[peerId]?.[relayId]
if (myOffer && selfId > peerId) {
return
}
const peer = initPeer(false, config)
peer.setHandlers({
connect: () => connectPeer(peer, peerId, relayId),
close: () => disconnectPeer(peer, peerId)
})
let plainOffer
try {
plainOffer = await toPlain(offer)
} catch {
handleJoinError(peerId, 'offer')
return
}
if (peer.isDead) {
return
}
const [topic, answer] = await all([
sha1(topicPath(rootTopicPlaintext, peerId)),
peer.signal(plainOffer)
])
signalPeer(
topic,
toJson({peerId: selfId, answer: await toCipher(answer)})
)
} else if (answer) {
let plainAnswer
try {
plainAnswer = await toPlain(answer)
} catch (e) {
handleJoinError(peerId, 'answer')
return
}
if (peer) {
peer.setHandlers({
connect: () => connectPeer(peer, peerId, relayId),
close: () => disconnectPeer(peer, peerId)
})
peer.signal(plainAnswer)
} else {
const peer = pendingOffers[peerId]?.[relayId]
if (peer && !peer.isDead) {
peer.signal(plainAnswer)
}
}
}
}
if (!config) {
throw mkErr('requires a config map as the first argument')
}
if (!appId && !config.firebaseApp) {
throw mkErr('config map is missing appId field')
}
if (!roomId) {
throw mkErr('roomId argument required')
}
if (!didInit) {
const initRes = init(config)
offerPool = alloc(poolSize, makeOffer)
initPromises = Array.isArray(initRes) ? initRes : [initRes]
didInit = true
offerCleanupTimer = setInterval(
() =>
(offerPool = offerPool.filter(peer => {
const shouldLive = Date.now() - peer.created < offerTtl
if (!shouldLive) {
peer.destroy()
}
return shouldLive
})),
offerTtl * 1.03
)
}
const announceIntervals = initPromises.map(() => announceIntervalMs)
const announceTimeouts = []
const unsubFns = initPromises.map(async (relayP, i) =>
subscribe(
await relayP,
await rootTopicP,
await selfTopicP,
handleMessage(i),
getOffers
)
)
all([rootTopicP, selfTopicP]).then(([rootTopic, selfTopic]) => {
const queueAnnounce = async (relay, i) => {
const ms = await announce(relay, rootTopic, selfTopic)
if (typeof ms === 'number') {
announceIntervals[i] = ms
}
announceTimeouts[i] = setTimeout(
() => queueAnnounce(relay, i),
announceIntervals[i]
)
}
unsubFns.forEach(async (didSub, i) => {
await didSub
queueAnnounce(await initPromises[i], i)
})
})
let onPeerConnect = noOp
occupiedRooms[appId] ||= {}
return (occupiedRooms[appId][roomId] = room(
f => (onPeerConnect = f),
id => delete connectedPeers[id],
() => {
delete occupiedRooms[appId][roomId]
announceTimeouts.forEach(clearTimeout)
unsubFns.forEach(async f => (await f)())
clearInterval(offerCleanupTimer)
}
))
}
}