Files
xTeVe/ts/network_ts.ts
T
nathan 3a3ba861dc
continuous-integration/drone/push Build encountered an error
Phase 5a: persistent websocket with request ids and reconnect
Client (ts/network_ts.ts): one WebSocket per page. Commands queue and go
out one at a time with a client-chosen id; the response is matched on the
echoed id (or to the in-flight request for older servers). 12 s timeout
per request, exponential-backoff reconnect (0.5 s to 10 s), the in-flight
request is retried after a reconnect, and log polls are de-duplicated so
they cannot pile up behind a stalled connection. The old global flag that
silently dropped any request made while another was in flight is gone.

Server (src/webserver.go): the /data/ handler now serves any number of
commands on one connection (it used to break out of its loop after the
first reply without closing the socket, leaving it open and deaf; the old
client papered over that by opening a new socket per request). Connection
closed on exit, request id echoed in the response.

Test: TestWSServesMultipleCommandsPerConnection.
2026-09-26 13:19:38 +10:00

306 lines
7.6 KiB
TypeScript

// Websocket transport between the web UI and xTeVe.
//
// One socket stays open for the life of the page. Requests are queued and
// sent one at a time; each carries an id that the server echoes back, and
// the response is matched on it (or, for older servers that close after a
// single reply, to the oldest in-flight request). Connection loss triggers
// a reconnect with exponential backoff, and queued requests survive it.
interface PendingRequest {
id: string
payload: Object
resolve: (response: any) => void
reject: (err: Error) => void
timer: number
sent: boolean
}
class WSClient {
private socket: WebSocket = null
private queue: PendingRequest[] = []
private inFlight: PendingRequest = null
private nextID: number = 1
private reconnectDelay: number = 500
private reconnectTimer: number = 0
private closedByUs: boolean = false
readonly requestTimeoutMs: number = 12000
private url(): string {
var protocol = window.location.protocol == "https:" ? "wss://" : "ws://"
var host = window.location.host
if (host == undefined || host.length < 1) {
host = window.location.hostname
}
return protocol + host + "/data/"
}
// send queues a command and resolves with the parsed response.
send(payload: Object): Promise<any> {
var client = this
return new Promise(function(resolve, reject) {
var req: PendingRequest = {
id: String(client.nextID++),
payload: payload,
resolve: resolve,
reject: reject,
timer: 0,
sent: false
}
client.queue.push(req)
client.pump()
})
}
// hasQueued reports whether a command of this kind is already waiting.
hasQueued(cmd: string): boolean {
if (this.inFlight != null && this.inFlight.payload["cmd"] == cmd) {
return true
}
for (var i = 0; i < this.queue.length; i++) {
if (this.queue[i].payload["cmd"] == cmd) {
return true
}
}
return false
}
private connect(): void {
if (this.socket != null && (this.socket.readyState == WebSocket.OPEN || this.socket.readyState == WebSocket.CONNECTING)) {
return
}
var client = this
var socket = new WebSocket(this.url())
this.socket = socket
socket.onopen = function() {
WS_AVAILABLE = true
client.reconnectDelay = 500
client.pump()
}
socket.onmessage = function(e: MessageEvent) {
client.handleMessage(e.data)
}
socket.onerror = function() {
// onclose follows; nothing to do here.
}
socket.onclose = function() {
if (client.socket == socket) {
client.socket = null
}
// An in-flight request that never got its answer goes back to the
// front of the queue and is retried on the next connection.
if (client.inFlight != null) {
var req = client.inFlight
client.inFlight = null
req.sent = false
client.queue.unshift(req)
}
client.scheduleReconnect()
}
}
private scheduleReconnect(): void {
if (this.closedByUs || this.reconnectTimer != 0) {
return
}
if (this.queue.length == 0) {
// Idle: reconnect lazily on the next request.
return
}
var client = this
this.reconnectTimer = window.setTimeout(function() {
client.reconnectTimer = 0
client.connect()
}, this.reconnectDelay)
this.reconnectDelay = Math.min(this.reconnectDelay * 2, 10000)
}
// pump sends the next queued request if the socket is open and idle.
private pump(): void {
if (this.inFlight != null || this.queue.length == 0) {
return
}
if (this.socket == null || this.socket.readyState != WebSocket.OPEN) {
this.connect()
return
}
var req = this.queue.shift()
this.inFlight = req
req.sent = true
req.payload["id"] = req.id
var client = this
req.timer = window.setTimeout(function() {
if (client.inFlight == req) {
client.inFlight = null
req.reject(new Error("timeout"))
// A socket that stopped answering is not trusted any more.
client.resetSocket()
client.pump()
}
}, this.requestTimeoutMs)
try {
this.socket.send(JSON.stringify(req.payload))
} catch (err) {
window.clearTimeout(req.timer)
this.inFlight = null
req.sent = false
this.queue.unshift(req)
this.resetSocket()
}
}
private resetSocket(): void {
var socket = this.socket
this.socket = null
if (socket != null) {
socket.onclose = null
try { socket.close() } catch (err) { /* ignore */ }
}
this.scheduleReconnect()
}
private handleMessage(raw: string): void {
var response: any
try {
response = JSON.parse(raw)
} catch (err) {
return
}
var req = this.inFlight
if (req == null) {
return
}
// Servers that echo the id let us drop stray late answers.
if (response.hasOwnProperty("id") && response["id"] != req.id) {
return
}
window.clearTimeout(req.timer)
this.inFlight = null
req.resolve(response)
this.pump()
}
}
var WS = new WSClient()
class Server {
cmd: string
constructor(cmd: string) {
this.cmd = cmd
}
request(data: Object): any {
var isLogUpdate: boolean = this.cmd == "updateLog"
// Log polling must never pile up behind a stalled connection.
if (isLogUpdate && WS.hasQueued("updateLog")) {
return
}
if (!isLogUpdate) {
showElement("loading", true)
UNDO = new Object()
setConnectionState("busy")
}
data["cmd"] = this.cmd
var requestCmd: string = this.cmd
WS.send(data).then(function(response) {
WS_FAILURE_COUNT = 0
if (!isLogUpdate) {
showElement("loading", false)
}
setConnectionState("online")
if (response["status"] == false) {
setConnectionState("offline")
alert(response["err"])
if (response.hasOwnProperty("reload")) {
location.reload()
}
return
}
if (response.hasOwnProperty("logoURL")) {
var div = (document.getElementById("channel-icon") as HTMLInputElement)
div.value = response["logoURL"]
div.className = "changed"
return
}
switch (requestCmd) {
case "updateLog":
SERVER["log"] = response["log"]
if (document.getElementById("content_log")) {
showLogs(false)
}
return
default:
SERVER = response
break
}
if (response.hasOwnProperty("openMenu")) {
var menu = document.getElementById(response["openMenu"])
menu.click()
showElement("popup", false)
}
if (response.hasOwnProperty("openLink")) {
window.location = response["openLink"]
}
if (response.hasOwnProperty("alert")) {
alert(response["alert"])
}
if (response.hasOwnProperty("reload")) {
location.reload()
}
if (response.hasOwnProperty("wizard")) {
createLayout()
configurationWizard[response["wizard"]].createWizard()
return
}
createLayout()
}, function(err) {
WS_FAILURE_COUNT++
if (!isLogUpdate) {
showElement("loading", false)
}
var state: string = "offline"
if (isLogUpdate && WS_FAILURE_COUNT < 2) {
state = "idle"
}
setConnectionState(state)
if (WS_AVAILABLE == false && !isLogUpdate && requestCmd != "getServerConfig") {
alert("No websocket connection to xTeVe could be established. Check your network configuration.")
}
})
}
}
var WS_FAILURE_COUNT: number = 0