From 7cf50ef276edceda44e332ced694e3070c7a60db Mon Sep 17 00:00:00 2001 From: Anbraten Date: Sun, 28 Jan 2024 22:44:07 +0100 Subject: [PATCH] fix htmx ws loading --- web_src/js/htmx.js | 8 +- web_src/js/ws.js | 512 --------------------------------------------- webpack.config.js | 3 + 3 files changed, 4 insertions(+), 519 deletions(-) delete mode 100644 web_src/js/ws.js diff --git a/web_src/js/htmx.js b/web_src/js/htmx.js index 183498cfde3..9a15c54d912 100644 --- a/web_src/js/htmx.js +++ b/web_src/js/htmx.js @@ -1,15 +1,9 @@ import * as htmx from "htmx.org"; import { showErrorToast } from "./modules/toast.js"; -import { ws } from "./ws.js"; +import "htmx.org/dist/ext/ws.js"; window.htmx = htmx; -// TODO: https://github.com/bigskysoftware/htmx/issues/1690 -ws(); -// import("htmx.org/dist/ext/ws.js"); - -console.log("htmx.js loaded", htmx.version, htmx); - // https://htmx.org/reference/#config htmx.config.requestClass = "is-loading"; htmx.config.scrollIntoViewOnBoost = false; diff --git a/web_src/js/ws.js b/web_src/js/ws.js deleted file mode 100644 index 2eb72837be5..00000000000 --- a/web_src/js/ws.js +++ /dev/null @@ -1,512 +0,0 @@ -/* -WebSockets Extension -============================ -This extension adds support for WebSockets to htmx. See /www/extensions/ws.md for usage instructions. -*/ - -export function ws() { - /** @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) { - switch (name) { - // Try to close the socket when elements are removed - case "htmx:beforeCleanupElement": - var internalData = api.getInternalData(evt.target); - - if (internalData.webSocket) { - internalData.webSocket.close(); - } - return; - - // Try to create websockets when elements are processed - case "htmx:beforeProcessNode": - var parent = evt.target; - - 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: message, - socketWrapper: this.publicInterface, - }) - ) { - this.socket.send(message); - sendElt && - api.triggerEvent(sendElt, "htmx:wsAfterSend", { - message: message, - socketWrapper: this.publicInterface, - }); - } - }, - - send: function (message, sendElt) { - if (this.socket.readyState !== this.socket.OPEN) { - this.messageQueue.push({ message: message, sendElt: 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 = 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: headers, - errors: 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]); - } - } - } -} diff --git a/webpack.config.js b/webpack.config.js index 0d8418938f0..b67722ada14 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -178,6 +178,9 @@ export default { ], }, plugins: [ + new webpack.ProvidePlugin({ + htmx: 'htmx.org', + }), new DefinePlugin({ __VUE_OPTIONS_API__: true, // at the moment, many Vue components still use the Vue Options API __VUE_PROD_DEVTOOLS__: false, // do not enable devtools support in production