Deno re-write
This commit is contained in:
File diff suppressed because it is too large
Load Diff
Binary file not shown.
Before Width: | Height: | Size: 15 KiB |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Before Width: | Height: | Size: 145 KiB |
File diff suppressed because one or more lines are too long
@ -1,418 +0,0 @@
|
||||
(function () {
|
||||
/**
|
||||
* This adds the "preload" extension to htmx. The extension will
|
||||
* preload the targets of elements with "preload" attribute if:
|
||||
* - they also have `href`, `hx-get` or `data-hx-get` attributes
|
||||
* - they are radio buttons, checkboxes, select elements and submit
|
||||
* buttons of forms with `method="get"` or `hx-get` attributes
|
||||
* The extension relies on browser cache and for it to work
|
||||
* server response must include `Cache-Control` header
|
||||
* e.g. `Cache-Control: private, max-age=60`.
|
||||
* For more details @see https://htmx.org/extensions/preload/
|
||||
*/
|
||||
|
||||
htmx.defineExtension("preload", {
|
||||
onEvent: function (name, event) {
|
||||
// Process preload attributes on `htmx:afterProcessNode`
|
||||
if (name === "htmx:afterProcessNode") {
|
||||
// Initialize all nodes with `preload` attribute
|
||||
const parent = event.target || event.detail.elt;
|
||||
const preloadNodes = [
|
||||
...(parent.hasAttribute("preload") ? [parent] : []),
|
||||
...parent.querySelectorAll("[preload]"),
|
||||
];
|
||||
preloadNodes.forEach(function (node) {
|
||||
// Initialize the node with the `preload` attribute
|
||||
init(node);
|
||||
|
||||
// Initialize all child elements which has
|
||||
// `href`, `hx-get` or `data-hx-get` attributes
|
||||
node.querySelectorAll("[href],[hx-get],[data-hx-get]").forEach(init);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Intercept HTMX preload requests on `htmx:beforeRequest` and
|
||||
// send them as XHR requests instead to avoid side-effects,
|
||||
// such as showing loading indicators while preloading data.
|
||||
if (name === "htmx:beforeRequest") {
|
||||
const requestHeaders = event.detail.requestConfig.headers;
|
||||
if (
|
||||
!(
|
||||
"HX-Preloaded" in requestHeaders &&
|
||||
requestHeaders["HX-Preloaded"] === "true"
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
// Reuse XHR created by HTMX with replaced callbacks
|
||||
const xhr = event.detail.xhr;
|
||||
xhr.onload = function () {
|
||||
processResponse(event.detail.elt, xhr.responseText);
|
||||
};
|
||||
xhr.onerror = null;
|
||||
xhr.onabort = null;
|
||||
xhr.ontimeout = null;
|
||||
xhr.send();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Initialize `node`, set up event handlers based on own or inherited
|
||||
* `preload` attributes and set `node.preloadState` to `READY`.
|
||||
*
|
||||
* `node.preloadState` can have these values:
|
||||
* - `READY` - event handlers have been set up and node is ready to preload
|
||||
* - `TIMEOUT` - a triggering event has been fired, but `node` is not
|
||||
* yet being loaded because some time need to pass first e.g. user
|
||||
* has to keep hovering over an element for 100ms for preload to start
|
||||
* - `LOADING` means that `node` is in the process of being preloaded
|
||||
* - `DONE` means that the preloading process is complete and `node`
|
||||
* doesn't need a repeated preload (indicated by preload="always")
|
||||
* @param {Node} node
|
||||
*/
|
||||
function init(node) {
|
||||
// Guarantee that each node is initialized only once
|
||||
if (node.preloadState !== undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isValidNodeForPreloading(node)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Initialize form element preloading
|
||||
if (node instanceof HTMLFormElement) {
|
||||
const form = node;
|
||||
// Only initialize forms with `method="get"` or `hx-get` attributes
|
||||
if (
|
||||
!(
|
||||
(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++) {
|
||||
const element = form.elements.item(i);
|
||||
init(element);
|
||||
element.labels.forEach(init);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Process node configuration from preload attribute
|
||||
let preloadAttr = getClosestAttribute(node, "preload");
|
||||
node.preloadAlways = preloadAttr && preloadAttr.includes("always");
|
||||
if (node.preloadAlways) {
|
||||
preloadAttr = preloadAttr.replace("always", "").trim();
|
||||
}
|
||||
let triggerEventName = preloadAttr || "mousedown";
|
||||
|
||||
// Set up event handlers listening for triggering events
|
||||
const needsTimeout = triggerEventName === "mouseover";
|
||||
node.addEventListener(
|
||||
triggerEventName,
|
||||
getEventHandler(node, needsTimeout),
|
||||
);
|
||||
|
||||
// Add `touchstart` listener for touchscreen support
|
||||
// if `mousedown` or `mouseover` is used
|
||||
if (triggerEventName === "mousedown" || triggerEventName === "mouseover") {
|
||||
node.addEventListener("touchstart", getEventHandler(node));
|
||||
}
|
||||
|
||||
// If `mouseover` is used, set up `mouseout` listener,
|
||||
// which will abort preloading if user moves mouse outside
|
||||
// the element in less than 100ms after hovering over it
|
||||
if (triggerEventName === "mouseover") {
|
||||
node.addEventListener("mouseout", function (evt) {
|
||||
if (evt.target === node && node.preloadState === "TIMEOUT") {
|
||||
node.preloadState = "READY";
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Mark the node as ready to be preloaded
|
||||
node.preloadState = "READY";
|
||||
|
||||
// This event can be used to load content immediately
|
||||
htmx.trigger(node, "preload:init");
|
||||
}
|
||||
|
||||
/**
|
||||
* Return event handler which can be called by event listener to start
|
||||
* the preloading process of `node` with or without a timeout
|
||||
* @param {Node} node
|
||||
* @param {boolean=} needsTimeout
|
||||
* @returns {function(): void}
|
||||
*/
|
||||
function getEventHandler(node, needsTimeout = false) {
|
||||
return function () {
|
||||
// Do not preload uninitialized nodes, nodes which are in process
|
||||
// of being preloaded or have been preloaded and don't need repeat
|
||||
if (node.preloadState !== "READY") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (needsTimeout) {
|
||||
node.preloadState = "TIMEOUT";
|
||||
const timeoutMs = 100;
|
||||
window.setTimeout(function () {
|
||||
if (node.preloadState === "TIMEOUT") {
|
||||
node.preloadState = "READY";
|
||||
load(node);
|
||||
}
|
||||
}, timeoutMs);
|
||||
return;
|
||||
}
|
||||
|
||||
load(node);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Preload the target of node, which can be:
|
||||
* - hx-get or data-hx-get attribute
|
||||
* - href or form action attribute
|
||||
* @param {Node} node
|
||||
*/
|
||||
function load(node) {
|
||||
// Do not preload uninitialized nodes, nodes which are in process
|
||||
// of being preloaded or have been preloaded and don't need repeat
|
||||
if (node.preloadState !== "READY") {
|
||||
return;
|
||||
}
|
||||
node.preloadState = "LOADING";
|
||||
|
||||
// Load nodes with `hx-get` or `data-hx-get` attribute
|
||||
// Forms don't reach this because only their elements are initialized
|
||||
const hxGet =
|
||||
node.getAttribute("hx-get") || node.getAttribute("data-hx-get");
|
||||
if (hxGet) {
|
||||
sendHxGetRequest(hxGet, node);
|
||||
return;
|
||||
}
|
||||
|
||||
// Load nodes with `href` attribute
|
||||
const hxBoost = getClosestAttribute(node, "hx-boost") === "true";
|
||||
if (node.hasAttribute("href")) {
|
||||
const url = node.getAttribute("href");
|
||||
if (hxBoost) {
|
||||
sendHxGetRequest(url, node);
|
||||
} else {
|
||||
sendXmlGetRequest(url, node);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Load form elements
|
||||
if (isPreloadableFormElement(node)) {
|
||||
const url =
|
||||
node.form.getAttribute("action") ||
|
||||
node.form.getAttribute("hx-get") ||
|
||||
node.form.getAttribute("data-hx-get");
|
||||
const formData = htmx.values(node.form);
|
||||
const isStandardForm = !(
|
||||
node.form.getAttribute("hx-get") ||
|
||||
node.form.getAttribute("data-hx-get") ||
|
||||
hxBoost
|
||||
);
|
||||
const sendGetRequest = isStandardForm
|
||||
? sendXmlGetRequest
|
||||
: sendHxGetRequest;
|
||||
|
||||
// submit button
|
||||
if (node.type === "submit") {
|
||||
sendGetRequest(url, node.form, formData);
|
||||
return;
|
||||
}
|
||||
|
||||
// select
|
||||
const inputName = node.name || node.control.name;
|
||||
if (node.tagName === "SELECT") {
|
||||
Array.from(node.options).forEach((option) => {
|
||||
if (option.selected) return;
|
||||
formData.set(inputName, option.value);
|
||||
const formDataOrdered = forceFormDataInOrder(node.form, formData);
|
||||
sendGetRequest(url, node.form, formDataOrdered);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// radio and checkbox
|
||||
const inputType =
|
||||
node.getAttribute("type") || node.control.getAttribute("type");
|
||||
const nodeValue = node.value || node.control?.value;
|
||||
if (inputType === "radio") {
|
||||
formData.set(inputName, nodeValue);
|
||||
} else if (inputType === "checkbox") {
|
||||
const inputValues = formData.getAll(inputName);
|
||||
if (inputValues.includes(nodeValue)) {
|
||||
formData[inputName] = inputValues.filter(
|
||||
(value) => value !== nodeValue,
|
||||
);
|
||||
} else {
|
||||
formData.append(inputName, nodeValue);
|
||||
}
|
||||
}
|
||||
const formDataOrdered = forceFormDataInOrder(node.form, formData);
|
||||
sendGetRequest(url, node.form, formDataOrdered);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Force formData values to be in the order of form elements.
|
||||
* This is useful to apply after alternating formData values
|
||||
* and before passing them to a HTTP request because cache is
|
||||
* sensitive to GET parameter order e.g., cached `/link?a=1&b=2`
|
||||
* will not be used for `/link?b=2&a=1`.
|
||||
* @param {HTMLFormElement} form
|
||||
* @param {FormData} formData
|
||||
* @returns {FormData}
|
||||
*/
|
||||
function forceFormDataInOrder(form, formData) {
|
||||
const formElements = form.elements;
|
||||
const orderedFormData = new FormData();
|
||||
for (let i = 0; i < formElements.length; i++) {
|
||||
const element = formElements.item(i);
|
||||
if (formData.has(element.name) && element.tagName === "SELECT") {
|
||||
orderedFormData.append(element.name, formData.get(element.name));
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
formData.has(element.name) &&
|
||||
formData.getAll(element.name).includes(element.value)
|
||||
) {
|
||||
orderedFormData.append(element.name, element.value);
|
||||
}
|
||||
}
|
||||
return orderedFormData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send GET request with `hx-request` headers as if `sourceNode`
|
||||
* target was loaded. Send alternated values if `formData` is set.
|
||||
*
|
||||
* Note that this request is intercepted and sent as XMLHttpRequest.
|
||||
* It is necessary to use `htmx.ajax` to acquire correct headers which
|
||||
* HTMX and extensions add based on `sourceNode`. But it cannot be used
|
||||
* to perform the request due to side-effects e.g. loading indicators.
|
||||
* @param {string} url
|
||||
* @param {Node} sourceNode
|
||||
* @param {FormData=} formData
|
||||
*/
|
||||
function sendHxGetRequest(url, sourceNode, formData = undefined) {
|
||||
htmx.ajax("GET", url, {
|
||||
source: sourceNode,
|
||||
values: formData,
|
||||
headers: { "HX-Preloaded": "true" },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send XML GET request to `url`. Send `formData` as URL params if set.
|
||||
* @param {string} url
|
||||
* @param {Node} sourceNode
|
||||
* @param {FormData=} formData
|
||||
*/
|
||||
function sendXmlGetRequest(url, sourceNode, formData = undefined) {
|
||||
const xhr = new XMLHttpRequest();
|
||||
if (formData) {
|
||||
url += "?" + new URLSearchParams(formData.entries()).toString();
|
||||
}
|
||||
xhr.open("GET", url);
|
||||
xhr.setRequestHeader("HX-Preloaded", "true");
|
||||
xhr.onload = function () {
|
||||
processResponse(sourceNode, xhr.responseText);
|
||||
};
|
||||
xhr.send();
|
||||
}
|
||||
|
||||
/**
|
||||
* Process request response by marking node `DONE` to prevent repeated
|
||||
* requests, except if preload attribute contains `always`,
|
||||
* and load linked resources (e.g. images) returned in the response
|
||||
* if `preload-images` attribute is `true`
|
||||
* @param {Node} node
|
||||
* @param {string} responseText
|
||||
*/
|
||||
function processResponse(node, responseText) {
|
||||
node.preloadState = node.preloadAlways ? "READY" : "DONE";
|
||||
|
||||
if (getClosestAttribute(node, "preload-images") === "true") {
|
||||
// Load linked resources
|
||||
document.createElement("div").innerHTML = responseText;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets attribute value from node or one of its parents
|
||||
* @param {Node} node
|
||||
* @param {string} attribute
|
||||
* @returns { string | undefined }
|
||||
*/
|
||||
function getClosestAttribute(node, attribute) {
|
||||
if (node == undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return (
|
||||
node.getAttribute(attribute) ||
|
||||
node.getAttribute("data-" + attribute) ||
|
||||
getClosestAttribute(node.parentElement, attribute)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if node is valid for preloading and should be
|
||||
* initialized by setting up event listeners and handlers
|
||||
* @param {Node} node
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isValidNodeForPreloading(node) {
|
||||
// Add listeners only to nodes which include "GET" transactions
|
||||
// or preloadable "GET" form elements
|
||||
const getReqAttrs = ["href", "hx-get", "data-hx-get"];
|
||||
const includesGetRequest = (node) =>
|
||||
getReqAttrs.some((a) => node.hasAttribute(a)) || node.method === "get";
|
||||
const isPreloadableGetFormElement =
|
||||
node.form instanceof HTMLFormElement &&
|
||||
includesGetRequest(node.form) &&
|
||||
isPreloadableFormElement(node);
|
||||
if (!includesGetRequest(node) && !isPreloadableGetFormElement) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Don't preload <input> elements contained in <label>
|
||||
// to prevent sending two requests. Interaction on <input> in a
|
||||
// <label><input></input></label> situation activates <label> too.
|
||||
if (node instanceof HTMLInputElement && node.closest("label")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if node is a form element which can be preloaded,
|
||||
* i.e., `radio`, `checkbox`, `select` or `submit` button
|
||||
* or a `label` of a form element which can be preloaded.
|
||||
* @param {Node} node
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isPreloadableFormElement(node) {
|
||||
if (node instanceof HTMLInputElement || node instanceof HTMLButtonElement) {
|
||||
const type = node.getAttribute("type");
|
||||
return ["checkbox", "radio", "submit"].includes(type);
|
||||
}
|
||||
if (node instanceof HTMLLabelElement) {
|
||||
return node.control && isPreloadableFormElement(node.control);
|
||||
}
|
||||
return node instanceof HTMLSelectElement;
|
||||
}
|
||||
})();
|
@ -1,293 +0,0 @@
|
||||
/*
|
||||
Server Sent Events Extension
|
||||
============================
|
||||
This extension adds support for Server Sent Events to htmx. See /www/extensions/sse.md for usage instructions.
|
||||
|
||||
*/
|
||||
|
||||
(function () {
|
||||
/** @type {import("../htmx").HtmxInternalApi} */
|
||||
var api;
|
||||
|
||||
htmx.defineExtension("sse", {
|
||||
/**
|
||||
* Init saves the provided reference to the internal HTMX API.
|
||||
*
|
||||
* @param {import("../htmx").HtmxInternalApi} api
|
||||
* @returns void
|
||||
*/
|
||||
init: function (apiRef) {
|
||||
// store a reference to the internal API.
|
||||
api = apiRef;
|
||||
|
||||
// set a function in the public API for creating new EventSource objects
|
||||
if (htmx.createEventSource == undefined) {
|
||||
htmx.createEventSource = createEventSource;
|
||||
}
|
||||
},
|
||||
|
||||
getSelectors: function () {
|
||||
return [
|
||||
"[sse-connect]",
|
||||
"[data-sse-connect]",
|
||||
"[sse-swap]",
|
||||
"[data-sse-swap]",
|
||||
];
|
||||
},
|
||||
|
||||
/**
|
||||
* onEvent handles all events passed to this extension.
|
||||
*
|
||||
* @param {string} name
|
||||
* @param {Event} evt
|
||||
* @returns void
|
||||
*/
|
||||
onEvent: function (name, evt) {
|
||||
var parent = evt.target || evt.detail.elt;
|
||||
switch (name) {
|
||||
case "htmx:beforeCleanupElement":
|
||||
var internalData = api.getInternalData(parent);
|
||||
// Try to remove remove an EventSource when elements are removed
|
||||
var source = internalData.sseEventSource;
|
||||
if (source) {
|
||||
api.triggerEvent(parent, "htmx:sseClose", {
|
||||
source,
|
||||
type: "nodeReplaced",
|
||||
});
|
||||
internalData.sseEventSource.close();
|
||||
}
|
||||
|
||||
return;
|
||||
|
||||
// Try to create EventSources when elements are processed
|
||||
case "htmx:afterProcessNode":
|
||||
ensureEventSourceOnElement(parent);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
/// ////////////////////////////////////////////
|
||||
// HELPER FUNCTIONS
|
||||
/// ////////////////////////////////////////////
|
||||
|
||||
/**
|
||||
* createEventSource is the default method for creating new EventSource objects.
|
||||
* it is hoisted into htmx.config.createEventSource to be overridden by the user, if needed.
|
||||
*
|
||||
* @param {string} url
|
||||
* @returns EventSource
|
||||
*/
|
||||
function createEventSource(url) {
|
||||
return new EventSource(url, { withCredentials: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* registerSSE looks for attributes that can contain sse events, right
|
||||
* now hx-trigger and sse-swap and adds listeners based on these attributes too
|
||||
* the closest event source
|
||||
*
|
||||
* @param {HTMLElement} elt
|
||||
*/
|
||||
function registerSSE(elt) {
|
||||
// Add message handlers for every `sse-swap` attribute
|
||||
if (api.getAttributeValue(elt, "sse-swap")) {
|
||||
// Find closest existing event source
|
||||
var sourceElement = api.getClosestMatch(elt, hasEventSource);
|
||||
if (sourceElement == null) {
|
||||
// api.triggerErrorEvent(elt, "htmx:noSSESourceError")
|
||||
return null; // no eventsource in parentage, orphaned element
|
||||
}
|
||||
|
||||
// Set internalData and source
|
||||
var internalData = api.getInternalData(sourceElement);
|
||||
var source = internalData.sseEventSource;
|
||||
|
||||
var sseSwapAttr = api.getAttributeValue(elt, "sse-swap");
|
||||
var sseEventNames = sseSwapAttr.split(",");
|
||||
|
||||
for (var i = 0; i < sseEventNames.length; i++) {
|
||||
const sseEventName = sseEventNames[i].trim();
|
||||
const listener = function (event) {
|
||||
// If the source is missing then close SSE
|
||||
if (maybeCloseSSESource(sourceElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If the body no longer contains the element, remove the listener
|
||||
if (!api.bodyContains(elt)) {
|
||||
source.removeEventListener(sseEventName, listener);
|
||||
return;
|
||||
}
|
||||
|
||||
// swap the response into the DOM and trigger a notification
|
||||
if (!api.triggerEvent(elt, "htmx:sseBeforeMessage", event)) {
|
||||
return;
|
||||
}
|
||||
swap(elt, event.data);
|
||||
api.triggerEvent(elt, "htmx:sseMessage", event);
|
||||
};
|
||||
|
||||
// Register the new listener
|
||||
api.getInternalData(elt).sseEventListener = listener;
|
||||
source.addEventListener(sseEventName, listener);
|
||||
}
|
||||
}
|
||||
|
||||
// Add message handlers for every `hx-trigger="sse:*"` attribute
|
||||
if (api.getAttributeValue(elt, "hx-trigger")) {
|
||||
// Find closest existing event source
|
||||
var sourceElement = api.getClosestMatch(elt, hasEventSource);
|
||||
if (sourceElement == null) {
|
||||
// api.triggerErrorEvent(elt, "htmx:noSSESourceError")
|
||||
return null; // no eventsource in parentage, orphaned element
|
||||
}
|
||||
|
||||
// Set internalData and source
|
||||
var internalData = api.getInternalData(sourceElement);
|
||||
var source = internalData.sseEventSource;
|
||||
|
||||
var triggerSpecs = api.getTriggerSpecs(elt);
|
||||
triggerSpecs.forEach(function (ts) {
|
||||
if (ts.trigger.slice(0, 4) !== "sse:") {
|
||||
return;
|
||||
}
|
||||
|
||||
var listener = function (event) {
|
||||
if (maybeCloseSSESource(sourceElement)) {
|
||||
return;
|
||||
}
|
||||
if (!api.bodyContains(elt)) {
|
||||
source.removeEventListener(ts.trigger.slice(4), listener);
|
||||
}
|
||||
// Trigger events to be handled by the rest of htmx
|
||||
htmx.trigger(elt, ts.trigger, event);
|
||||
htmx.trigger(elt, "htmx:sseMessage", event);
|
||||
};
|
||||
|
||||
// Register the new listener
|
||||
api.getInternalData(elt).sseEventListener = listener;
|
||||
source.addEventListener(ts.trigger.slice(4), listener);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ensureEventSourceOnElement creates a new EventSource connection on the provided element.
|
||||
* If a usable EventSource already exists, then it is returned. If not, then a new EventSource
|
||||
* is created and stored in the element's internalData.
|
||||
* @param {HTMLElement} elt
|
||||
* @param {number} retryCount
|
||||
* @returns {EventSource | null}
|
||||
*/
|
||||
function ensureEventSourceOnElement(elt, retryCount) {
|
||||
if (elt == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// handle extension source creation attribute
|
||||
if (api.getAttributeValue(elt, "sse-connect")) {
|
||||
var sseURL = api.getAttributeValue(elt, "sse-connect");
|
||||
if (sseURL == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
ensureEventSource(elt, sseURL, retryCount);
|
||||
}
|
||||
|
||||
registerSSE(elt);
|
||||
}
|
||||
|
||||
function ensureEventSource(elt, url, retryCount) {
|
||||
var source = htmx.createEventSource(url);
|
||||
|
||||
source.onerror = function (err) {
|
||||
// Log an error event
|
||||
api.triggerErrorEvent(elt, "htmx:sseError", { error: err, source });
|
||||
|
||||
// If parent no longer exists in the document, then clean up this EventSource
|
||||
if (maybeCloseSSESource(elt)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise, try to reconnect the EventSource
|
||||
if (source.readyState === EventSource.CLOSED) {
|
||||
retryCount = retryCount || 0;
|
||||
retryCount = Math.max(Math.min(retryCount * 2, 128), 1);
|
||||
var timeout = retryCount * 500;
|
||||
window.setTimeout(function () {
|
||||
ensureEventSourceOnElement(elt, retryCount);
|
||||
}, timeout);
|
||||
}
|
||||
};
|
||||
|
||||
source.onopen = function (evt) {
|
||||
api.triggerEvent(elt, "htmx:sseOpen", { source });
|
||||
|
||||
if (retryCount && retryCount > 0) {
|
||||
const childrenToFix = elt.querySelectorAll(
|
||||
"[sse-swap], [data-sse-swap], [hx-trigger], [data-hx-trigger]",
|
||||
);
|
||||
for (let i = 0; i < childrenToFix.length; i++) {
|
||||
registerSSE(childrenToFix[i]);
|
||||
}
|
||||
// We want to increase the reconnection delay for consecutive failed attempts only
|
||||
retryCount = 0;
|
||||
}
|
||||
};
|
||||
|
||||
api.getInternalData(elt).sseEventSource = source;
|
||||
|
||||
var closeAttribute = api.getAttributeValue(elt, "sse-close");
|
||||
if (closeAttribute) {
|
||||
// close eventsource when this message is received
|
||||
source.addEventListener(closeAttribute, function () {
|
||||
api.triggerEvent(elt, "htmx:sseClose", {
|
||||
source,
|
||||
type: "message",
|
||||
});
|
||||
source.close();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* maybeCloseSSESource confirms that the parent element still exists.
|
||||
* If not, then any associated SSE source is closed and the function returns true.
|
||||
*
|
||||
* @param {HTMLElement} elt
|
||||
* @returns boolean
|
||||
*/
|
||||
function maybeCloseSSESource(elt) {
|
||||
if (!api.bodyContains(elt)) {
|
||||
var source = api.getInternalData(elt).sseEventSource;
|
||||
if (source != undefined) {
|
||||
api.triggerEvent(elt, "htmx:sseClose", {
|
||||
source,
|
||||
type: "nodeMissing",
|
||||
});
|
||||
source.close();
|
||||
// source = null
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLElement} elt
|
||||
* @param {string} content
|
||||
*/
|
||||
function swap(elt, content) {
|
||||
api.withExtensions(elt, function (extension) {
|
||||
content = extension.transformResponse(content, null, elt);
|
||||
});
|
||||
|
||||
var swapSpec = api.getSwapSpecification(elt);
|
||||
var target = api.getTarget(elt);
|
||||
api.swap(target, content, swapSpec);
|
||||
}
|
||||
|
||||
function hasEventSource(node) {
|
||||
return api.getInternalData(node).sseEventSource != null;
|
||||
}
|
||||
})();
|
@ -1,517 +0,0 @@
|
||||
/*
|
||||
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<string, Function[]>} */
|
||||
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 <form> 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)) {
|
||||
var internalData = api.getInternalData(elt);
|
||||
if (internalData.webSocket) {
|
||||
internalData.webSocket.close();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
})();
|
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user