mirror of
https://github.com/bigskysoftware/htmx.git
synced 2025-09-28 21:41:40 +00:00
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:
parent
e3e83df5ad
commit
2aca696c88
226
src/ext/sse.js
226
src/ext/sse.js
@ -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
16
src/htmx.d.ts
vendored
@ -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
|
||||
}
|
48
src/htmx.js
48
src/htmx.js
@ -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);
|
||||
}
|
||||
|
||||
|
@ -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>
|
||||
|
Loading…
x
Reference in New Issue
Block a user