322 lines
8.5 KiB
JavaScript
322 lines
8.5 KiB
JavaScript
|
/*
|
||
|
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;
|
||
|
}
|
||
|
},
|
||
|
|
||
|
/**
|
||
|
* onEvent handles all events passed to this extension.
|
||
|
*
|
||
|
* @param {string} name
|
||
|
* @param {Event} evt
|
||
|
* @returns void
|
||
|
*/
|
||
|
onEvent: function(name, evt) {
|
||
|
|
||
|
switch (name) {
|
||
|
|
||
|
// Try to remove remove an EventSource when elements are removed
|
||
|
case "htmx:beforeCleanupElement":
|
||
|
var internalData = api.getInternalData(evt.target)
|
||
|
if (internalData.sseEventSource) {
|
||
|
internalData.sseEventSource.close();
|
||
|
}
|
||
|
return;
|
||
|
|
||
|
// Try to create EventSources when elements are processed
|
||
|
case "htmx:afterProcessNode":
|
||
|
createEventSourceOnElement(evt.target);
|
||
|
}
|
||
|
}
|
||
|
});
|
||
|
|
||
|
///////////////////////////////////////////////
|
||
|
// 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});
|
||
|
}
|
||
|
|
||
|
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) {
|
||
|
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;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* createEventSourceOnElement 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 createEventSourceOnElement(elt, retryCount) {
|
||
|
|
||
|
if (elt == null) {
|
||
|
return null;
|
||
|
}
|
||
|
|
||
|
var internalData = api.getInternalData(elt);
|
||
|
|
||
|
// get URL from element's attribute
|
||
|
var sseURL = api.getAttributeValue(elt, "sse-connect");
|
||
|
|
||
|
|
||
|
if (sseURL == undefined) {
|
||
|
var legacyURL = getLegacySSEURL(elt)
|
||
|
if (legacyURL) {
|
||
|
sseURL = legacyURL;
|
||
|
} else {
|
||
|
return null;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// Connect to the EventSource
|
||
|
var source = htmx.createEventSource(sseURL);
|
||
|
internalData.sseEventSource = source;
|
||
|
|
||
|
// Create event handlers
|
||
|
source.onerror = function (err) {
|
||
|
|
||
|
// Log an error event
|
||
|
api.triggerErrorEvent(elt, "htmx:sseError", {error:err, source: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;
|
||
|
var timeout = Math.random() * (2 ^ retryCount) * 500;
|
||
|
window.setTimeout(function() {
|
||
|
createEventSourceOnElement(elt, Math.min(7, retryCount+1));
|
||
|
}, timeout);
|
||
|
}
|
||
|
};
|
||
|
|
||
|
source.onopen = function (evt) {
|
||
|
api.triggerEvent(elt, "htmx::sseOpen", {source: source});
|
||
|
}
|
||
|
|
||
|
// Add message handlers for every `sse-swap` attribute
|
||
|
queryAttributeOnThisOrChildren(elt, "sse-swap").forEach(function(child) {
|
||
|
|
||
|
var sseSwapAttr = api.getAttributeValue(child, "sse-swap");
|
||
|
if (sseSwapAttr) {
|
||
|
var sseEventNames = sseSwapAttr.split(",");
|
||
|
} else {
|
||
|
var sseEventNames = getLegacySSESwaps(child);
|
||
|
}
|
||
|
|
||
|
for (var i = 0 ; i < sseEventNames.length ; i++) {
|
||
|
var sseEventName = sseEventNames[i].trim();
|
||
|
var listener = function(event) {
|
||
|
|
||
|
// If the parent is missing then close SSE and remove listener
|
||
|
if (maybeCloseSSESource(elt)) {
|
||
|
source.removeEventListener(sseEventName, listener);
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
// swap the response into the DOM and trigger a notification
|
||
|
swap(child, 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
|
||
|
queryAttributeOnThisOrChildren(elt, "hx-trigger").forEach(function(child) {
|
||
|
|
||
|
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;
|
||
|
}
|
||
|
|
||
|
var listener = function(event) {
|
||
|
|
||
|
// If parent is missing, then close SSE and remove listener
|
||
|
if (maybeCloseSSESource(elt)) {
|
||
|
source.removeEventListener(sseEventName, listener);
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
// Trigger events to be handled by the rest of htmx
|
||
|
htmx.trigger(child, sseEventName, event);
|
||
|
htmx.trigger(child, "htmx:sseMessage", event);
|
||
|
}
|
||
|
|
||
|
// Register the new listener
|
||
|
api.getInternalData(elt).sseEventListener = listener;
|
||
|
source.addEventListener(sseEventName.slice(4), listener);
|
||
|
});
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* 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) {
|
||
|
source.close();
|
||
|
// source = null
|
||
|
return true;
|
||
|
}
|
||
|
}
|
||
|
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) || api.hasAttribute(elt, "hx-sse")) {
|
||
|
result.push(elt);
|
||
|
}
|
||
|
|
||
|
// Search all child nodes that match the requested attribute
|
||
|
elt.querySelectorAll("[" + attributeName + "], [data-" + attributeName + "], [hx-sse], [data-hx-sse]").forEach(function(node) {
|
||
|
result.push(node);
|
||
|
});
|
||
|
|
||
|
return result;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* @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);
|
||
|
var settleInfo = api.makeSettleInfo(elt);
|
||
|
|
||
|
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');
|
||
|
});
|
||
|
}
|
||
|
}
|
||
|
|
||
|
})();
|