class WSElement extends HTMLElement { static #sockets = {}; #socket; #delay = 1000; constructor() { super(); } connectedCallback() { // track `src` and `autoreconnect` attributes for later use // `autoreconnect` defaults to `true`, unless explicitly set to `false` this.src = this.getAttribute('src'); this.autoreconnect = !this.hasAttribute('autoreconnect') || this.getAttribute('autoreconnect').toLowerCase() !== 'false'; this.#connect_socket(false); } #connect_socket(recreate) { // `recreate` is used to handle reconnects, removeing the old websocket object // and recreating a new one if(recreate && WSElement.#sockets[this.src]) { delete WSElement.#sockets[this.src]; } // a single WebSocket can be reused for multiple elements // check to see if one exists for this `src` else create // a new one. if(!(this.src in WSElement.#sockets)) { this.#socket = WSElement.#newSocket(this.src); WSElement.#sockets[this.src] = this.#socket; } else { this.#socket = WSElement.#sockets[this.src]; } // setup event handlers for this WebSocket/Elemnent pair this.#socket.addEventListener('error', this.#on_ws_error.bind(this)); this.#socket.addEventListener('message', this.#on_ws_message.bind(this)); this.#socket.addEventListener('open', this.#on_ws_open.bind(this)); this.#socket.addEventListener('close', this.#on_ws_close.bind(this)); } #on_ws_open(sock) { // set connected attribute for use in CSS // ``` // ws-element[connected] { // color: green; // } // ws-element:not(connected) { // color: red; // } // ``` this.setAttribute('connected', ''); // call this.open if defined by subclass if('open' in this) { this.open(sock); } } #on_ws_message(sock) { // call this.message, which is always defined this.message(sock.data); } #on_ws_close(sock) { this.removeAttribute('connected'); // clean up event handlers for this element sock.target.removeEventListener('error', this.#on_ws_error.bind(this)); sock.target.removeEventListener('message', this.#on_ws_message.bind(this)); sock.target.removeEventListener('open', this.#on_ws_open.bind(this)); sock.target.removeEventListener('close', this.#on_ws_close.bind(this)); // call this.close if subclass has defined it if('close' in this) { this.close(sock); } // handle autoreconnect and delay throttling if(this.autoreconnect) { setTimeout(this.#connect_socket.call(this, true), this.#delay); if(this.#delay < 10000) { this.#delay += 1000; } } } #on_ws_error(sock) { // call this.error if subclass has defined it if('error' in this) { this.error(sock); } } // default behavior on this.message is just update innerText message(data) { this.innerText = data; } // static method for creating new sockets, which are tracked // on the static propert WSElement.#sockets static #newSocket(src) { var s = window.location.protocol.slice(-2) === 'p:' ? '' : 's'; var host = window.location.host; var path = src[0] === '/' ? src.slice(1) : src; var wsurl = `ws${s}://${host}/${path}`; return new WebSocket(wsurl); } } // Example subclass to display a basic 'Time: 00:00:00' class WSTime extends WSElement { connectedCallback() { super.connectedCallback(); // insert subelements for the label (Time:) and the value; this.insertAdjacentHTML('beforeend', ''); // track the value element for updating later this.value = this.querySelector('span.time-value'); } // override this.message to update the innerText of the value span element message(data) { this.value.innerText = data; } } // Example subclass to parse arbitrary json and build a table // with dynamically controlled limit class WSTable extends WSElement { // observe limit to dynamically update static observedAttributes = [ 'limit' ]; connectedCallback() { super.connectedCallback(); // ignoreNext is to handle duplicate headers being sent on reconnect this.ignoreNext = false; // insert and track table element this.insertAdjacentHTML('beforeend', '
'); this.table = this.querySelector('table'); // track limit attribute, defaulting to no limit if(this.hasAttribute('limit')) { this.limit = parseInt(this.getAttribute('limit')) || -1; } } attributeChangedCallback(name, oldValue, newValue) { // update the tracked limit, defaulting to none if no value provided if(oldValue === null) { this.limit = parseInt(newValue) || -1; } } message(data) { // ignore one message, because the demo server is a bit dumb if(this.ignoreNext) { this.ignoreNext = false; return; } data = JSON.parse(data); // with autoheader set the first message will create a header row instead of body if(this.hasAttribute('autoheader') && !('header' in this)) { // createElement works just as well as insertAdjacentHTML for single, to be tracked elements this.thead = document.createElement('thead'); this.header = document.createElement('tr'); this.thead.appendChild(this.header); this.table.appendChild(this.thead); // add a `th` for each key/value pair in the json data for(let k in data) { this.header.insertAdjacentHTML('beforeend', `${data[k]}`); } // work is done, this block will be ignored in future messages return; } // create the body if it doesn't yet exists if(!('tbody' in this)) { this.tbody = document.createElement('tbody'); this.table.appendChild(this.tbody); } // create TD for each key/value pair in message data var inner = ''; for(let k in data) { inner += `${data[k]}`; } // instead the above TDs into a new TR this.tbody.insertAdjacentHTML('beforeend', `${inner}`); // check limits are enforced if(this.limit > -1 && this.tbody.childElementCount > this.limit) { while(this.tbody.childElementCount > this.limit) { this.tbody.removeChild(this.tbody.childNodes[0]); } } } // ignore the next message if we're disconnected, // demo server always sends headers on connect close(sock) { this.ignoreNext = true; } } // define custom elements customElements.define('ws-element', WSElement); customElements.define('ws-time', WSTime); customElements.define('ws-table', WSTable);