/* 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 == undefined) { htmx.createWebSocket = createWebSocket; } // Default setting for reconnect delay if (htmx.config.wsReconnectDelay == undefined) { 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 remove remove an EventSource when elements are removed case "htmx:beforeCleanupElement": var internalData = api.getInternalData(evt.target) if (internalData.webSocket != undefined) { internalData.webSocket.close(); } return; // Try to create EventSources when elements are processed case "htmx:afterProcessNode": 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} elt * @param {number=} retryCount * @returns */ function ensureWebSocket(elt, retryCount) { // If the element containing the WebSocket connection no longer exists, then // do not connect/reconnect the WebSocket. if (!api.bodyContains(elt)) { return; } // Get the source straight from the element's value var wssSource = api.getAttributeValue(elt, "ws-connect") if (wssSource == null || wssSource === "") { var legacySource = getLegacyWebsocketURL(elt); if (legacySource == null) { return; } else { wssSource = legacySource; } } // Default value for retryCount if (retryCount == undefined) { retryCount = 0; } // 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; } } // Create a new WebSocket and event handlers /** @type {WebSocket} */ var socket = htmx.createWebSocket(wssSource); var messageQueue = []; socket.onopen = function (e) { retryCount = 0; handleQueuedMessages(messageQueue, socket); } socket.onclose = function (e) { // If Abnormal Closure/Service Restart/Try Again Later, then set a timer to reconnect after a pause. if ([1006, 1012, 1013].indexOf(e.code) >= 0) { var delay = getWebSocketReconnectDelay(retryCount); setTimeout(function() { ensureWebSocket(elt, retryCount+1); }, delay); } }; socket.onerror = function (e) { api.triggerErrorEvent(elt, "htmx:wsError", {error:e, socket:socket}); maybeCloseWebSocketSource(elt); }; socket.addEventListener('message', function (event) { if (maybeCloseWebSocketSource(elt)) { return; } var response = event.data; api.withExtensions(elt, function(extension){ response = extension.transformResponse(response, null, elt); }); var settleInfo = api.makeSettleInfo(elt); 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); }); // Put the WebSocket into the HTML Element's custom data. api.getInternalData(elt).webSocket = socket; api.getInternalData(elt).webSocketMessageQueue = messageQueue; } /** * 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