In its current state the implementation should allow a user to resume their session if the websocket connection is reset, for whatever reason. This could be expanded to allow session sharing (multiple agents logging in to the same client), or manual session resume via some sort of password (encode uid & key to some pass-phrase kinda thing, or QR code).
270 lines
6.5 KiB
JavaScript
270 lines
6.5 KiB
JavaScript
"use strict"
|
|
|
|
/* global document, window */
|
|
const crypto = window.crypto
|
|
const location = document.location
|
|
const performance = window.performance
|
|
const storage = window.sessionStorage
|
|
|
|
// TODOs
|
|
// - measure/report latency
|
|
// - use server reported time to find winner
|
|
|
|
import config from "./config.js"
|
|
|
|
const buzzer_key = {
|
|
code: 0x20,
|
|
name: "space bar",
|
|
}
|
|
|
|
const q = (selector, root) => (root || document).querySelector(selector)
|
|
const on = (event, cb) => document.addEventListener(event, cb)
|
|
function node(type, { appendTo, cls, text, data, style, ...attrs } = {}) {
|
|
let elem = typeof type === "string" ? document.createElement(type) : type
|
|
if (cls) {
|
|
elem.className = cls
|
|
}
|
|
if (text) {
|
|
elem.textContent = text
|
|
}
|
|
Object.assign(elem.dataset, data ?? {})
|
|
Object.assign(elem.style, style ?? {})
|
|
for (const name in attrs) {
|
|
elem.setAttribute(name, attrs[name])
|
|
}
|
|
if (appendTo) {
|
|
elem = appendTo.appendChild(elem)
|
|
}
|
|
return elem
|
|
}
|
|
|
|
/**
|
|
* Some duration conversion constants.
|
|
*/
|
|
const ms_ns = 1_000_000 // nanoseconds in a millisecond
|
|
const s_ms = 1_000 // milliseconds in a second
|
|
const s_ns = 1_000_000_000 // nanoseconds in a second
|
|
|
|
let socket,
|
|
servertime,
|
|
toffset_ms,
|
|
clients = [],
|
|
me,
|
|
session_key
|
|
|
|
function hide(e) {
|
|
e = typeof e === "string" ? q(`#${e}`) : e
|
|
e.style.display = "none"
|
|
}
|
|
|
|
function show(e) {
|
|
e = typeof e === "string" ? q(`#${e}`) : e
|
|
e.style.display = "block"
|
|
}
|
|
|
|
function session_id() {
|
|
const match = /^#?(.+)/.exec(document.location.hash)
|
|
return match ? match[1] : null
|
|
}
|
|
|
|
function new_session_id() {
|
|
if (!crypto) {
|
|
return Math.random().toString(36).substr(2)
|
|
}
|
|
const data = new Uint8Array(10)
|
|
crypto.getRandomValues(data)
|
|
return Array.from(data, (v) => v.toString(36)).join("")
|
|
}
|
|
|
|
function setup_url() {
|
|
const sid = session_id() || new_session_id()
|
|
document.location.hash = sid
|
|
}
|
|
|
|
function send(type, value) {
|
|
// console.debug('sending', value)
|
|
socket.send(JSON.stringify({ type, value }))
|
|
}
|
|
|
|
function clear(container) {
|
|
while (container.children.length > 0) {
|
|
const child = container.children[0]
|
|
child.remove()
|
|
}
|
|
}
|
|
|
|
let ul
|
|
function redraw_clients(me, clients) {
|
|
if (!me) {
|
|
return
|
|
}
|
|
clear(ul)
|
|
const player_tpl = q("template#player").content.firstElementChild
|
|
for (const c of clients) {
|
|
node(player_tpl.cloneNode(), {
|
|
text: c.name,
|
|
data: { cid: c.id },
|
|
appendTo: ul,
|
|
cls: c.id === me.id ? "me" : "",
|
|
})
|
|
}
|
|
}
|
|
|
|
const highlights = {}
|
|
function highlight(client_id, until_ns) {
|
|
if (highlights[client_id]) {
|
|
return
|
|
}
|
|
const timeout_ms = (until_ns - servertime_now_ns()) / ms_ns
|
|
if (timeout_ms <= 10) {
|
|
console.warn("That highlight timeout was ridiculously low:", client_id, timeout_ms)
|
|
return
|
|
}
|
|
for (const li of ul.children) {
|
|
if (li.dataset.cid === client_id) {
|
|
li.classList.add("buzzing")
|
|
if (Object.keys(highlights).length) {
|
|
li.classList.add("too-late")
|
|
} else {
|
|
li.classList.add("first")
|
|
}
|
|
highlights[client_id] = setTimeout(() => {
|
|
delete highlights[client_id]
|
|
li.classList.remove("buzzing")
|
|
li.classList.remove("too-late")
|
|
li.classList.remove("first")
|
|
}, timeout_ms)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Guess the exact current server time.
|
|
*/
|
|
function servertime_now_ns() {
|
|
const now_ms = performance.now()
|
|
const delta_ns = ms_ns * (now_ms - toffset_ms)
|
|
return servertime + delta_ns
|
|
}
|
|
|
|
function setup_ui() {
|
|
on("focus", (event) => {
|
|
hide("active")
|
|
hide("inactive")
|
|
show("ready")
|
|
})
|
|
on("blur", (event) => {
|
|
hide("active")
|
|
show("inactive")
|
|
hide("ready")
|
|
})
|
|
if (document.hasFocus()) {
|
|
hide("inactive")
|
|
} else {
|
|
hide("ready")
|
|
}
|
|
|
|
q("#bkey").textContent = buzzer_key.name
|
|
let buzzing = false
|
|
on("keydown", (event) => {
|
|
if (!buzzing && event.keyCode === buzzer_key.code) {
|
|
buzzing = true
|
|
send("buzz", servertime_now_ns())
|
|
show("active")
|
|
hide("ready")
|
|
}
|
|
})
|
|
on("keyup", (event) => {
|
|
if (event.keyCode === buzzer_key.code) {
|
|
buzzing = false
|
|
hide("active")
|
|
show("ready")
|
|
}
|
|
})
|
|
|
|
const username_el = q("#username")
|
|
if (storage["my_name"]) {
|
|
username_el.value = storage["my_name"]
|
|
}
|
|
username_el.addEventListener("change", (event) => {
|
|
set_name(event.target.value)
|
|
})
|
|
|
|
ul = q("#info ul")
|
|
}
|
|
|
|
function set_name(name) {
|
|
storage["my_name"] = name
|
|
send("name", name)
|
|
}
|
|
|
|
function session_url(sid) {
|
|
return `${config.wsurl}/${sid}`
|
|
}
|
|
|
|
function session_id_from_url(url) {
|
|
const wsurl = new URL(config.wsurl)
|
|
const match = RegExp(`${wsurl.pathname}/([^/]+)$`).exec(url)
|
|
return !match ? null : match[1]
|
|
}
|
|
|
|
function setup_ws() {
|
|
const sid = session_id()
|
|
const credentials = { id: storage["my_uid"], key: storage["my_key"] }
|
|
socket = new WebSocket(`${session_url(sid)}`)
|
|
socket.addEventListener("open", function (event) {
|
|
if (sid === storage["my_sid"]) {
|
|
send("login", credentials)
|
|
}
|
|
set_name(q("#username").value)
|
|
})
|
|
socket.addEventListener("message", function (event) {
|
|
const msg = JSON.parse(event.data)
|
|
if (msg.type === "time") {
|
|
servertime = msg.value
|
|
toffset_ms = performance.now()
|
|
} else if (msg.type === "id") {
|
|
me = { id: msg.id, key: msg.key, path: msg.path }
|
|
storage["my_uid"] = me.id
|
|
storage["my_key"] = me.key
|
|
storage["my_sid"] = session_id_from_url(me.path)
|
|
redraw_clients(me, clients)
|
|
} else if (msg.type === "session_key") {
|
|
session_key = { path: msg.path, key: msg.key }
|
|
storage["session_path"] = session_key.path
|
|
storage["session_key"] = session_key.key
|
|
} else if (msg.type === "buzz") {
|
|
const buzztime_ns = msg.time
|
|
const client_id = msg.client
|
|
const duration_ns = 3 * s_ns
|
|
const until_ns = buzztime_ns + duration_ns
|
|
highlight(client_id, until_ns)
|
|
} else if (msg.type === "clients") {
|
|
clients = msg.value
|
|
redraw_clients(me, clients)
|
|
} else if (msg.type === "client") {
|
|
const client = { name: msg.name, id: msg.id, active: msg.active }
|
|
for (const c of clients) {
|
|
if (c.id === client.id) {
|
|
c.name = client.name
|
|
redraw_clients(me, clients)
|
|
return
|
|
}
|
|
}
|
|
clients.push(client)
|
|
redraw_clients(me, clients)
|
|
} else if (msg.type === "error") {
|
|
console.error(`Error: ${msg.reason}`)
|
|
const errorbox = q("#error")
|
|
q("code", errorbox).textContent = JSON.stringify(msg, null, 2)
|
|
show(errorbox)
|
|
} else {
|
|
console.error(`Unknown message: ${event.data}`)
|
|
}
|
|
})
|
|
}
|
|
|
|
setup_url()
|
|
setup_ui()
|
|
setup_ws()
|