@socketry/live
Version:
Live HTML tags for Ruby.
297 lines (222 loc) • 6.77 kB
JavaScript
import morphdom from 'morphdom';
export class Live {
#window;
#document;
#server;
#events;
#failures;
#reconnectTimer;
static start(options = {}) {
let window = options.window || globalThis;
let path = options.path || 'live'
let base = options.base || window.location.href;
let url = new URL(path, base);
url.protocol = url.protocol.replace('http', 'ws');
return new this(window, url);
}
constructor(window, url) {
this.#window = window;
this.#document = window.document;
this.url = url;
this.#server = null;
this.#events = [];
this.#failures = 0;
this.#reconnectTimer = null;
// Track visibility state and connect if required:
this.#document.addEventListener("visibilitychange", () => this.#handleVisibilityChange());
this.#handleVisibilityChange();
const elementNodeType = this.#window.Node.ELEMENT_NODE;
// Create a MutationObserver to watch for removed nodes
this.observer = new this.#window.MutationObserver((mutationsList, observer) => {
for (let mutation of mutationsList) {
if (mutation.type === 'childList') {
for (let node of mutation.removedNodes) {
if (node.nodeType !== elementNodeType) continue;
if (node.classList?.contains('live')) {
this.#unbind(node);
}
// Unbind any child nodes:
for (let child of node.getElementsByClassName('live')) {
this.#unbind(child);
}
}
for (let node of mutation.addedNodes) {
if (node.nodeType !== elementNodeType) continue;
if (node.classList.contains('live')) {
this.#bind(node);
}
// Bind any child nodes:
for (let child of node.getElementsByClassName('live')) {
this.#bind(child);
}
}
}
}
});
this.observer.observe(this.#document.body, {childList: true, subtree: true});
}
// -- Connection Handling --
connect() {
if (this.#server) {
return this.#server;
}
let server = this.#server = new this.#window.WebSocket(this.url);
if (this.#reconnectTimer) {
clearTimeout(this.#reconnectTimer);
this.#reconnectTimer = null;
}
server.onopen = () => {
this.#failures = 0;
this.#flush();
this.#attach();
};
server.onmessage = (message) => {
const [name, ...args] = JSON.parse(message.data);
this[name](...args);
};
// The remote end has disconnected:
server.addEventListener('error', () => {
this.#failures += 1;
});
server.addEventListener('close', () => {
// Explicit disconnect will clear `this.#server`:
if (this.#server && !this.#reconnectTimer) {
// We need a minimum delay otherwise this can end up immediately invoking the callback:
const delay = Math.min(100 * (this.#failures ** 2), 60000);
this.#reconnectTimer = setTimeout(() => {
this.#reconnectTimer = null;
this.connect();
}, delay);
}
if (this.#server === server) {
this.#server = null;
}
});
return server;
}
disconnect() {
if (this.#server) {
const server = this.#server;
this.#server = null;
server.close();
}
if (this.#reconnectTimer) {
clearTimeout(this.#reconnectTimer);
this.#reconnectTimer = null;
}
}
#send(message) {
if (this.#server) {
try {
return this.#server.send(message);
} catch (error) {
// console.log("Live.send", "failed to send message to server", error);
}
}
this.#events.push(message);
}
#flush() {
if (this.#events.length === 0) return;
let events = this.#events;
this.#events = [];
for (var event of events) {
this.#send(event);
}
}
#handleVisibilityChange() {
if (this.#document.hidden) {
this.disconnect();
} else {
this.connect();
}
}
#bind(element) {
console.log("bind", element.id, element.dataset);
this.#send(JSON.stringify(['bind', element.id, element.dataset]));
}
#unbind(element) {
console.log("unbind", element.id, element.dataset);
if (this.#server) {
this.#send(JSON.stringify(['unbind', element.id]));
}
}
#attach() {
for (let node of this.#document.getElementsByClassName('live')) {
this.#bind(node);
}
}
#createDocumentFragment(html) {
return this.#document.createRange().createContextualFragment(html);
}
#reply(options, ...args) {
if (options?.reply) {
this.#send(JSON.stringify(['reply', options.reply, ...args]));
}
}
// -- RPC Methods --
script(id, code, options) {
let element = this.#document.getElementById(id);
try {
let result = this.#window.Function(code).call(element);
this.#reply(options, result);
} catch (error) {
this.#reply(options, null, {name: error.name, message: error.message, stack: error.stack});
}
}
update(id, html, options) {
let element = this.#document.getElementById(id);
let fragment = this.#createDocumentFragment(html);
morphdom(element, fragment);
this.#reply(options);
}
replace(selector, html, options) {
let elements = this.#document.querySelectorAll(selector);
let fragment = this.#createDocumentFragment(html);
elements.forEach(element => morphdom(element, fragment.cloneNode(true)));
this.#reply(options);
}
prepend(selector, html, options) {
let elements = this.#document.querySelectorAll(selector);
let fragment = this.#createDocumentFragment(html);
elements.forEach(element => element.prepend(fragment.cloneNode(true)));
this.#reply(options);
}
append(selector, html, options) {
let elements = this.#document.querySelectorAll(selector);
let fragment = this.#createDocumentFragment(html);
elements.forEach(element => element.append(fragment.cloneNode(true)));
this.#reply(options);
}
remove(selector, options) {
let elements = this.#document.querySelectorAll(selector);
elements.forEach(element => element.remove());
this.#reply(options);
}
dispatchEvent(selector, type, options) {
let elements = this.#document.querySelectorAll(selector);
elements.forEach(element => element.dispatchEvent(
new this.#window.CustomEvent(type, options)
));
this.#reply(options);
}
error(message) {
console.error("Live.error", ...arguments);
}
// -- Event Handling --
forward(id, event) {
this.connect();
this.#send(
JSON.stringify(['event', id, event])
);
}
forwardEvent(id, event, detail, preventDefault = false) {
if (preventDefault) event.preventDefault();
this.forward(id, {type: event.type, detail: detail});
}
forwardFormEvent(id, event, detail, preventDefault = true) {
if (preventDefault) event.preventDefault();
let form = event.form;
let formData = new FormData(form);
this.forward(id, {type: event.type, detail: detail, formData: [...formData]});
}
}