export {}
declare var hdemo: HTMLElement
declare var herror: HTMLElement, husers: HTMLElement
declare var hloginname: HTMLInputElement, hloginroom: HTMLInputElement, hloginjoin: HTMLInputElement
declare var hsend: HTMLInputElement, hmessage: HTMLInputElement, hmessages: HTMLElement
let chatui = `
your name: ,
room:
`
const signalingServer = 'https://iio.ie/sig'
const colors = [
'black',
'blue',
'brown',
'cyan',
'gold',
'gray',
'green',
'magenta',
'orange',
'pink',
'purple',
'red',
'silver',
'white',
'yellow',
]
const animals = [
'bear',
'cat',
'chicken',
'cow',
'deer',
'dog',
'fox',
'hamster',
'mouse',
'panda',
'pig',
'rabbit',
'rat',
'tiger',
]
const rtcConfig = {
iceServers: [{
urls: 'stun:stun.l.google.com:19302',
}]
}
class client {
username: string
conn: RTCPeerConnection
channel: RTCDataChannel
constructor(u: string, conn: RTCPeerConnection, ch: RTCDataChannel) {
this.username = u
this.conn = conn
this.channel = ch
}
}
let clients: client[] = []
function reportError(msg: string) {
if (msg == '') {
herror.hidden = true
} else {
herror.innerText = msg
herror.hidden = false
}
}
// convert continuation passing style into direct style:
// await eventPromise(obj, 'click') will wait for a single click on obj.
function eventPromise(obj: EventTarget, eventName: string) {
return new Promise(resolve => {
let handler = (event: Event) => {
obj.removeEventListener(eventName, handler)
resolve(event)
}
obj.addEventListener(eventName, handler);
})
}
function pruneDisconnected() {
for (let i = 0; i < clients.length; i++) {
if (['disconnected', 'closed'].includes(clients[i].conn.iceConnectionState) == false) continue
let user = clients[i].username
clients[i] = clients[clients.length - 1]
clients.pop()
i--
distributeMessage(`${user} disconnected.`)
}
}
function onmsgKeyup(ev: KeyboardEvent) {
if (ev.key == 'Enter') sendmessage()
}
function sendmessage() {
let msg = hmessage.value
if (msg.length == 0) return
if (serverChannel != null) {
serverChannel.send(msg)
} else {
distributeMessage(`${hloginname.value}: ${msg}`)
}
hmessage.value = ''
}
let active = new Set()
function addmessage(msg: string) {
let atbottom = Math.abs(hmessages.scrollTop - (hmessages.scrollHeight - hmessages.offsetHeight)) < 1
hmessages.innerText += msg + '\n'
if (atbottom) hmessages.scrollTo(0, hmessages.scrollHeight)
let [user, op, rest] = msg.split(' ')
if (user == '--') return;
if (user.endsWith(':')) return;
if (op == 'disconnected.') {
active.delete(user)
} else {
active.add(user)
}
husers.innerText = Array.from(active.keys()).sort().join(', ')
}
function randval(a: string[]) {
return a[Math.floor(Math.random() * a.length)]
}
let msgHistory: string[]
function distributeMessage(msg: string) {
msgHistory.push(msg)
addmessage(msg)
for (let c of clients) c.channel.send(msg)
}
let aborter: AbortController | null
let usernameRE = /^[a-z0-9_.-]{1,32}$/
async function server() {
msgHistory = []
let room = hloginroom.value
distributeMessage(`${hloginname.value} created the room ${room}.`)
aborter = new AbortController()
let failedAttempts = 0
while (true) {
// create a description offer and upload it to the signaling service.
let conn = new RTCPeerConnection(rtcConfig)
let channel = conn.createDataChannel('datachannel')
conn.setLocalDescription(await conn.createOffer())
do {
await eventPromise(conn, 'icegatheringstatechange')
} while (conn.iceGatheringState != 'complete');
let response
try {
response = await fetch(`${signalingServer}?set=chatoffer_${room}`, {
method: 'POST',
body: conn?.localDescription?.sdp,
signal: aborter.signal,
})
} catch (e) {
if (aborter.signal.aborted) {
conn.close()
break
}
if (failedAttempts == 6) throw e
reportError(`temporarily unavailable (attempt ${failedAttempts}): ` + e)
await new Promise(f => setTimeout(f, failedAttempts++ * 1000));
reportError('')
continue
}
failedAttempts = 0
if (response.status == 204) {
conn.close()
continue
}
// read the description answer from the next client.
response = await fetch(`${signalingServer}?get=chatanswer_${room}&timeoutms=500`, {
method: 'POST',
})
if (response.status == 204) {
conn.close()
continue
}
let sdp = await response.text()
conn.setRemoteDescription({
type: 'answer',
sdp: sdp,
})
await eventPromise(channel, 'open')
// read the username, setup event handlers, notify other clients.
let username = (await eventPromise(channel, 'message') as MessageEvent).data
if (!username.match(usernameRE)) {
channel.send('-- username invalid, connect rejected. --')
conn.close()
continue
}
if (active.has(username)) {
channel.send('-- username taken, connect rejected. --')
conn.close()
continue
}
conn.oniceconnectionstatechange = pruneDisconnected
channel.onmessage = (ev) => {
if (ev.data == '/leave') {
conn.close()
pruneDisconnected()
} else {
distributeMessage(`${username}: ${ev.data}`)
}
}
for (let m of msgHistory) channel.send(m)
clients.push(new client(username, conn, channel))
distributeMessage(`${username} joined.`)
}
}
let serverConn: RTCPeerConnection | null
let serverChannel: RTCDataChannel | null
async function join() {
if (!hloginname.value.match(usernameRE)) {
hmessages.innerText = 'invalid username, pick a short alphanumeric identifier.'
return
}
if (!hloginroom.value.match(/^[a-z0-9_-]{1,16}$/)) {
hmessages.innerText = 'invalid room name, pick a short alphanumeric identifier.'
return
}
// update the ui.
hloginname.disabled = true
hloginroom.disabled = true
hloginjoin.disabled = true
hloginjoin.innerText = 'joining...'
hsend.disabled = false
hmessages.innerText = ''
hmessage.disabled = false
hmessage.focus()
let room = hloginroom.value
let response = await fetch(`${signalingServer}?get=chatoffer_${room}&timeoutms=900`, {
method: 'POST',
})
if (response.status == 204) {
// timed out means there is no running server, become a server then.
hloginjoin.disabled = false
hloginjoin.innerText = 'close room'
hloginjoin.onclick = closeRoom
return server()
}
if (response.status != 200) {
reportError(`unexpected response: ${response.status}`)
return
}
// assuming client mode.
// send description answer to the server.
let offer = await response.text()
serverConn = new RTCPeerConnection(rtcConfig)
let channelPromise = eventPromise(serverConn, 'datachannel')
await serverConn.setRemoteDescription({
type: 'offer',
sdp: offer,
})
serverConn.setLocalDescription(await serverConn.createAnswer())
do {
await eventPromise(serverConn, 'icegatheringstatechange')
} while (serverConn.iceGatheringState != 'complete');
response = await fetch(`${signalingServer}?set=chatanswer_${room}`, {
method: 'POST',
body: serverConn?.localDescription?.sdp,
})
serverChannel = (await channelPromise as RTCDataChannelEvent).channel
// set up event handlers for the connection.
serverConn.oniceconnectionstatechange = (ev) => {
if (serverConn?.iceConnectionState != 'disconnected') return
addmessage('-- server disconnected --')
leave()
}
serverChannel.onmessage = ev => {
let msg = (ev as MessageEvent).data
addmessage(msg)
if (msg == '-- chatroom closed --') leave()
if (msg == '-- username taken, connect rejected. --') leave()
if (msg == '-- username invalid, connect rejected. --') leave()
}
// send over the username as the first message.
serverChannel.send(hloginname.value)
hloginjoin.disabled = false
hloginjoin.innerText = 'leave'
hloginjoin.onclick = leave
}
function leave() {
if (serverConn != null) {
serverChannel?.send('/leave')
serverConn.close()
serverConn = null
addmessage(`-- left the room ${hloginroom.value} --`)
}
hloginname.disabled = false
hloginroom.disabled = false
husers.innerText = ''
active = new Set()
hloginjoin.innerText = 'join'
hloginjoin.onclick = join
hsend.disabled = true
hmessage.disabled = true
}
function closeRoom() {
if (aborter != null) aborter.abort()
let msg = '-- chatroom closed --'
addmessage(msg)
for (let c of clients) {
c.channel.send(msg)
c.conn.close()
}
clients = []
leave()
}
function closeall() {
if (hloginjoin.innerText == 'close room') closeRoom()
if (hloginjoin.innerText == 'leave') leave()
}
function main() {
try {
let c = new RTCPeerConnection()
c.close()
} catch (e) {
hdemo.innerHTML = ''
reportError('no support for webrtc in your browser: ' + e)
return
}
window.onbeforeunload = closeall
window.onerror = (msg, src, line) => reportError(`${src}:${line} ${msg}`)
window.onunhandledrejection = e => reportError(e.reason)
hdemo.innerHTML = chatui
hloginname.value = `${randval(colors)}-${randval(animals)}`
hloginroom.value = 'default'
hloginjoin.onclick = join
hmessage.onkeyup = onmsgKeyup
hsend.onclick = sendmessage
}
main()