Deps
All checks were successful
Docker Deploy / build-and-push (push) Successful in 1m9s

This commit is contained in:
Atridad Lahiji 2025-03-24 00:58:56 -06:00
parent 2267706737
commit 0b85919395
Signed by: atridad
SSH key fingerprint: SHA256:LGomp8Opq0jz+7kbwNcdfTcuaLRb5Nh0k5AchDDb438
4 changed files with 415 additions and 339 deletions

File diff suppressed because one or more lines are too long

View file

@ -1,4 +1,4 @@
(function() { (function () {
/** /**
* This adds the "preload" extension to htmx. The extension will * This adds the "preload" extension to htmx. The extension will
* preload the targets of elements with "preload" attribute if: * preload the targets of elements with "preload" attribute if:
@ -11,49 +11,54 @@
* For more details @see https://htmx.org/extensions/preload/ * For more details @see https://htmx.org/extensions/preload/
*/ */
htmx.defineExtension('preload', { htmx.defineExtension("preload", {
onEvent: function(name, event) { onEvent: function (name, event) {
// Process preload attributes on `htmx:afterProcessNode` // Process preload attributes on `htmx:afterProcessNode`
if (name === 'htmx:afterProcessNode') { if (name === "htmx:afterProcessNode") {
// Initialize all nodes with `preload` attribute // Initialize all nodes with `preload` attribute
const parent = event.target || event.detail.elt; const parent = event.target || event.detail.elt;
const preloadNodes = [ const preloadNodes = [
...parent.hasAttribute("preload") ? [parent] : [], ...(parent.hasAttribute("preload") ? [parent] : []),
...parent.querySelectorAll("[preload]")] ...parent.querySelectorAll("[preload]"),
preloadNodes.forEach(function(node) { ];
preloadNodes.forEach(function (node) {
// Initialize the node with the `preload` attribute // Initialize the node with the `preload` attribute
init(node) init(node);
// Initialize all child elements which has // Initialize all child elements which has
// `href`, `hx-get` or `data-hx-get` attributes // `href`, `hx-get` or `data-hx-get` attributes
node.querySelectorAll('[href],[hx-get],[data-hx-get]').forEach(init) node.querySelectorAll("[href],[hx-get],[data-hx-get]").forEach(init);
}) });
return return;
} }
// Intercept HTMX preload requests on `htmx:beforeRequest` and // Intercept HTMX preload requests on `htmx:beforeRequest` and
// send them as XHR requests instead to avoid side-effects, // send them as XHR requests instead to avoid side-effects,
// such as showing loading indicators while preloading data. // such as showing loading indicators while preloading data.
if (name === 'htmx:beforeRequest') { if (name === "htmx:beforeRequest") {
const requestHeaders = event.detail.requestConfig.headers const requestHeaders = event.detail.requestConfig.headers;
if (!("HX-Preloaded" in requestHeaders if (
&& requestHeaders["HX-Preloaded"] === "true")) { !(
return "HX-Preloaded" in requestHeaders &&
requestHeaders["HX-Preloaded"] === "true"
)
) {
return;
} }
event.preventDefault() event.preventDefault();
// Reuse XHR created by HTMX with replaced callbacks // Reuse XHR created by HTMX with replaced callbacks
const xhr = event.detail.xhr const xhr = event.detail.xhr;
xhr.onload = function() { xhr.onload = function () {
processResponse(event.detail.elt, xhr.responseText) processResponse(event.detail.elt, xhr.responseText);
};
xhr.onerror = null;
xhr.onabort = null;
xhr.ontimeout = null;
xhr.send();
} }
xhr.onerror = null },
xhr.onabort = null });
xhr.ontimeout = null
xhr.send()
}
}
})
/** /**
* Initialize `node`, set up event handlers based on own or inherited * Initialize `node`, set up event handlers based on own or inherited
@ -72,63 +77,71 @@
function init(node) { function init(node) {
// Guarantee that each node is initialized only once // Guarantee that each node is initialized only once
if (node.preloadState !== undefined) { if (node.preloadState !== undefined) {
return return;
} }
if (!isValidNodeForPreloading(node)) { if (!isValidNodeForPreloading(node)) {
return return;
} }
// Initialize form element preloading // Initialize form element preloading
if (node instanceof HTMLFormElement) { if (node instanceof HTMLFormElement) {
const form = node const form = node;
// Only initialize forms with `method="get"` or `hx-get` attributes // Only initialize forms with `method="get"` or `hx-get` attributes
if (!((form.hasAttribute('method') && form.method === 'get') if (
|| form.hasAttribute('hx-get') || form.hasAttribute('hx-data-get'))) { !(
return (form.hasAttribute("method") && form.method === "get") ||
form.hasAttribute("hx-get") ||
form.hasAttribute("hx-data-get")
)
) {
return;
} }
for (let i = 0; i < form.elements.length; i++) { for (let i = 0; i < form.elements.length; i++) {
const element = form.elements.item(i); const element = form.elements.item(i);
init(element); init(element);
element.labels.forEach(init); element.labels.forEach(init);
} }
return return;
} }
// Process node configuration from preload attribute // Process node configuration from preload attribute
let preloadAttr = getClosestAttribute(node, 'preload'); let preloadAttr = getClosestAttribute(node, "preload");
node.preloadAlways = preloadAttr && preloadAttr.includes('always'); node.preloadAlways = preloadAttr && preloadAttr.includes("always");
if (node.preloadAlways) { if (node.preloadAlways) {
preloadAttr = preloadAttr.replace('always', '').trim(); preloadAttr = preloadAttr.replace("always", "").trim();
} }
let triggerEventName = preloadAttr || 'mousedown'; let triggerEventName = preloadAttr || "mousedown";
// Set up event handlers listening for triggering events // Set up event handlers listening for triggering events
const needsTimeout = triggerEventName === 'mouseover' const needsTimeout = triggerEventName === "mouseover";
node.addEventListener(triggerEventName, getEventHandler(node, needsTimeout)) node.addEventListener(
triggerEventName,
getEventHandler(node, needsTimeout),
);
// Add `touchstart` listener for touchscreen support // Add `touchstart` listener for touchscreen support
// if `mousedown` or `mouseover` is used // if `mousedown` or `mouseover` is used
if (triggerEventName === 'mousedown' || triggerEventName === 'mouseover') { if (triggerEventName === "mousedown" || triggerEventName === "mouseover") {
node.addEventListener('touchstart', getEventHandler(node)) node.addEventListener("touchstart", getEventHandler(node));
} }
// If `mouseover` is used, set up `mouseout` listener, // If `mouseover` is used, set up `mouseout` listener,
// which will abort preloading if user moves mouse outside // which will abort preloading if user moves mouse outside
// the element in less than 100ms after hovering over it // the element in less than 100ms after hovering over it
if (triggerEventName === 'mouseover') { if (triggerEventName === "mouseover") {
node.addEventListener('mouseout', function(evt) { node.addEventListener("mouseout", function (evt) {
if ((evt.target === node) && (node.preloadState === 'TIMEOUT')) { if (evt.target === node && node.preloadState === "TIMEOUT") {
node.preloadState = 'READY' node.preloadState = "READY";
} }
}) });
} }
// Mark the node as ready to be preloaded // Mark the node as ready to be preloaded
node.preloadState = 'READY' node.preloadState = "READY";
// This event can be used to load content immediately // This event can be used to load content immediately
htmx.trigger(node, 'preload:init') htmx.trigger(node, "preload:init");
} }
/** /**
@ -139,27 +152,27 @@
* @returns {function(): void} * @returns {function(): void}
*/ */
function getEventHandler(node, needsTimeout = false) { function getEventHandler(node, needsTimeout = false) {
return function() { return function () {
// Do not preload uninitialized nodes, nodes which are in process // Do not preload uninitialized nodes, nodes which are in process
// of being preloaded or have been preloaded and don't need repeat // of being preloaded or have been preloaded and don't need repeat
if (node.preloadState !== 'READY') { if (node.preloadState !== "READY") {
return return;
} }
if (needsTimeout) { if (needsTimeout) {
node.preloadState = 'TIMEOUT' node.preloadState = "TIMEOUT";
const timeoutMs = 100 const timeoutMs = 100;
window.setTimeout(function() { window.setTimeout(function () {
if (node.preloadState === 'TIMEOUT') { if (node.preloadState === "TIMEOUT") {
node.preloadState = 'READY' node.preloadState = "READY";
load(node) load(node);
} }
}, timeoutMs) }, timeoutMs);
return return;
} }
load(node) load(node);
} };
} }
/** /**
@ -171,76 +184,85 @@
function load(node) { function load(node) {
// Do not preload uninitialized nodes, nodes which are in process // Do not preload uninitialized nodes, nodes which are in process
// of being preloaded or have been preloaded and don't need repeat // of being preloaded or have been preloaded and don't need repeat
if (node.preloadState !== 'READY') { if (node.preloadState !== "READY") {
return return;
} }
node.preloadState = 'LOADING' node.preloadState = "LOADING";
// Load nodes with `hx-get` or `data-hx-get` attribute // Load nodes with `hx-get` or `data-hx-get` attribute
// Forms don't reach this because only their elements are initialized // Forms don't reach this because only their elements are initialized
const hxGet = node.getAttribute('hx-get') || node.getAttribute('data-hx-get') const hxGet =
node.getAttribute("hx-get") || node.getAttribute("data-hx-get");
if (hxGet) { if (hxGet) {
sendHxGetRequest(hxGet, node); sendHxGetRequest(hxGet, node);
return return;
} }
// Load nodes with `href` attribute // Load nodes with `href` attribute
const hxBoost = getClosestAttribute(node, "hx-boost") === "true" const hxBoost = getClosestAttribute(node, "hx-boost") === "true";
if (node.hasAttribute('href')) { if (node.hasAttribute("href")) {
const url = node.getAttribute('href'); const url = node.getAttribute("href");
if (hxBoost) { if (hxBoost) {
sendHxGetRequest(url, node); sendHxGetRequest(url, node);
} else { } else {
sendXmlGetRequest(url, node); sendXmlGetRequest(url, node);
} }
return return;
} }
// Load form elements // Load form elements
if (isPreloadableFormElement(node)) { if (isPreloadableFormElement(node)) {
const url = node.form.getAttribute('action') const url =
|| node.form.getAttribute('hx-get') node.form.getAttribute("action") ||
|| node.form.getAttribute('data-hx-get'); node.form.getAttribute("hx-get") ||
node.form.getAttribute("data-hx-get");
const formData = htmx.values(node.form); const formData = htmx.values(node.form);
const isStandardForm = !(node.form.getAttribute('hx-get') const isStandardForm = !(
|| node.form.getAttribute('data-hx-get') node.form.getAttribute("hx-get") ||
|| hxBoost); node.form.getAttribute("data-hx-get") ||
const sendGetRequest = isStandardForm ? sendXmlGetRequest : sendHxGetRequest hxBoost
);
const sendGetRequest = isStandardForm
? sendXmlGetRequest
: sendHxGetRequest;
// submit button // submit button
if (node.type === 'submit') { if (node.type === "submit") {
sendGetRequest(url, node.form, formData) sendGetRequest(url, node.form, formData);
return return;
} }
// select // select
const inputName = node.name || node.control.name; const inputName = node.name || node.control.name;
if (node.tagName === 'SELECT') { if (node.tagName === "SELECT") {
Array.from(node.options).forEach(option => { Array.from(node.options).forEach((option) => {
if (option.selected) return; if (option.selected) return;
formData.set(inputName, option.value); formData.set(inputName, option.value);
const formDataOrdered = forceFormDataInOrder(node.form, formData); const formDataOrdered = forceFormDataInOrder(node.form, formData);
sendGetRequest(url, node.form, formDataOrdered) sendGetRequest(url, node.form, formDataOrdered);
}); });
return return;
} }
// radio and checkbox // radio and checkbox
const inputType = node.getAttribute("type") || node.control.getAttribute("type"); const inputType =
node.getAttribute("type") || node.control.getAttribute("type");
const nodeValue = node.value || node.control?.value; const nodeValue = node.value || node.control?.value;
if (inputType === 'radio') { if (inputType === "radio") {
formData.set(inputName, nodeValue); formData.set(inputName, nodeValue);
} else if (inputType === 'checkbox'){ } else if (inputType === "checkbox") {
const inputValues = formData.getAll(inputName); const inputValues = formData.getAll(inputName);
if (inputValues.includes(nodeValue)) { if (inputValues.includes(nodeValue)) {
formData[inputName] = inputValues.filter(value => value !== nodeValue); formData[inputName] = inputValues.filter(
(value) => value !== nodeValue,
);
} else { } else {
formData.append(inputName, nodeValue); formData.append(inputName, nodeValue);
} }
} }
const formDataOrdered = forceFormDataInOrder(node.form, formData); const formDataOrdered = forceFormDataInOrder(node.form, formData);
sendGetRequest(url, node.form, formDataOrdered) sendGetRequest(url, node.form, formDataOrdered);
return return;
} }
} }
@ -257,15 +279,16 @@
function forceFormDataInOrder(form, formData) { function forceFormDataInOrder(form, formData) {
const formElements = form.elements; const formElements = form.elements;
const orderedFormData = new FormData(); const orderedFormData = new FormData();
for(let i = 0; i < formElements.length; i++) { for (let i = 0; i < formElements.length; i++) {
const element = formElements.item(i); const element = formElements.item(i);
if (formData.has(element.name) && element.tagName === 'SELECT') { if (formData.has(element.name) && element.tagName === "SELECT") {
orderedFormData.append( orderedFormData.append(element.name, formData.get(element.name));
element.name, formData.get(element.name));
continue; continue;
} }
if (formData.has(element.name) && formData.getAll(element.name) if (
.includes(element.value)) { formData.has(element.name) &&
formData.getAll(element.name).includes(element.value)
) {
orderedFormData.append(element.name, element.value); orderedFormData.append(element.name, element.value);
} }
} }
@ -285,10 +308,10 @@
* @param {FormData=} formData * @param {FormData=} formData
*/ */
function sendHxGetRequest(url, sourceNode, formData = undefined) { function sendHxGetRequest(url, sourceNode, formData = undefined) {
htmx.ajax('GET', url, { htmx.ajax("GET", url, {
source: sourceNode, source: sourceNode,
values: formData, values: formData,
headers: {"HX-Preloaded": "true"} headers: { "HX-Preloaded": "true" },
}); });
} }
@ -299,14 +322,16 @@
* @param {FormData=} formData * @param {FormData=} formData
*/ */
function sendXmlGetRequest(url, sourceNode, formData = undefined) { function sendXmlGetRequest(url, sourceNode, formData = undefined) {
const xhr = new XMLHttpRequest() const xhr = new XMLHttpRequest();
if (formData) { if (formData) {
url += '?' + new URLSearchParams(formData.entries()).toString() url += "?" + new URLSearchParams(formData.entries()).toString();
} }
xhr.open('GET', url); xhr.open("GET", url);
xhr.setRequestHeader("HX-Preloaded", "true") xhr.setRequestHeader("HX-Preloaded", "true");
xhr.onload = function() { processResponse(sourceNode, xhr.responseText) } xhr.onload = function () {
xhr.send() processResponse(sourceNode, xhr.responseText);
};
xhr.send();
} }
/** /**
@ -318,11 +343,11 @@
* @param {string} responseText * @param {string} responseText
*/ */
function processResponse(node, responseText) { function processResponse(node, responseText) {
node.preloadState = node.preloadAlways ? 'READY' : 'DONE' node.preloadState = node.preloadAlways ? "READY" : "DONE";
if (getClosestAttribute(node, 'preload-images') === 'true') { if (getClosestAttribute(node, "preload-images") === "true") {
// Load linked resources // Load linked resources
document.createElement('div').innerHTML = responseText document.createElement("div").innerHTML = responseText;
} }
} }
@ -333,10 +358,14 @@
* @returns { string | undefined } * @returns { string | undefined }
*/ */
function getClosestAttribute(node, attribute) { function getClosestAttribute(node, attribute) {
if (node == undefined) { return undefined } if (node == undefined) {
return node.getAttribute(attribute) return undefined;
|| node.getAttribute('data-' + attribute) }
|| getClosestAttribute(node.parentElement, attribute) return (
node.getAttribute(attribute) ||
node.getAttribute("data-" + attribute) ||
getClosestAttribute(node.parentElement, attribute)
);
} }
/** /**
@ -348,24 +377,25 @@
function isValidNodeForPreloading(node) { function isValidNodeForPreloading(node) {
// Add listeners only to nodes which include "GET" transactions // Add listeners only to nodes which include "GET" transactions
// or preloadable "GET" form elements // or preloadable "GET" form elements
const getReqAttrs = ['href', 'hx-get', 'data-hx-get']; const getReqAttrs = ["href", "hx-get", "data-hx-get"];
const includesGetRequest = node => getReqAttrs.some(a => node.hasAttribute(a)) const includesGetRequest = (node) =>
|| node.method === 'get'; getReqAttrs.some((a) => node.hasAttribute(a)) || node.method === "get";
const isPreloadableGetFormElement = node.form instanceof HTMLFormElement const isPreloadableGetFormElement =
&& includesGetRequest(node.form) node.form instanceof HTMLFormElement &&
&& isPreloadableFormElement(node) includesGetRequest(node.form) &&
isPreloadableFormElement(node);
if (!includesGetRequest(node) && !isPreloadableGetFormElement) { if (!includesGetRequest(node) && !isPreloadableGetFormElement) {
return false return false;
} }
// Don't preload <input> elements contained in <label> // Don't preload <input> elements contained in <label>
// to prevent sending two requests. Interaction on <input> in a // to prevent sending two requests. Interaction on <input> in a
// <label><input></input></label> situation activates <label> too. // <label><input></input></label> situation activates <label> too.
if (node instanceof HTMLInputElement && node.closest('label')) { if (node instanceof HTMLInputElement && node.closest("label")) {
return false return false;
} }
return true return true;
} }
/** /**
@ -377,12 +407,12 @@
*/ */
function isPreloadableFormElement(node) { function isPreloadableFormElement(node) {
if (node instanceof HTMLInputElement || node instanceof HTMLButtonElement) { if (node instanceof HTMLInputElement || node instanceof HTMLButtonElement) {
const type = node.getAttribute('type'); const type = node.getAttribute("type");
return ['checkbox', 'radio', 'submit'].includes(type); return ["checkbox", "radio", "submit"].includes(type);
} }
if (node instanceof HTMLLabelElement) { if (node instanceof HTMLLabelElement) {
return node.control && isPreloadableFormElement(node.control); return node.control && isPreloadableFormElement(node.control);
} }
return node instanceof HTMLSelectElement; return node instanceof HTMLSelectElement;
} }
})() })();

