Proper pre-loading and deps

This commit is contained in:
Atridad Lahiji 2024-10-30 23:50:34 -06:00
parent 24bf04aa7a
commit 561905aabb
Signed by: atridad
SSH key fingerprint: SHA256:LGomp8Opq0jz+7kbwNcdfTcuaLRb5Nh0k5AchDDb438
13 changed files with 880 additions and 825 deletions

View file

@ -47,6 +47,4 @@ Atridad Lahiji // Root
{{end}} {{end}}
{{define "foot"}} {{define "foot"}}
<script src="/public/js/htmx.base.js"></script>
<script src="/public/js/htmx.sse.js"></script>
{{end}} {{end}}

View file

@ -10,12 +10,14 @@
{{template "head" .}} {{template "head" .}}
</head> </head>
<body class="block h-[100%]"> <body class="block h-[100%]" hx-ext="preload">
{{template "header" .}} {{template "header" .}}
<main class="container flex flex-col items-center justify-center gap-3 sm:gap-6 p-4 text-center mx-auto min-h-[calc(100%-64px)]"> <main class="container flex flex-col items-center justify-center gap-3 sm:gap-6 p-4 text-center mx-auto min-h-[calc(100%-64px)]">
{{template "main" .}} {{template "main" .}}
</main> </main>
<script src="/public/js/htmx.base.js"></script>
<script src="/public/js/htmx.preload.js"></script>
{{template "foot" .}} {{template "foot" .}}
</body> </body>
</html> </html>

View file

@ -42,7 +42,7 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-copy"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg> <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-copy"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>
</button> </button>
<a href="/posts" class="btn btn-primary btn-outline"> <a href="/posts" class="btn btn-primary btn-outline" preload="mouseover">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-undo-2"><path d="M9 14 4 9l5-5"/><path d="M4 9h10.5a5.5 5.5 0 0 1 5.5 5.5v0a5.5 5.5 0 0 1-5.5 5.5H11"/></svg> <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-undo-2"><path d="M9 14 4 9l5-5"/><path d="M4 9h10.5a5.5 5.5 0 0 1 5.5 5.5v0a5.5 5.5 0 0 1-5.5 5.5H11"/></svg>
Back Back
</a> </a>
@ -52,6 +52,8 @@
</article> </article>
</main> </main>
<script src="/public/js/htmx.preload.js"></script>
<script src="/public/js/htmx.base.js"></script>
{{template "foot" .}} {{template "foot" .}}
</body> </body>
</html> </html>

View file

@ -1,7 +1,7 @@
{{define "buttonlinks"}} {{define "buttonlinks"}}
{{if eq true .Internal}} {{if eq true .Internal}}
<a class="btn btn-primary btn-outline btn-md lg:btn-lg" href={{.Href}}> <a class="btn btn-primary btn-outline btn-md lg:btn-lg" href={{.Href}} preload="mouseover">
{{.Name}} {{.Name}}
</a> </a>

View file

@ -32,6 +32,7 @@
href={{.Href}} href={{.Href}}
aria-label={{.Name}} aria-label={{.Name}}
class="btn btn-circle btn-base-100 text-primary hover:btn-accent hover:text-neutral" class="btn btn-circle btn-base-100 text-primary hover:btn-accent hover:text-neutral"
preload="mouseover"
> >
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-arrow-right"><path d="M5 12h14"/><path d="m12 5 7 7-7 7"/></svg> <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-arrow-right"><path d="M5 12h14"/><path d="m12 5 7 7-7 7"/></svg>
</a> </a>

View file

@ -11,6 +11,7 @@
<ul <ul
tabindex="0" tabindex="0"
class="menu menu-compact dropdown-content gap-2 mt-3 p-2 shadow bg-base-100 rounded-box" class="menu menu-compact dropdown-content gap-2 mt-3 p-2 shadow bg-base-100 rounded-box"
preload="mouseover"
> >
{{template "navitems" .}} {{template "navitems" .}}
</ul> </ul>

View file

@ -15,6 +15,5 @@ Atridad Lahiji // Post
{{end}} {{end}}
{{define "foot"}} {{define "foot"}}
<script src="/public/js/htmx.base.js"></script>
<script src="/public/js/hyperscript.js"></script> <script src="/public/js/hyperscript.js"></script>
{{end}} {{end}}

