From 2aca696c886d96c93db38c7f05c06cdd0df72183 Mon Sep 17 00:00:00 2001 From: Ben Pate Date: Fri, 29 Oct 2021 13:24:16 -0600 Subject: [PATCH] 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 --- src/ext/sse.js | 226 +++++++++++++++++++++++------------------ src/htmx.d.ts | 16 ++- src/htmx.js | 48 +++++++-- test/manual/index.html | 1 + 4 files changed, 180 insertions(+), 111 deletions(-) diff --git a/src/ext/sse.js b/src/ext/sse.js index a9bed457..534e48e2 100644 --- a/src/ext/sse.js +++ b/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); } + })(); \ No newline at end of file diff --git a/src/htmx.d.ts b/src/htmx.d.ts index 03ee2a53..e5abe709 100644 --- a/src/htmx.d.ts +++ b/src/htmx.d.ts @@ -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 } \ No newline at end of file diff --git a/src/htmx.js b/src/htmx.js index 7c030083..1d6f7ae7 100644 --- a/src/htmx.js +++ b/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} */ 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); } diff --git a/test/manual/index.html b/test/manual/index.html index 85d150b2..b76b1173 100644 --- a/test/manual/index.html +++ b/test/manual/index.html @@ -28,6 +28,7 @@