Rebuild SSE using new Extension API

- internal API has been moved to a constructor variable.
- added reconnect with exponential backoff
- initial tests of SSE features are working
- added many JSDoc comments in the process.
- added link to "sse triggers" test
This commit is contained in:
Ben Pate 2021-10-29 13:24:16 -06:00
parent e3e83df5ad
commit 2aca696c88
4 changed files with 180 additions and 111 deletions

View File

@ -8,7 +8,7 @@ This extension adds support for Server Sent Events to htmx. See /www/extensions
(function(){
/** @type {import("../htmx").HtmxInternalApi} */
var api
var api;
htmx.defineExtension("sse", {
@ -16,11 +16,16 @@ This extension adds support for Server Sent Events to htmx. See /www/extensions
* Init saves the provided reference to the internal HTMX API.
*
* @param {import("../htmx").HtmxInternalApi} api
* @param {HTMLElement} elt
* @returns void
*/
init: function(apiRef, _elt) {
api = apiRef
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;
}
},
/**
@ -36,89 +41,15 @@ This extension adds support for Server Sent Events to htmx. See /www/extensions
// Try to remove remove an EventSource when elements are removed
case "htmx:beforeCleanupElement":
var source = api.getInternalData(evt.target, "sseEventSource")
if (source != null) {
source.close();
var internalData = api.getInternalData(evt.target)
if (internalData.sseEventSource) {
internalData.sseEventSource.close();
}
return;
// Try to create EventSources when elements are processed
case "htmx:afterProcessNode":
var parent = evt.target;
// get URL from element's attribute
var sseURL = api.getAttributeValue(evt.target, "sse-url")
if (sseURL == undefined) {
return;
}
// Default function for creating new EventSource objects
if (htmx.createEventSource == undefined) {
htmx.createEventSource = createEventSource;
}
// Connect to the EventSource
var source = htmx.createEventSource(sseURL);
source.onerror = function (err) {
api.triggerErrorEvent(parent, "htmx:sseError", {error:err, source:source});
maybeCloseSSESource(api, parent);
};
api.getInternalData(parent).sseEventSource = source;
// Add message handlers for every `sse-swap` attribute
queryAttributeOnThisOrChildren(api, parent, "sse-swap").forEach(function(child) {
var sseEventName = api.getAttributeValue(child, "sse-swap")
var listener = function(event) {
// If the parent is missing then close SSE and remove listener
if (maybeCloseSSESource(api, parent)) {
source.removeEventListener(sseEventName, listener);
return;
}
// swap the response into the DOM and trigger a notification
api.swap(child, event.data)
api.triggerEvent(parent, "htmx:sseMessage", event)
};
// Register the new listener
api.getInternalData(parent).sseEventListener = listener;
source.addEventListener(sseEventName, listener);
});
// Add message handlers for every `hx-trigger="sse:*"` attribute
queryAttributeOnThisOrChildren(api, parent, "hx-trigger").forEach(function(child) {
var sseEventName = api.getAttributeValue(child, "hx-trigger")
// 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(api, parent)) {
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(parent).sseEventListener = listener;
source.addEventListener(sseEventName.slice(4), listener);
})
createEventSourceOnElement(evt.target);
}
}
});
@ -135,8 +66,104 @@ This extension adds support for Server Sent Events to htmx. See /www/extensions
* @param {string} url
* @returns EventSource
*/
function createEventSource(url){
return new EventSource(url, {withCredentials:true})
function createEventSource(url) {
return new EventSource(url, {withCredentials:true});
}
/**
* 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} parent
* @param {number} retryCount
* @returns {EventSource | null}
*/
function createEventSourceOnElement(parent, retryCount) {
var internalData = api.getInternalData(parent);
// get URL from element's attribute
var sseURL = api.getAttributeValue(parent, "sse-url");
if (sseURL == undefined) {
return;
}
// 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(parent, "htmx:sseError", {error:err, source:source});
// If parent no longer exists in the document, then clean up this EventSource
if (maybeCloseSSESource(parent)) {
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(parent, Math.min(7, retryCount+1));
}, timeout);
}
};
// Add message handlers for every `sse-swap` attribute
queryAttributeOnThisOrChildren(parent, "sse-swap").forEach(function(child) {
var sseEventName = api.getAttributeValue(child, "sse-swap");
var listener = function(event) {
// If the parent is missing then close SSE and remove listener
if (maybeCloseSSESource(parent)) {
source.removeEventListener(sseEventName, listener);
return;
}
// swap the response into the DOM and trigger a notification
swap(child, event.data);
api.triggerEvent(parent, "htmx:sseMessage", event);
};
// Register the new listener
api.getInternalData(parent).sseEventListener = listener;
source.addEventListener(sseEventName, listener);
});
// Add message handlers for every `hx-trigger="sse:*"` attribute
queryAttributeOnThisOrChildren(parent, "hx-trigger").forEach(function(child) {
var sseEventName = api.getAttributeValue(child, "hx-trigger");
// 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(parent)) {
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(parent).sseEventListener = listener;
source.addEventListener(sseEventName.slice(4), listener);
});
}
/**
@ -148,9 +175,9 @@ This extension adds support for Server Sent Events to htmx. See /www/extensions
*/
function maybeCloseSSESource(elt) {
if (!api.bodyContains(elt)) {
var source = api.getInternalData("sseEventSource")
var source = api.getInternalData(elt, "sseEventSource");
if (source != undefined) {
source.close()
source.close();
// source = null
return true;
}
@ -166,7 +193,7 @@ This extension adds support for Server Sent Events to htmx. See /www/extensions
*/
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 (api.hasAttribute(elt, attributeName)) {
@ -175,10 +202,10 @@ This extension adds support for Server Sent Events to htmx. See /www/extensions
// Search all child nodes that match the requested attribute
elt.querySelectorAll("[" + attributeName + "], [data-" + attributeName + "]").forEach(function(node) {
result.push(node)
})
result.push(node);
});
return result
return result;
}
/**
@ -186,16 +213,17 @@ This extension adds support for Server Sent Events to htmx. See /www/extensions
* @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.withExtensions(elt, function(extension) {
content = extension.transformResponse(content, null, elt);
});
selectAndSwap(swapSpec.swapStyle, elt, target, content, settleInfo)
settleImmediately(settleInfo.tasks)
}
var swapSpec = api.getSwapSpecification(elt);
var target = api.getTarget(elt);
var settleInfo = api.makeSettleInfo(elt);
api.selectAndSwap(swapSpec.swapStyle, elt, target, content, settleInfo);
api.settleImmediately(settleInfo.tasks);
}
})();

16
src/htmx.d.ts vendored
View File

@ -1,6 +1,7 @@
export interface HtmxApi {
config?: HtmxConfig
logger?: (a: HTMLElement, b: Event, c: any) => void | null
on: (event:string, listener:EventListener) => void
}
export interface HtmxConfig {
@ -15,12 +16,15 @@ export interface HtmxConfig {
requestClass?: 'htmx-request' | string;
settlingClass?: 'htmx-settling' | string;
swappingClass?: 'htmx-swapping' | string;
addedClass?: string;
allowEval?: boolean;
timeout: number;
attributesToSettle?: ["class", "style", "width", "height"] | string[];
withCredentials?: boolean;
wsReconnectDelay?: 'full-jitter' | string;
disableSelector?: "[hx-disable], [data-hx-disable]" | string;
useTemplateFragments?: boolean;
scrollBehavior: string;
}
export declare var htmx: HtmxApi
@ -41,18 +45,20 @@ export interface HtmxInternalApi {
getInternalData: (element: HTMLElement) => Object
getSwapSpecification: (element: HTMLElement) => HtmxSwapSpecification
getTarget: (element: HTMLElement) => object
makeSettleInfo: () => Object
selectAndSwap: (swapStyle: any, target: any, elt: any, responseText: any, settleInfo: any) => void // TODO: improve parameter definitions
settleImmediately: (tasks: any) => void // TODO: improve parameter definitions
triggerEvent: (element: HTMLElement, eventName: string, detail: any) => void
triggerErrorEvent: (element: HTMLElement, eventName: string, detail: any) => void
withExtensions: (element: HTMLElement, toDo:(ext:HtmxExtension) => void) => void
swap: (element: HTMLElement, content: string) => void
}
export interface HtmxSwapSpecification {
swapStyle: string
swapDelay: number
settleDelay: number
show:? string
showTarget:? string
scroll:? string
scrollTarget:? string
show?: string
showTarget?: string
scroll?: string
scrollTarget?: string
}

View File

@ -14,6 +14,8 @@ return (function () {
'use strict';
// Public API
//** @type {import("./htmx").HtmxApi} */
// TODO: list all methods in public API
var htmx = {
onLoad: onLoadHelper,
process: processNode,
@ -67,6 +69,22 @@ return (function () {
version: "1.6.0"
};
/** @type {import("./htmx").HtmxInternalApi} */
var internalAPI = {
bodyContains: bodyContains,
hasAttribute: hasAttribute,
getAttributeValue: getAttributeValue,
getInternalData: getInternalData,
getSwapSpecification: getSwapSpecification,
getTarget: getTarget,
makeSettleInfo: makeSettleInfo,
selectAndSwap: selectAndSwap,
settleImmediately: settleImmediately,
triggerEvent: triggerEvent,
triggerErrorEvent: triggerErrorEvent,
withExtensions: withExtensions,
}
var VERBS = ['get', 'post', 'put', 'delete', 'patch'];
var VERB_SELECTOR = VERBS.map(function(verb){
return "[hx-" + verb + "], [data-hx-" + verb + "]"
@ -154,10 +172,9 @@ return (function () {
* @returns {boolean}
*/
function matches(elt, selector) {
// @ts-ignore: non-standard properties for browser compatability
// noinspection JSUnresolvedVariable
var matchesFunction = elt.matches ||
elt.matchesSelector || elt.msMatchesSelector || elt.mozMatchesSelector
|| elt.webkitMatchesSelector || elt.oMatchesSelector;
var matchesFunction = elt.matches || elt.matchesSelector || elt.msMatchesSelector || elt.mozMatchesSelector || elt.webkitMatchesSelector || elt.oMatchesSelector;
return matchesFunction && matchesFunction.call(elt, selector);
}
@ -245,6 +262,11 @@ return (function () {
return isType(o, "Object");
}
/**
* getInternalData retrieves "private" data stored by htmx within an element
* @param {HTMLElement} elt
* @returns {*}
*/
function getInternalData(elt) {
var dataProp = 'htmx-internal-data';
var data = elt[dataProp];
@ -254,6 +276,11 @@ return (function () {
return data;
}
/**
* toArray converts an ArrayLike object into a real array.
* @param {ArrayLike} arr
* @returns {any[]}
*/
function toArray(arr) {
var returnArr = [];
if (arr) {
@ -647,12 +674,14 @@ return (function () {
if (target.tagName === "BODY") {
return swapInnerHTML(target, fragment, settleInfo);
} else {
// @type {HTMLElement}
var newElt
var eltBeforeNewContent = target.previousSibling;
insertNodesBefore(parentElt(target), target, fragment, settleInfo);
if (eltBeforeNewContent == null) {
var newElt = parentElt(target).firstChild;
newElt = parentElt(target).firstChild;
} else {
var newElt = eltBeforeNewContent.nextSibling;
newElt = eltBeforeNewContent.nextSibling;
}
getInternalData(target).replacedWith = newElt; // tuck away so we can fire events on it later
settleInfo.elts = [] // clear existing elements
@ -1911,7 +1940,7 @@ return (function () {
/**
*
* @param {HTMLElement} elt
* @returns import("./htmx").HtmxSwapSpecification
* @returns {import("./htmx").HtmxSwapSpecification}
*/
function getSwapSpecification(elt) {
var swapInfo = getClosestAttributeValue(elt, "hx-swap");
@ -2560,6 +2589,11 @@ return (function () {
/** @type {Object<string, import("./htmx").HtmxExtension>} */
var extensions = {};
/**
* extensionBase defines the default functions for all extensions.
* @returns {import("./htmx").HtmxExtension}
*/
function extensionBase() {
return {
init: function(api) {return null;},
@ -2578,7 +2612,7 @@ return (function () {
* @param {import("./htmx").HtmxExtension} extension
*/
function defineExtension(name, extension) {
extension.init(this)
extension.init(internalAPI)
extensions[name] = mergeObjects(extensionBase(), extension);
}

View File

@ -28,6 +28,7 @@
<ul>
<li><a href="sse.html">Core SSE Test</a></li>
<li><a href="sse-multichannel.html">SSE Multichannel</a></li>
<li><a href="sse-multichannel.html">SSE Triggers</a></li>
<li><a href="sse-settle.html">SSE Settle</a></li>
</ul>
</li>