View file

@ -31,6 +31,5 @@ Atridad Lahiji // Tools // SSE Demo
{{end}} {{end}}
{{define "foot"}} {{define "foot"}}
<script src="/public/js/htmx.base.js"></script>
<script src="/public/js/htmx.sse.js"></script> <script src="/public/js/htmx.sse.js"></script>
{{end}} {{end}}

File diff suppressed because one or more lines are too long

141
public/js/htmx.preload.js vendored Normal file
View file

@ -0,0 +1,141 @@
// This adds the "preload" extension to htmx. By default, this will
// preload the targets of any tags with `href` or `hx-get` attributes
// if they also have a `preload` attribute as well. See documentation
// for more details
htmx.defineExtension('preload', {
onEvent: function(name, event) {
// Only take actions on "htmx:afterProcessNode"
if (name !== 'htmx:afterProcessNode') {
return
}
// SOME HELPER FUNCTIONS WE'LL NEED ALONG THE WAY
// attr gets the closest non-empty value from the attribute.
var attr = function(node, property) {
if (node == undefined) { return undefined }
return node.getAttribute(property) || node.getAttribute('data-' + property) || attr(node.parentElement, property)
}
// load handles the actual HTTP fetch, and uses htmx.ajax in cases where we're
// preloading an htmx resource (this sends the same HTTP headers as a regular htmx request)
var load = function(node) {
// Called after a successful AJAX request, to mark the
// content as loaded (and prevent additional AJAX calls.)
var done = function(html) {
if (!node.preloadAlways) {
node.preloadState = 'DONE'
}
if (attr(node, 'preload-images') == 'true') {
document.createElement('div').innerHTML = html // create and populate a node to load linked resources, too.
}
}
return function() {
// If this value has already been loaded, then do not try again.
if (node.preloadState !== 'READY') {
return
}
// Special handling for HX-GET - use built-in htmx.ajax function
// so that headers match other htmx requests, then set
// node.preloadState = TRUE so that requests are not duplicated
// in the future
var hxGet = node.getAttribute('hx-get') || node.getAttribute('data-hx-get')
if (hxGet) {
htmx.ajax('GET', hxGet, {
source: node,
handler: function(elt, info) {
done(info.xhr.responseText)
}
})
return
}
// Otherwise, perform a standard xhr request, then set
// node.preloadState = TRUE so that requests are not duplicated
// in the future.
if (node.getAttribute('href')) {
var r = new XMLHttpRequest()
r.open('GET', node.getAttribute('href'))
r.onload = function() { done(r.responseText) }
r.send()
}
}
}
// This function processes a specific node and sets up event handlers.
// We'll search for nodes and use it below.
var init = function(node) {
// If this node DOES NOT include a "GET" transaction, then there's nothing to do here.
if (node.getAttribute('href') + node.getAttribute('hx-get') + node.getAttribute('data-hx-get') == '') {
return
}
// Guarantee that we only initialize each node once.
if (node.preloadState !== undefined) {
return
}
// Get event name from config.
var on = attr(node, 'preload') || 'mousedown'
const always = on.indexOf('always') !== -1
if (always) {
on = on.replace('always', '').trim()
}
// FALL THROUGH to here means we need to add an EventListener
// Apply the listener to the node
node.addEventListener(on, function(evt) {
if (node.preloadState === 'PAUSE') { // Only add one event listener
node.preloadState = 'READY' // Required for the `load` function to trigger
// Special handling for "mouseover" events. Wait 100ms before triggering load.
if (on === 'mouseover') {
window.setTimeout(load(node), 100)
} else {
load(node)() // all other events trigger immediately.
}
}
})
// Special handling for certain built-in event handlers
switch (on) {
case 'mouseover':
// Mirror `touchstart` events (fires immediately)
node.addEventListener('touchstart', load(node))
// WHhen the mouse leaves, immediately disable the preload
node.addEventListener('mouseout', function(evt) {
if ((evt.target === node) && (node.preloadState === 'READY')) {
node.preloadState = 'PAUSE'
}
})
break
case 'mousedown':
// Mirror `touchstart` events (fires immediately)
node.addEventListener('touchstart', load(node))
break
}
// Mark the node as ready to run.
node.preloadState = 'PAUSE'
node.preloadAlways = always
htmx.trigger(node, 'preload:init') // This event can be used to load content immediately.
}
// Search for all child nodes that have a "preload" attribute
const parent = event.target || event.detail.elt;
parent.querySelectorAll("[preload]").forEach(function(node) {
// Initialize the node with the "preload" attribute
init(node)
// Initialize all child elements that are anchors or have `hx-get` (use with care)
node.querySelectorAll('a,[hx-get],[data-hx-get]').forEach(init)
})
}
})

