ws-element/static/index.js

253 lines
7.3 KiB
JavaScript

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() {
// 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);
}
delete WSElement.#sockets[this.src];
// handle autoreconnect and delay throttling
if(this.autoreconnect) {
setTimeout(this.#connect_socket.bind(this), 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;
}
// kind of wonky work around because privates are not inheritted
get sock() {
return this.#socket;
}
// 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);
}
}
class WSSenderElement extends WSElement {
send(data) {
this.sock.send(data);
}
// by default WSSenderElement should not write to it's own .innerText
// instead the subclass should override `.message` for desired behavior
message(data) {
// no nothing
}
}
class WSInput extends WSSenderElement {
connectedCallback() {
super.connectedCallback();
this.insertAdjacentHTML('beforeend', `
<label class="time-label" for="ws-input-send">Send: </label>
<input placeholder="Press enter" id="ws-input-send" />`
);
this.input = this.querySelector('input');
var self = this;
this.input.addEventListener('keyup', function(evt) {
if(evt.keyCode === 13) {
self.send(self.input.value);
self.input.value = '';
}
})
}
}
// 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', '<label class="time-label">Time: </label><span class="time-value"></span>');
// 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', '<table class="table"></table>');
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', `<th scope="col" class="${k}">${data[k]}</th>`);
}
// 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 += `<td class="${k}">${data[k]}</td>`;
}
// instead the above TDs into a new TR
this.tbody.insertAdjacentHTML('beforeend', `<tr>${inner}</tr>`);
// 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-input', WSInput);
customElements.define('ws-time', WSTime);
customElements.define('ws-table', WSTable);