406
public/js/htmx.ws.js vendored
View file

@ -4,28 +4,27 @@ WebSockets Extension
This extension adds support for WebSockets to htmx. See /www/extensions/ws.md for usage instructions. This extension adds support for WebSockets to htmx. See /www/extensions/ws.md for usage instructions.
*/ */
(function() { (function () {
/** @type {import("../htmx").HtmxInternalApi} */ /** @type {import("../htmx").HtmxInternalApi} */
var api var api;
htmx.defineExtension('ws', {
htmx.defineExtension("ws", {
/** /**
* init is called once, when this extension is first registered. * init is called once, when this extension is first registered.
* @param {import("../htmx").HtmxInternalApi} apiRef * @param {import("../htmx").HtmxInternalApi} apiRef
*/ */
init: function(apiRef) { init: function (apiRef) {
// Store reference to internal API // Store reference to internal API
api = apiRef api = apiRef;
// Default function for creating new EventSource objects // Default function for creating new EventSource objects
if (!htmx.createWebSocket) { if (!htmx.createWebSocket) {
htmx.createWebSocket = createWebSocket htmx.createWebSocket = createWebSocket;
} }
// Default setting for reconnect delay // Default setting for reconnect delay
if (!htmx.config.wsReconnectDelay) { if (!htmx.config.wsReconnectDelay) {
htmx.config.wsReconnectDelay = 'full-jitter' htmx.config.wsReconnectDelay = "full-jitter";
} }
}, },
@ -35,44 +34,48 @@ This extension adds support for WebSockets to htmx. See /www/extensions/ws.md f
* @param {string} name * @param {string} name
* @param {Event} evt * @param {Event} evt
*/ */
onEvent: function(name, evt) { onEvent: function (name, evt) {
var parent = evt.target || evt.detail.elt var parent = evt.target || evt.detail.elt;
switch (name) { switch (name) {
// Try to close the socket when elements are removed // Try to close the socket when elements are removed
case 'htmx:beforeCleanupElement': case "htmx:beforeCleanupElement":
var internalData = api.getInternalData(parent);
var internalData = api.getInternalData(parent)
if (internalData.webSocket) { if (internalData.webSocket) {
internalData.webSocket.close() internalData.webSocket.close();
} }
return return;
// Try to create websockets when elements are processed // Try to create websockets when elements are processed
case 'htmx:beforeProcessNode': case "htmx:beforeProcessNode":
forEach(
forEach(queryAttributeOnThisOrChildren(parent, 'ws-connect'), function(child) { queryAttributeOnThisOrChildren(parent, "ws-connect"),
ensureWebSocket(child) function (child) {
}) ensureWebSocket(child);
forEach(queryAttributeOnThisOrChildren(parent, 'ws-send'), function(child) { },
ensureWebSocketSend(child) );
}) forEach(
queryAttributeOnThisOrChildren(parent, "ws-send"),
function (child) {
ensureWebSocketSend(child);
},
);
} }
} },
}) });
function splitOnWhitespace(trigger) { function splitOnWhitespace(trigger) {
return trigger.trim().split(/\s+/) return trigger.trim().split(/\s+/);
} }
function getLegacyWebsocketURL(elt) { function getLegacyWebsocketURL(elt) {
var legacySSEValue = api.getAttributeValue(elt, 'hx-ws') var legacySSEValue = api.getAttributeValue(elt, "hx-ws");
if (legacySSEValue) { if (legacySSEValue) {
var values = splitOnWhitespace(legacySSEValue) var values = splitOnWhitespace(legacySSEValue);
for (var i = 0; i < values.length; i++) { for (var i = 0; i < values.length; i++) {
var value = values[i].split(/:(.+)/) var value = values[i].split(/:(.+)/);
if (value[0] === 'connect') { if (value[0] === "connect") {
return value[1] return value[1];
} }
} }
} }
@ -88,68 +91,78 @@ This extension adds support for WebSockets to htmx. See /www/extensions/ws.md f
// If the element containing the WebSocket connection no longer exists, then // If the element containing the WebSocket connection no longer exists, then
// do not connect/reconnect the WebSocket. // do not connect/reconnect the WebSocket.
if (!api.bodyContains(socketElt)) { if (!api.bodyContains(socketElt)) {
return return;
} }
// Get the source straight from the element's value // Get the source straight from the element's value
var wssSource = api.getAttributeValue(socketElt, 'ws-connect') var wssSource = api.getAttributeValue(socketElt, "ws-connect");
if (wssSource == null || wssSource === '') { if (wssSource == null || wssSource === "") {
var legacySource = getLegacyWebsocketURL(socketElt) var legacySource = getLegacyWebsocketURL(socketElt);
if (legacySource == null) { if (legacySource == null) {
return return;
} else { } else {
wssSource = legacySource wssSource = legacySource;
} }
} }
// Guarantee that the wssSource value is a fully qualified URL // Guarantee that the wssSource value is a fully qualified URL
if (wssSource.indexOf('/') === 0) { if (wssSource.indexOf("/") === 0) {
var base_part = location.hostname + (location.port ? ':' + location.port : '') var base_part =
if (location.protocol === 'https:') { location.hostname + (location.port ? ":" + location.port : "");
wssSource = 'wss://' + base_part + wssSource if (location.protocol === "https:") {
} else if (location.protocol === 'http:') { wssSource = "wss://" + base_part + wssSource;
wssSource = 'ws://' + base_part + wssSource } else if (location.protocol === "http:") {
wssSource = "ws://" + base_part + wssSource;
} }
} }
var socketWrapper = createWebsocketWrapper(socketElt, function() { var socketWrapper = createWebsocketWrapper(socketElt, function () {
return htmx.createWebSocket(wssSource) return htmx.createWebSocket(wssSource);
}) });
socketWrapper.addEventListener('message', function(event) { socketWrapper.addEventListener("message", function (event) {
if (maybeCloseWebSocketSource(socketElt)) { if (maybeCloseWebSocketSource(socketElt)) {
return return;
} }
var response = event.data var response = event.data;
if (!api.triggerEvent(socketElt, 'htmx:wsBeforeMessage', { if (
!api.triggerEvent(socketElt, "htmx:wsBeforeMessage", {
message: response, message: response,
socketWrapper: socketWrapper.publicInterface socketWrapper: socketWrapper.publicInterface,
})) { })
return ) {
return;
} }
api.withExtensions(socketElt, function(extension) { api.withExtensions(socketElt, function (extension) {
response = extension.transformResponse(response, null, socketElt) response = extension.transformResponse(response, null, socketElt);
}) });
var settleInfo = api.makeSettleInfo(socketElt) var settleInfo = api.makeSettleInfo(socketElt);
var fragment = api.makeFragment(response) var fragment = api.makeFragment(response);
if (fragment.children.length) { if (fragment.children.length) {
var children = Array.from(fragment.children) var children = Array.from(fragment.children);
for (var i = 0; i < children.length; i++) { for (var i = 0; i < children.length; i++) {
api.oobSwap(api.getAttributeValue(children[i], 'hx-swap-oob') || 'true', children[i], settleInfo) api.oobSwap(
api.getAttributeValue(children[i], "hx-swap-oob") || "true",
children[i],
settleInfo,
);
} }
} }
api.settleImmediately(settleInfo.tasks) api.settleImmediately(settleInfo.tasks);
api.triggerEvent(socketElt, 'htmx:wsAfterMessage', { message: response, socketWrapper: socketWrapper.publicInterface }) api.triggerEvent(socketElt, "htmx:wsAfterMessage", {
}) message: response,
socketWrapper: socketWrapper.publicInterface,
});
});
// Put the WebSocket into the HTML Element's custom data. // Put the WebSocket into the HTML Element's custom data.
api.getInternalData(socketElt).webSocket = socketWrapper api.getInternalData(socketElt).webSocket = socketWrapper;
} }
/** /**
@ -179,120 +192,138 @@ This extension adds support for WebSockets to htmx. See /www/extensions/ws.md f
/** @type {Object<string, Function[]>} */ /** @type {Object<string, Function[]>} */
events: {}, events: {},
addEventListener: function(event, handler) { addEventListener: function (event, handler) {
if (this.socket) { if (this.socket) {
this.socket.addEventListener(event, handler) this.socket.addEventListener(event, handler);
} }
if (!this.events[event]) { if (!this.events[event]) {
this.events[event] = [] this.events[event] = [];
} }
this.events[event].push(handler) this.events[event].push(handler);
}, },
sendImmediately: function(message, sendElt) { sendImmediately: function (message, sendElt) {
if (!this.socket) { if (!this.socket) {
api.triggerErrorEvent() api.triggerErrorEvent();
} }
if (!sendElt || api.triggerEvent(sendElt, 'htmx:wsBeforeSend', { if (
!sendElt ||
api.triggerEvent(sendElt, "htmx:wsBeforeSend", {
message, message,
socketWrapper: this.publicInterface socketWrapper: this.publicInterface,
})) {
this.socket.send(message)
sendElt && api.triggerEvent(sendElt, 'htmx:wsAfterSend', {
message,
socketWrapper: this.publicInterface
}) })
) {
this.socket.send(message);
sendElt &&
api.triggerEvent(sendElt, "htmx:wsAfterSend", {
message,
socketWrapper: this.publicInterface,
});
} }
}, },
send: function(message, sendElt) { send: function (message, sendElt) {
if (this.socket.readyState !== this.socket.OPEN) { if (this.socket.readyState !== this.socket.OPEN) {
this.messageQueue.push({ message, sendElt }) this.messageQueue.push({ message, sendElt });
} else { } else {
this.sendImmediately(message, sendElt) this.sendImmediately(message, sendElt);
} }
}, },
handleQueuedMessages: function() { handleQueuedMessages: function () {
while (this.messageQueue.length > 0) { while (this.messageQueue.length > 0) {
var queuedItem = this.messageQueue[0] var queuedItem = this.messageQueue[0];
if (this.socket.readyState === this.socket.OPEN) { if (this.socket.readyState === this.socket.OPEN) {
this.sendImmediately(queuedItem.message, queuedItem.sendElt) this.sendImmediately(queuedItem.message, queuedItem.sendElt);
this.messageQueue.shift() this.messageQueue.shift();
} else { } else {
break break;
} }
} }
}, },
init: function() { init: function () {
if (this.socket && this.socket.readyState === this.socket.OPEN) { if (this.socket && this.socket.readyState === this.socket.OPEN) {
// Close discarded socket // Close discarded socket
this.socket.close() this.socket.close();
} }
// Create a new WebSocket and event handlers // Create a new WebSocket and event handlers
/** @type {WebSocket} */ /** @type {WebSocket} */
var socket = socketFunc() var socket = socketFunc();
// The event.type detail is added for interface conformance with the // The event.type detail is added for interface conformance with the
// other two lifecycle events (open and close) so a single handler method // other two lifecycle events (open and close) so a single handler method
// can handle them polymorphically, if required. // can handle them polymorphically, if required.
api.triggerEvent(socketElt, 'htmx:wsConnecting', { event: { type: 'connecting' } }) api.triggerEvent(socketElt, "htmx:wsConnecting", {
event: { type: "connecting" },
});
this.socket = socket this.socket = socket;
socket.onopen = function(e) { socket.onopen = function (e) {
wrapper.retryCount = 0 wrapper.retryCount = 0;
api.triggerEvent(socketElt, 'htmx:wsOpen', { event: e, socketWrapper: wrapper.publicInterface }) api.triggerEvent(socketElt, "htmx:wsOpen", {
wrapper.handleQueuedMessages() event: e,
} socketWrapper: wrapper.publicInterface,
});
wrapper.handleQueuedMessages();
};
socket.onclose = function(e) { socket.onclose = function (e) {
// If socket should not be connected, stop further attempts to establish connection // 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 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) { if (
var delay = getWebSocketReconnectDelay(wrapper.retryCount) !maybeCloseWebSocketSource(socketElt) &&
setTimeout(function() { [1006, 1012, 1013].indexOf(e.code) >= 0
wrapper.retryCount += 1 ) {
wrapper.init() var delay = getWebSocketReconnectDelay(wrapper.retryCount);
}, delay) setTimeout(function () {
wrapper.retryCount += 1;
wrapper.init();
}, delay);
} }
// Notify client code that connection has been closed. Client code can inspect `event` field // Notify client code that connection has been closed. Client code can inspect `event` field
// to determine whether closure has been valid or abnormal // to determine whether closure has been valid or abnormal
api.triggerEvent(socketElt, 'htmx:wsClose', { event: e, socketWrapper: wrapper.publicInterface }) api.triggerEvent(socketElt, "htmx:wsClose", {
} event: e,
socketWrapper: wrapper.publicInterface,
});
};
socket.onerror = function(e) { socket.onerror = function (e) {
api.triggerErrorEvent(socketElt, 'htmx:wsError', { error: e, socketWrapper: wrapper }) api.triggerErrorEvent(socketElt, "htmx:wsError", {
maybeCloseWebSocketSource(socketElt) error: e,
} socketWrapper: wrapper,
});
maybeCloseWebSocketSource(socketElt);
};
var events = this.events var events = this.events;
Object.keys(events).forEach(function(k) { Object.keys(events).forEach(function (k) {
events[k].forEach(function(e) { events[k].forEach(function (e) {
socket.addEventListener(k, e) socket.addEventListener(k, e);
}) });
}) });
}, },
close: function() { close: function () {
this.socket.close() this.socket.close();
} },
} };
wrapper.init() wrapper.init();
wrapper.publicInterface = { wrapper.publicInterface = {
send: wrapper.send.bind(wrapper), send: wrapper.send.bind(wrapper),
sendImmediately: wrapper.sendImmediately.bind(wrapper), sendImmediately: wrapper.sendImmediately.bind(wrapper),
queue: wrapper.messageQueue queue: wrapper.messageQueue,
} };
return wrapper return wrapper;
} }
/** /**
@ -301,13 +332,13 @@ This extension adds support for WebSockets to htmx. See /www/extensions/ws.md f
* @param {HTMLElement} elt * @param {HTMLElement} elt
*/ */
function ensureWebSocketSend(elt) { function ensureWebSocketSend(elt) {
var legacyAttribute = api.getAttributeValue(elt, 'hx-ws') var legacyAttribute = api.getAttributeValue(elt, "hx-ws");
if (legacyAttribute && legacyAttribute !== 'send') { if (legacyAttribute && legacyAttribute !== "send") {
return return;
} }
var webSocketParent = api.getClosestMatch(elt, hasWebSocket) var webSocketParent = api.getClosestMatch(elt, hasWebSocket);
processWebSocketSend(webSocketParent, elt) processWebSocketSend(webSocketParent, elt);
} }
/** /**
@ -316,7 +347,7 @@ This extension adds support for WebSockets to htmx. See /www/extensions/ws.md f
* @returns {boolean} * @returns {boolean}
*/ */
function hasWebSocket(node) { function hasWebSocket(node) {
return api.getInternalData(node).webSocket != null return api.getInternalData(node).webSocket != null;
} }
/** /**
@ -326,23 +357,23 @@ This extension adds support for WebSockets to htmx. See /www/extensions/ws.md f
* @param {HTMLElement} sendElt * @param {HTMLElement} sendElt
*/ */
function processWebSocketSend(socketElt, sendElt) { function processWebSocketSend(socketElt, sendElt) {
var nodeData = api.getInternalData(sendElt) var nodeData = api.getInternalData(sendElt);
var triggerSpecs = api.getTriggerSpecs(sendElt) var triggerSpecs = api.getTriggerSpecs(sendElt);
triggerSpecs.forEach(function(ts) { triggerSpecs.forEach(function (ts) {
api.addTriggerHandler(sendElt, ts, nodeData, function(elt, evt) { api.addTriggerHandler(sendElt, ts, nodeData, function (elt, evt) {
if (maybeCloseWebSocketSource(socketElt)) { if (maybeCloseWebSocketSource(socketElt)) {
return return;
} }
/** @type {WebSocketWrapper} */ /** @type {WebSocketWrapper} */
var socketWrapper = api.getInternalData(socketElt).webSocket var socketWrapper = api.getInternalData(socketElt).webSocket;
var headers = api.getHeaders(sendElt, api.getTarget(sendElt)) var headers = api.getHeaders(sendElt, api.getTarget(sendElt));
var results = api.getInputValues(sendElt, 'post') var results = api.getInputValues(sendElt, "post");
var errors = results.errors var errors = results.errors;
var rawParameters = Object.assign({}, results.values) var rawParameters = Object.assign({}, results.values);
var expressionVars = api.getExpressionVars(sendElt) var expressionVars = api.getExpressionVars(sendElt);
var allParameters = api.mergeObjects(rawParameters, expressionVars) var allParameters = api.mergeObjects(rawParameters, expressionVars);
var filteredParameters = api.filterValues(allParameters, sendElt) var filteredParameters = api.filterValues(allParameters, sendElt);
var sendConfig = { var sendConfig = {
parameters: filteredParameters, parameters: filteredParameters,
@ -352,32 +383,34 @@ This extension adds support for WebSockets to htmx. See /www/extensions/ws.md f
triggeringEvent: evt, triggeringEvent: evt,
messageBody: undefined, messageBody: undefined,
socketWrapper: socketWrapper.publicInterface socketWrapper: socketWrapper.publicInterface,
} };
if (!api.triggerEvent(elt, 'htmx:wsConfigSend', sendConfig)) { if (!api.triggerEvent(elt, "htmx:wsConfigSend", sendConfig)) {
return return;
} }
if (errors && errors.length > 0) { if (errors && errors.length > 0) {
api.triggerEvent(elt, 'htmx:validation:halted', errors) api.triggerEvent(elt, "htmx:validation:halted", errors);
return return;
} }
var body = sendConfig.messageBody var body = sendConfig.messageBody;
if (body === undefined) { if (body === undefined) {
var toSend = Object.assign({}, sendConfig.parameters) var toSend = Object.assign({}, sendConfig.parameters);
if (sendConfig.headers) { toSend.HEADERS = headers } if (sendConfig.headers) {
body = JSON.stringify(toSend) toSend.HEADERS = headers;
}
body = JSON.stringify(toSend);
} }
socketWrapper.send(body, elt) socketWrapper.send(body, elt);
if (evt && api.shouldCancel(evt, elt)) { if (evt && api.shouldCancel(evt, elt)) {
evt.preventDefault() evt.preventDefault();
} }
}) });
}) });
} }
/** /**
@ -387,17 +420,19 @@ This extension adds support for WebSockets to htmx. See /www/extensions/ws.md f
*/ */
function getWebSocketReconnectDelay(retryCount) { function getWebSocketReconnectDelay(retryCount) {
/** @type {"full-jitter" | ((retryCount:number) => number)} */ /** @type {"full-jitter" | ((retryCount:number) => number)} */
var delay = htmx.config.wsReconnectDelay var delay = htmx.config.wsReconnectDelay;
if (typeof delay === 'function') { if (typeof delay === "function") {
return delay(retryCount) return delay(retryCount);
} }
if (delay === 'full-jitter') { if (delay === "full-jitter") {
var exp = Math.min(retryCount, 6) var exp = Math.min(retryCount, 6);
var maxDelay = 1000 * Math.pow(2, exp) var maxDelay = 1000 * Math.pow(2, exp);
return maxDelay * Math.random() return maxDelay * Math.random();
} }
logError('htmx.config.wsReconnectDelay must either be a function or the string "full-jitter"') logError(
'htmx.config.wsReconnectDelay must either be a function or the string "full-jitter"',
);
} }
/** /**
@ -411,14 +446,14 @@ This extension adds support for WebSockets to htmx. See /www/extensions/ws.md f
*/ */
function maybeCloseWebSocketSource(elt) { function maybeCloseWebSocketSource(elt) {
if (!api.bodyContains(elt)) { if (!api.bodyContains(elt)) {
var internalData = api.getInternalData(elt) var internalData = api.getInternalData(elt);
if (internalData.webSocket) { if (internalData.webSocket) {
internalData.webSocket.close() internalData.webSocket.close();
return true return true;
} }
return false return false;
} }
return false return false;
} }
/** /**
@ -429,9 +464,9 @@ This extension adds support for WebSockets to htmx. See /www/extensions/ws.md f
* @returns WebSocket * @returns WebSocket
*/ */
function createWebSocket(url) { function createWebSocket(url) {
var sock = new WebSocket(url, []) var sock = new WebSocket(url, []);
sock.binaryType = htmx.config.wsBinaryType sock.binaryType = htmx.config.wsBinaryType;
return sock return sock;
} }
/** /**
@ -441,19 +476,30 @@ This extension adds support for WebSockets to htmx. See /www/extensions/ws.md f
* @param {string} attributeName * @param {string} attributeName
*/ */
function queryAttributeOnThisOrChildren(elt, attributeName) { function queryAttributeOnThisOrChildren(elt, attributeName) {
var result = [] var result = [];
// If the parent element also contains the requested attribute, then add it to the results too. // 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')) { if (
result.push(elt) api.hasAttribute(elt, attributeName) ||
api.hasAttribute(elt, "hx-ws")
) {
result.push(elt);
} }
// Search all child nodes that match the requested attribute // Search all child nodes that match the requested attribute
elt.querySelectorAll('[' + attributeName + '], [data-' + attributeName + '], [data-hx-ws], [hx-ws]').forEach(function(node) { elt
result.push(node) .querySelectorAll(
}) "[" +
attributeName +
"], [data-" +
attributeName +
"], [data-hx-ws], [hx-ws]",
)
.forEach(function (node) {
result.push(node);
});
return result return result;
} }
/** /**
@ -464,8 +510,8 @@ This extension adds support for WebSockets to htmx. See /www/extensions/ws.md f
function forEach(arr, func) { function forEach(arr, func) {
if (arr) { if (arr) {
for (var i = 0; i < arr.length; i++) { for (var i = 0; i < arr.length; i++) {
func(arr[i]) func(arr[i]);
} }
} }
} }
})() })();

File diff suppressed because one or more lines are too long