361
public/js/htmx.sse.js vendored
View file

@ -6,11 +6,10 @@ This extension adds support for Server Sent Events to htmx. See /www/extensions
*/ */
(function() { (function() {
/** @type {import("../htmx").HtmxInternalApi} */ /** @type {import("../htmx").HtmxInternalApi} */
var api; var api
htmx.defineExtension("sse", { htmx.defineExtension('sse', {
/** /**
* Init saves the provided reference to the internal HTMX API. * Init saves the provided reference to the internal HTMX API.
@ -20,14 +19,18 @@ This extension adds support for Server Sent Events to htmx. See /www/extensions
*/ */
init: function(apiRef) { init: function(apiRef) {
// store a reference to the internal API. // store a reference to the internal API.
api = apiRef; api = apiRef
// set a function in the public API for creating new EventSource objects // set a function in the public API for creating new EventSource objects
if (htmx.createEventSource == undefined) { if (htmx.createEventSource == undefined) {
htmx.createEventSource = createEventSource; htmx.createEventSource = createEventSource
} }
}, },
getSelectors: function() {
return ['[sse-connect]', '[data-sse-connect]', '[sse-swap]', '[data-sse-swap]']
},
/** /**
* onEvent handles all events passed to this extension. * onEvent handles all events passed to this extension.
* *
@ -36,30 +39,32 @@ This extension adds support for Server Sent Events to htmx. See /www/extensions
* @returns void * @returns void
*/ */
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) {
case 'htmx:beforeCleanupElement':
case "htmx:beforeCleanupElement":
var internalData = api.getInternalData(parent) var internalData = api.getInternalData(parent)
// Try to remove remove an EventSource when elements are removed // Try to remove remove an EventSource when elements are removed
if (internalData.sseEventSource) { var source = internalData.sseEventSource
internalData.sseEventSource.close(); if (source) {
api.triggerEvent(parent, 'htmx:sseClose', {
source,
type: 'nodeReplaced',
})
internalData.sseEventSource.close()
} }
return; return
// Try to create EventSources when elements are processed // Try to create EventSources when elements are processed
case "htmx:afterProcessNode": case 'htmx:afterProcessNode':
ensureEventSourceOnElement(parent); ensureEventSourceOnElement(parent)
} }
} }
}); })
/////////////////////////////////////////////// /// ////////////////////////////////////////////
// HELPER FUNCTIONS // HELPER FUNCTIONS
/////////////////////////////////////////////// /// ////////////////////////////////////////////
/** /**
* createEventSource is the default method for creating new EventSource objects. * createEventSource is the default method for creating new EventSource objects.
@ -69,39 +74,7 @@ This extension adds support for Server Sent Events to htmx. See /www/extensions
* @returns EventSource * @returns EventSource
*/ */
function createEventSource(url) { function createEventSource(url) {
return new EventSource(url, { withCredentials: true }); return new EventSource(url, { withCredentials: true })
}
function splitOnWhitespace(trigger) {
return trigger.trim().split(/\s+/);
}
function getLegacySSEURL(elt) {
var legacySSEValue = api.getAttributeValue(elt, "hx-sse");
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];
}
}
}
}
function getLegacySSESwaps(elt) {
var legacySSEValue = api.getAttributeValue(elt, "hx-sse");
var returnArr = [];
if (legacySSEValue != null) {
var values = splitOnWhitespace(legacySSEValue);
for (var i = 0; i < values.length; i++) {
var value = values[i].split(/:(.+)/);
if (value[0] === "swap") {
returnArr.push(value[1]);
}
}
}
return returnArr;
} }
/** /**
@ -113,90 +86,85 @@ This extension adds support for Server Sent Events to htmx. See /www/extensions
*/ */
function registerSSE(elt) { function registerSSE(elt) {
// Add message handlers for every `sse-swap` attribute // Add message handlers for every `sse-swap` attribute
queryAttributeOnThisOrChildren(elt, "sse-swap").forEach(function (child) { if (api.getAttributeValue(elt, 'sse-swap')) {
// Find closest existing event source // Find closest existing event source
var sourceElement = api.getClosestMatch(child, hasEventSource); var sourceElement = api.getClosestMatch(elt, hasEventSource)
if (sourceElement == null) { if (sourceElement == null) {
// api.triggerErrorEvent(elt, "htmx:noSSESourceError") // api.triggerErrorEvent(elt, "htmx:noSSESourceError")
return null; // no eventsource in parentage, orphaned element return null // no eventsource in parentage, orphaned element
} }
// Set internalData and source // Set internalData and source
var internalData = api.getInternalData(sourceElement); var internalData = api.getInternalData(sourceElement)
var source = internalData.sseEventSource; var source = internalData.sseEventSource
var sseSwapAttr = api.getAttributeValue(child, "sse-swap"); var sseSwapAttr = api.getAttributeValue(elt, 'sse-swap')
if (sseSwapAttr) { var sseEventNames = sseSwapAttr.split(',')
var sseEventNames = sseSwapAttr.split(",");
} else {
var sseEventNames = getLegacySSESwaps(child);
}
for (var i = 0; i < sseEventNames.length; i++) { for (var i = 0; i < sseEventNames.length; i++) {
var sseEventName = sseEventNames[i].trim(); const sseEventName = sseEventNames[i].trim()
var listener = function(event) { const listener = function(event) {
// If the source is missing then close SSE // 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(child)) {
source.removeEventListener(sseEventName, listener);
return;
}
// swap the response into the DOM and trigger a notification
if(!api.triggerEvent(elt, "htmx:sseBeforeMessage", event)) {
return;
}
swap(child, event.data);
api.triggerEvent(elt, "htmx:sseMessage", event);
};
// Register the new listener
api.getInternalData(child).sseEventListener = listener;
source.addEventListener(sseEventName, listener);
}
});
// Add message handlers for every `hx-trigger="sse:*"` attribute
queryAttributeOnThisOrChildren(elt, "hx-trigger").forEach(function(child) {
// Find closest existing event source
var sourceElement = api.getClosestMatch(child, 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 sseEventName = api.getAttributeValue(child, "hx-trigger");
if (sseEventName == null) {
return;
}
// Only process hx-triggers for events with the "sse:" prefix
if (sseEventName.slice(0, 4) != "sse:") {
return;
}
// remove the sse: prefix from here on out
sseEventName = sseEventName.substr(4);
var listener = function() {
if (maybeCloseSSESource(sourceElement)) { if (maybeCloseSSESource(sourceElement)) {
return return
} }
if (!api.bodyContains(child)) { // If the body no longer contains the element, remove the listener
source.removeEventListener(sseEventName, 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)
})
}
} }
/** /**
@ -208,62 +176,73 @@ This extension adds support for Server Sent Events to htmx. See /www/extensions
* @returns {EventSource | null} * @returns {EventSource | null}
*/ */
function ensureEventSourceOnElement(elt, retryCount) { function ensureEventSourceOnElement(elt, retryCount) {
if (elt == null) { if (elt == null) {
return null; return null
} }
// handle extension source creation attribute // handle extension source creation attribute
queryAttributeOnThisOrChildren(elt, "sse-connect").forEach(function(child) { if (api.getAttributeValue(elt, 'sse-connect')) {
var sseURL = api.getAttributeValue(child, "sse-connect"); var sseURL = api.getAttributeValue(elt, 'sse-connect')
if (sseURL == null) { if (sseURL == null) {
return; return
} }
ensureEventSource(child, sseURL, retryCount); ensureEventSource(elt, sseURL, retryCount)
});
// handle legacy sse, remove for HTMX2
queryAttributeOnThisOrChildren(elt, "hx-sse").forEach(function(child) {
var sseURL = getLegacySSEURL(child);
if (sseURL == null) {
return;
} }
ensureEventSource(child, sseURL, retryCount); registerSSE(elt)
});
registerSSE(elt);
} }
function ensureEventSource(elt, url, retryCount) { function ensureEventSource(elt, url, retryCount) {
var source = htmx.createEventSource(url); var source = htmx.createEventSource(url)
source.onerror = function(err) { source.onerror = function(err) {
// Log an error event // Log an error event
api.triggerErrorEvent(elt, "htmx:sseError", { error: err, source: source }); api.triggerErrorEvent(elt, 'htmx:sseError', { error: err, source })
// If parent no longer exists in the document, then clean up this EventSource // If parent no longer exists in the document, then clean up this EventSource
if (maybeCloseSSESource(elt)) { if (maybeCloseSSESource(elt)) {
return; return
} }
// Otherwise, try to reconnect the EventSource // Otherwise, try to reconnect the EventSource
if (source.readyState === EventSource.CLOSED) { if (source.readyState === EventSource.CLOSED) {
retryCount = retryCount || 0; retryCount = retryCount || 0
var timeout = Math.random() * (2 ^ retryCount) * 500; retryCount = Math.max(Math.min(retryCount * 2, 128), 1)
var timeout = retryCount * 500
window.setTimeout(function() { window.setTimeout(function() {
ensureEventSourceOnElement(elt, Math.min(7, retryCount + 1)); ensureEventSourceOnElement(elt, retryCount)
}, timeout); }, timeout)
}
} }
};
source.onopen = function(evt) { source.onopen = function(evt) {
api.triggerEvent(elt, "htmx:sseOpen", { source: source }); 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; 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()
});
}
} }
/** /**
@ -275,95 +254,37 @@ This extension adds support for Server Sent Events to htmx. See /www/extensions
*/ */
function maybeCloseSSESource(elt) { function maybeCloseSSESource(elt) {
if (!api.bodyContains(elt)) { if (!api.bodyContains(elt)) {
var source = api.getInternalData(elt).sseEventSource; var source = api.getInternalData(elt).sseEventSource
if (source != undefined) { if (source != undefined) {
source.close(); api.triggerEvent(elt, 'htmx:sseClose', {
source,
type: 'nodeMissing',
})
source.close()
// source = null // source = null
return true; return true
} }
} }
return false; return false
} }
/**
* 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)) {
result.push(elt);
}
// Search all child nodes that match the requested attribute
elt.querySelectorAll("[" + attributeName + "], [data-" + attributeName + "]").forEach(function(node) {
result.push(node);
});
return result;
}
/** /**
* @param {HTMLElement} elt * @param {HTMLElement} elt
* @param {string} content * @param {string} content
*/ */
function swap(elt, content) { function swap(elt, content) {
api.withExtensions(elt, function(extension) { api.withExtensions(elt, function(extension) {
content = extension.transformResponse(content, null, elt); content = extension.transformResponse(content, null, elt)
}); })
var swapSpec = api.getSwapSpecification(elt); var swapSpec = api.getSwapSpecification(elt)
var target = api.getTarget(elt); var target = api.getTarget(elt)
var settleInfo = api.makeSettleInfo(elt); api.swap(target, content, swapSpec)
api.selectAndSwap(swapSpec.swapStyle, target, elt, content, settleInfo);
settleInfo.elts.forEach(function(elt) {
if (elt.classList) {
elt.classList.add(htmx.config.settlingClass);
}
api.triggerEvent(elt, 'htmx:beforeSettle');
});
// Handle settle tasks (with delay if requested)
if (swapSpec.settleDelay > 0) {
setTimeout(doSettle(settleInfo), swapSpec.settleDelay);
} else {
doSettle(settleInfo)();
}
} }
/**
* doSettle mirrors much of the functionality in htmx that
* settles elements after their content has been swapped.
* TODO: this should be published by htmx, and not duplicated here
* @param {import("../htmx").HtmxSettleInfo} settleInfo
* @returns () => void
*/
function doSettle(settleInfo) {
return function() {
settleInfo.tasks.forEach(function(task) {
task.call();
});
settleInfo.elts.forEach(function(elt) {
if (elt.classList) {
elt.classList.remove(htmx.config.settlingClass);
}
api.triggerEvent(elt, 'htmx:afterSettle');
});
}
}
function hasEventSource(node) { function hasEventSource(node) {
return api.getInternalData(node).sseEventSource != null; return api.getInternalData(node).sseEventSource != null
} }
})()
})();

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

