diff --git a/public/js/ws.js b/public/js/ws.js new file mode 100644 index 0000000..be1f08d --- /dev/null +++ b/public/js/ws.js @@ -0,0 +1,467 @@ +/* +WebSockets Extension +============================ +This extension adds support for WebSockets to htmx. See /www/extensions/ws.md for usage instructions. +*/ + +(function() { + /** @type {import("../htmx").HtmxInternalApi} */ + var api + + htmx.defineExtension('ws', { + + /** + * init is called once, when this extension is first registered. + * @param {import("../htmx").HtmxInternalApi} apiRef + */ + init: function(apiRef) { + // Store reference to internal API + api = apiRef + + // Default function for creating new EventSource objects + if (!htmx.createWebSocket) { + htmx.createWebSocket = createWebSocket + } + + // Default setting for reconnect delay + if (!htmx.config.wsReconnectDelay) { + htmx.config.wsReconnectDelay = 'full-jitter' + } + }, + + /** + * onEvent handles all events passed to this extension. + * + * @param {string} name + * @param {Event} evt + */ + onEvent: function(name, evt) { + var parent = evt.target || evt.detail.elt + switch (name) { + // Try to close the socket when elements are removed + case 'htmx:beforeCleanupElement': + + var internalData = api.getInternalData(parent) + + if (internalData.webSocket) { + internalData.webSocket.close() + } + return + + // Try to create websockets when elements are processed + case 'htmx:beforeProcessNode': + + forEach(queryAttributeOnThisOrChildren(parent, 'ws-connect'), function(child) { + ensureWebSocket(child) + }) + forEach(queryAttributeOnThisOrChildren(parent, 'ws-send'), function(child) { + ensureWebSocketSend(child) + }) + } + } + }) + + function splitOnWhitespace(trigger) { + return trigger.trim().split(/\s+/) + } + + function getLegacyWebsocketURL(elt) { + var legacySSEValue = api.getAttributeValue(elt, 'hx-ws') + if (legacySSEValue) { + var values = splitOnWhitespace(legacySSEValue) + for (var i = 0; i < values.length; i++) { + var value = values[i].split(/:(.+)/) + if (value[0] === 'connect') { + return value[1] + } + } + } + } + + /** + * ensureWebSocket creates a new WebSocket on the designated element, using + * the element's "ws-connect" attribute. + * @param {HTMLElement} socketElt + * @returns + */ + function ensureWebSocket(socketElt) { + // If the element containing the WebSocket connection no longer exists, then + // do not connect/reconnect the WebSocket. + if (!api.bodyContains(socketElt)) { + return + } + + // Get the source straight from the element's value + var wssSource = api.getAttributeValue(socketElt, 'ws-connect') + + if (wssSource == null || wssSource === '') { + var legacySource = getLegacyWebsocketURL(socketElt) + if (legacySource == null) { + return + } else { + wssSource = legacySource + } + } + + // Guarantee that the wssSource value is a fully qualified URL + if (wssSource.indexOf('/') === 0) { + var base_part = location.hostname + (location.port ? ':' + location.port : '') + if (location.protocol === 'https:') { + wssSource = 'wss://' + base_part + wssSource + } else if (location.protocol === 'http:') { + wssSource = 'ws://' + base_part + wssSource + } + } + + var socketWrapper = createWebsocketWrapper(socketElt, function() { + return htmx.createWebSocket(wssSource) + }) + + socketWrapper.addEventListener('message', function(event) { + if (maybeCloseWebSocketSource(socketElt)) { + return + } + + var response = event.data + if (!api.triggerEvent(socketElt, 'htmx:wsBeforeMessage', { + message: response, + socketWrapper: socketWrapper.publicInterface + })) { + return + } + + api.withExtensions(socketElt, function(extension) { + response = extension.transformResponse(response, null, socketElt) + }) + + var settleInfo = api.makeSettleInfo(socketElt) + var fragment = api.makeFragment(response) + + if (fragment.children.length) { + var children = Array.from(fragment.children) + for (var i = 0; i < children.length; i++) { + api.oobSwap(api.getAttributeValue(children[i], 'hx-swap-oob') || 'true', children[i], settleInfo) + } + } + + api.settleImmediately(settleInfo.tasks) + api.triggerEvent(socketElt, 'htmx:wsAfterMessage', { message: response, socketWrapper: socketWrapper.publicInterface }) + }) + + // Put the WebSocket into the HTML Element's custom data. + api.getInternalData(socketElt).webSocket = socketWrapper + } + + /** + * @typedef {Object} WebSocketWrapper + * @property {WebSocket} socket + * @property {Array<{message: string, sendElt: Element}>} messageQueue + * @property {number} retryCount + * @property {(message: string, sendElt: Element) => void} sendImmediately sendImmediately sends message regardless of websocket connection state + * @property {(message: string, sendElt: Element) => void} send + * @property {(event: string, handler: Function) => void} addEventListener + * @property {() => void} handleQueuedMessages + * @property {() => void} init + * @property {() => void} close + */ + /** + * + * @param socketElt + * @param socketFunc + * @returns {WebSocketWrapper} + */ + function createWebsocketWrapper(socketElt, socketFunc) { + var wrapper = { + socket: null, + messageQueue: [], + retryCount: 0, + + /** @type {Object} */ + events: {}, + + addEventListener: function(event, handler) { + if (this.socket) { + this.socket.addEventListener(event, handler) + } + + if (!this.events[event]) { + this.events[event] = [] + } + + this.events[event].push(handler) + }, + + sendImmediately: function(message, sendElt) { + if (!this.socket) { + api.triggerErrorEvent() + } + if (!sendElt || api.triggerEvent(sendElt, 'htmx:wsBeforeSend', { + message, + socketWrapper: this.publicInterface + })) { + this.socket.send(message) + sendElt && api.triggerEvent(sendElt, 'htmx:wsAfterSend', { + message, + socketWrapper: this.publicInterface + }) + } + }, + + send: function(message, sendElt) { + if (this.socket.readyState !== this.socket.OPEN) { + this.messageQueue.push({ message, sendElt }) + } else { + this.sendImmediately(message, sendElt) + } + }, + + handleQueuedMessages: function() { + while (this.messageQueue.length > 0) { + var queuedItem = this.messageQueue[0] + if (this.socket.readyState === this.socket.OPEN) { + this.sendImmediately(queuedItem.message, queuedItem.sendElt) + this.messageQueue.shift() + } else { + break + } + } + }, + + init: function() { + if (this.socket && this.socket.readyState === this.socket.OPEN) { + // Close discarded socket + this.socket.close() + } + + // Create a new WebSocket and event handlers + /** @type {WebSocket} */ + var socket = socketFunc() + + // The event.type detail is added for interface conformance with the + // other two lifecycle events (open and close) so a single handler method + // can handle them polymorphically, if required. + api.triggerEvent(socketElt, 'htmx:wsConnecting', { event: { type: 'connecting' } }) + + this.socket = socket + + socket.onopen = function(e) { + wrapper.retryCount = 0 + api.triggerEvent(socketElt, 'htmx:wsOpen', { event: e, socketWrapper: wrapper.publicInterface }) + wrapper.handleQueuedMessages() + } + + socket.onclose = function(e) { + // If socket should not be connected, stop further attempts to establish connection + // If Abnormal Closure/Service Restart/Try Again Later, then set a timer to reconnect after a pause. + if (!maybeCloseWebSocketSource(socketElt) && [1006, 1012, 1013].indexOf(e.code) >= 0) { + var delay = getWebSocketReconnectDelay(wrapper.retryCount) + setTimeout(function() { + wrapper.retryCount += 1 + wrapper.init() + }, delay) + } + + // Notify client code that connection has been closed. Client code can inspect `event` field + // to determine whether closure has been valid or abnormal + api.triggerEvent(socketElt, 'htmx:wsClose', { event: e, socketWrapper: wrapper.publicInterface }) + } + + socket.onerror = function(e) { + api.triggerErrorEvent(socketElt, 'htmx:wsError', { error: e, socketWrapper: wrapper }) + maybeCloseWebSocketSource(socketElt) + } + + var events = this.events + Object.keys(events).forEach(function(k) { + events[k].forEach(function(e) { + socket.addEventListener(k, e) + }) + }) + }, + + close: function() { + this.socket.close() + } + } + + wrapper.init() + + wrapper.publicInterface = { + send: wrapper.send.bind(wrapper), + sendImmediately: wrapper.sendImmediately.bind(wrapper), + queue: wrapper.messageQueue + } + + return wrapper + } + + /** + * ensureWebSocketSend attaches trigger handles to elements with + * "ws-send" attribute + * @param {HTMLElement} elt + */ + function ensureWebSocketSend(elt) { + var legacyAttribute = api.getAttributeValue(elt, 'hx-ws') + if (legacyAttribute && legacyAttribute !== 'send') { + return + } + + var webSocketParent = api.getClosestMatch(elt, hasWebSocket) + processWebSocketSend(webSocketParent, elt) + } + + /** + * hasWebSocket function checks if a node has webSocket instance attached + * @param {HTMLElement} node + * @returns {boolean} + */ + function hasWebSocket(node) { + return api.getInternalData(node).webSocket != null + } + + /** + * processWebSocketSend adds event listeners to the
element so that + * messages can be sent to the WebSocket server when the form is submitted. + * @param {HTMLElement} socketElt + * @param {HTMLElement} sendElt + */ + function processWebSocketSend(socketElt, sendElt) { + var nodeData = api.getInternalData(sendElt) + var triggerSpecs = api.getTriggerSpecs(sendElt) + triggerSpecs.forEach(function(ts) { + api.addTriggerHandler(sendElt, ts, nodeData, function(elt, evt) { + if (maybeCloseWebSocketSource(socketElt)) { + return + } + + /** @type {WebSocketWrapper} */ + var socketWrapper = api.getInternalData(socketElt).webSocket + var headers = api.getHeaders(sendElt, api.getTarget(sendElt)) + var results = api.getInputValues(sendElt, 'post') + var errors = results.errors + var rawParameters = Object.assign({}, results.values) + var expressionVars = api.getExpressionVars(sendElt) + var allParameters = api.mergeObjects(rawParameters, expressionVars) + var filteredParameters = api.filterValues(allParameters, sendElt) + + var sendConfig = { + parameters: filteredParameters, + unfilteredParameters: allParameters, + headers, + errors, + + triggeringEvent: evt, + messageBody: undefined, + socketWrapper: socketWrapper.publicInterface + } + + if (!api.triggerEvent(elt, 'htmx:wsConfigSend', sendConfig)) { + return + } + + if (errors && errors.length > 0) { + api.triggerEvent(elt, 'htmx:validation:halted', errors) + return + } + + var body = sendConfig.messageBody + if (body === undefined) { + var toSend = Object.assign({}, sendConfig.parameters) + if (sendConfig.headers) { toSend.HEADERS = headers } + body = JSON.stringify(toSend) + } + + socketWrapper.send(body, elt) + + if (evt && api.shouldCancel(evt, elt)) { + evt.preventDefault() + } + }) + }) + } + + /** + * getWebSocketReconnectDelay is the default easing function for WebSocket reconnects. + * @param {number} retryCount // The number of retries that have already taken place + * @returns {number} + */ + function getWebSocketReconnectDelay(retryCount) { + /** @type {"full-jitter" | ((retryCount:number) => number)} */ + var delay = htmx.config.wsReconnectDelay + if (typeof delay === 'function') { + return delay(retryCount) + } + if (delay === 'full-jitter') { + var exp = Math.min(retryCount, 6) + var maxDelay = 1000 * Math.pow(2, exp) + return maxDelay * Math.random() + } + + logError('htmx.config.wsReconnectDelay must either be a function or the string "full-jitter"') + } + + /** + * maybeCloseWebSocketSource checks to the if the element that created the WebSocket + * still exists in the DOM. If NOT, then the WebSocket is closed and this function + * returns TRUE. If the element DOES EXIST, then no action is taken, and this function + * returns FALSE. + * + * @param {*} elt + * @returns + */ + function maybeCloseWebSocketSource(elt) { + if (!api.bodyContains(elt)) { + api.getInternalData(elt).webSocket.close() + return true + } + return false + } + + /** + * createWebSocket is the default method for creating new WebSocket objects. + * it is hoisted into htmx.createWebSocket to be overridden by the user, if needed. + * + * @param {string} url + * @returns WebSocket + */ + function createWebSocket(url) { + var sock = new WebSocket(url, []) + sock.binaryType = htmx.config.wsBinaryType + return sock + } + + /** + * queryAttributeOnThisOrChildren returns all nodes that contain the requested attributeName, INCLUDING THE PROVIDED ROOT ELEMENT. + * + * @param {HTMLElement} elt + * @param {string} attributeName + */ + function queryAttributeOnThisOrChildren(elt, attributeName) { + var result = [] + + // If the parent element also contains the requested attribute, then add it to the results too. + if (api.hasAttribute(elt, attributeName) || api.hasAttribute(elt, 'hx-ws')) { + result.push(elt) + } + + // Search all child nodes that match the requested attribute + elt.querySelectorAll('[' + attributeName + '], [data-' + attributeName + '], [data-hx-ws], [hx-ws]').forEach(function(node) { + result.push(node) + }) + + return result + } + + /** + * @template T + * @param {T[]} arr + * @param {(T) => void} func + */ + function forEach(arr, func) { + if (arr) { + for (var i = 0; i < arr.length; i++) { + func(arr[i]) + } + } + } +})()