// 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 { 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