@ -4,30 +4,28 @@ 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'
} }
}, },
@ -37,45 +35,44 @@ 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(queryAttributeOnThisOrChildren(parent, "ws-connect"), function (child) {
forEach(queryAttributeOnThisOrChildren(parent, 'ws-connect'), function(child) {
ensureWebSocket(child) ensureWebSocket(child)
}); })
forEach(queryAttributeOnThisOrChildren(parent, "ws-send"), function (child) { forEach(queryAttributeOnThisOrChildren(parent, 'ws-send'), function(child) {
ensureWebSocketSend(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,72 +85,71 @@ This extension adds support for WebSockets to htmx. See /www/extensions/ws.md f
* @returns * @returns
*/ */
function ensureWebSocket(socketElt) { function ensureWebSocket(socketElt) {
// 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 = location.hostname + (location.port ? ':' + location.port : '')
if (location.protocol === 'https:') { if (location.protocol === 'https:') {
wssSource = "wss://" + base_part + wssSource; wssSource = 'wss://' + base_part + wssSource
} else if (location.protocol === 'http:') { } else if (location.protocol === 'http:') {
wssSource = "ws://" + base_part + wssSource; 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
} }
/** /**
@ -183,55 +179,55 @@ 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, message,
socketWrapper: this.publicInterface socketWrapper: this.publicInterface
})) { })) {
this.socket.send(message); this.socket.send(message)
sendElt && api.triggerEvent(sendElt, 'htmx:wsAfterSend', { sendElt && api.triggerEvent(sendElt, 'htmx:wsAfterSend', {
message: message, message,
socketWrapper: this.publicInterface 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: message, sendElt: 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()
@ -239,64 +235,64 @@ This extension adds support for WebSockets to htmx. See /www/extensions/ws.md f
// 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', { event: e, socketWrapper: wrapper.publicInterface })
wrapper.handleQueuedMessages(); 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 (!maybeCloseWebSocketSource(socketElt) && [1006, 1012, 1013].indexOf(e.code) >= 0) {
var delay = getWebSocketReconnectDelay(wrapper.retryCount); var delay = getWebSocketReconnectDelay(wrapper.retryCount)
setTimeout(function () { setTimeout(function() {
wrapper.retryCount += 1; wrapper.retryCount += 1
wrapper.init(); wrapper.init()
}, delay); }, 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', { error: e, socketWrapper: wrapper })
maybeCloseWebSocketSource(socketElt); 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
} }
/** /**
@ -305,13 +301,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)
} }
/** /**
@ -320,7 +316,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
} }
/** /**
@ -330,59 +326,58 @@ 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 = 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,
unfilteredParameters: allParameters, unfilteredParameters: allParameters,
headers: headers, headers,
errors: errors, errors,
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) if (sendConfig.headers) { toSend.HEADERS = headers }
toSend['HEADERS'] = headers; body = JSON.stringify(toSend)
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()
} }
}); })
}); })
} }
/** /**
@ -391,19 +386,18 @@ This extension adds support for WebSockets to htmx. See /www/extensions/ws.md f
* @returns {number} * @returns {number}
*/ */
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"')
} }
/** /**
@ -417,10 +411,10 @@ 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)) {
api.getInternalData(elt).webSocket.close(); api.getInternalData(elt).webSocket.close()
return true; return true
} }
return false; return false
} }
/** /**
@ -431,9 +425,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
} }
/** /**
@ -443,16 +437,15 @@ 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 (api.hasAttribute(elt, attributeName) || api.hasAttribute(elt, 'hx-ws')) {
result.push(elt); 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.querySelectorAll('[' + attributeName + '], [data-' + attributeName + '], [data-hx-ws], [hx-ws]').forEach(function(node) {
result.push(node) result.push(node)
}) })
@ -467,10 +460,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