Heavily customized version of smokesignal - https://whtwnd.com/kayrozen.com/3lpwe4ymowg2t
at main 8.9 kB view raw
1/* 2Server Sent Events Extension 3============================ 4This extension adds support for Server Sent Events to htmx. See /www/extensions/sse.md for usage instructions. 5 6*/ 7 8(function() { 9 /** @type {import("../htmx").HtmxInternalApi} */ 10 var api 11 12 htmx.defineExtension('sse', { 13 14 /** 15 * Init saves the provided reference to the internal HTMX API. 16 * 17 * @param {import("../htmx").HtmxInternalApi} api 18 * @returns void 19 */ 20 init: function(apiRef) { 21 // store a reference to the internal API. 22 api = apiRef 23 24 // set a function in the public API for creating new EventSource objects 25 if (htmx.createEventSource == undefined) { 26 htmx.createEventSource = createEventSource 27 } 28 }, 29 30 getSelectors: function() { 31 return ['[sse-connect]', '[data-sse-connect]', '[sse-swap]', '[data-sse-swap]'] 32 }, 33 34 /** 35 * onEvent handles all events passed to this extension. 36 * 37 * @param {string} name 38 * @param {Event} evt 39 * @returns void 40 */ 41 onEvent: function(name, evt) { 42 var parent = evt.target || evt.detail.elt 43 switch (name) { 44 case 'htmx:beforeCleanupElement': 45 var internalData = api.getInternalData(parent) 46 // Try to remove remove an EventSource when elements are removed 47 var source = internalData.sseEventSource 48 if (source) { 49 api.triggerEvent(parent, 'htmx:sseClose', { 50 source, 51 type: 'nodeReplaced', 52 }) 53 internalData.sseEventSource.close() 54 } 55 56 return 57 58 // Try to create EventSources when elements are processed 59 case 'htmx:afterProcessNode': 60 ensureEventSourceOnElement(parent) 61 } 62 } 63 }) 64 65 /// //////////////////////////////////////////// 66 // HELPER FUNCTIONS 67 /// //////////////////////////////////////////// 68 69 /** 70 * createEventSource is the default method for creating new EventSource objects. 71 * it is hoisted into htmx.config.createEventSource to be overridden by the user, if needed. 72 * 73 * @param {string} url 74 * @returns EventSource 75 */ 76 function createEventSource(url) { 77 return new EventSource(url, { withCredentials: true }) 78 } 79 80 /** 81 * registerSSE looks for attributes that can contain sse events, right 82 * now hx-trigger and sse-swap and adds listeners based on these attributes too 83 * the closest event source 84 * 85 * @param {HTMLElement} elt 86 */ 87 function registerSSE(elt) { 88 // Add message handlers for every `sse-swap` attribute 89 if (api.getAttributeValue(elt, 'sse-swap')) { 90 // Find closest existing event source 91 var sourceElement = api.getClosestMatch(elt, hasEventSource) 92 if (sourceElement == null) { 93 // api.triggerErrorEvent(elt, "htmx:noSSESourceError") 94 return null // no eventsource in parentage, orphaned element 95 } 96 97 // Set internalData and source 98 var internalData = api.getInternalData(sourceElement) 99 var source = internalData.sseEventSource 100 101 var sseSwapAttr = api.getAttributeValue(elt, 'sse-swap') 102 var sseEventNames = sseSwapAttr.split(',') 103 104 for (var i = 0; i < sseEventNames.length; i++) { 105 const sseEventName = sseEventNames[i].trim() 106 const listener = function(event) { 107 // If the source is missing then close SSE 108 if (maybeCloseSSESource(sourceElement)) { 109 return 110 } 111 112 // If the body no longer contains the element, remove the listener 113 if (!api.bodyContains(elt)) { 114 source.removeEventListener(sseEventName, listener) 115 return 116 } 117 118 // swap the response into the DOM and trigger a notification 119 if (!api.triggerEvent(elt, 'htmx:sseBeforeMessage', event)) { 120 return 121 } 122 swap(elt, event.data) 123 api.triggerEvent(elt, 'htmx:sseMessage', event) 124 } 125 126 // Register the new listener 127 api.getInternalData(elt).sseEventListener = listener 128 source.addEventListener(sseEventName, listener) 129 } 130 } 131 132 // Add message handlers for every `hx-trigger="sse:*"` attribute 133 if (api.getAttributeValue(elt, 'hx-trigger')) { 134 // Find closest existing event source 135 var sourceElement = api.getClosestMatch(elt, hasEventSource) 136 if (sourceElement == null) { 137 // api.triggerErrorEvent(elt, "htmx:noSSESourceError") 138 return null // no eventsource in parentage, orphaned element 139 } 140 141 // Set internalData and source 142 var internalData = api.getInternalData(sourceElement) 143 var source = internalData.sseEventSource 144 145 var triggerSpecs = api.getTriggerSpecs(elt) 146 triggerSpecs.forEach(function(ts) { 147 if (ts.trigger.slice(0, 4) !== 'sse:') { 148 return 149 } 150 151 var listener = function (event) { 152 if (maybeCloseSSESource(sourceElement)) { 153 return 154 } 155 if (!api.bodyContains(elt)) { 156 source.removeEventListener(ts.trigger.slice(4), listener) 157 } 158 // Trigger events to be handled by the rest of htmx 159 htmx.trigger(elt, ts.trigger, event) 160 htmx.trigger(elt, 'htmx:sseMessage', event) 161 } 162 163 // Register the new listener 164 api.getInternalData(elt).sseEventListener = listener 165 source.addEventListener(ts.trigger.slice(4), listener) 166 }) 167 } 168 } 169 170 /** 171 * ensureEventSourceOnElement creates a new EventSource connection on the provided element. 172 * If a usable EventSource already exists, then it is returned. If not, then a new EventSource 173 * is created and stored in the element's internalData. 174 * @param {HTMLElement} elt 175 * @param {number} retryCount 176 * @returns {EventSource | null} 177 */ 178 function ensureEventSourceOnElement(elt, retryCount) { 179 if (elt == null) { 180 return null 181 } 182 183 // handle extension source creation attribute 184 if (api.getAttributeValue(elt, 'sse-connect')) { 185 var sseURL = api.getAttributeValue(elt, 'sse-connect') 186 if (sseURL == null) { 187 return 188 } 189 190 ensureEventSource(elt, sseURL, retryCount) 191 } 192 193 registerSSE(elt) 194 } 195 196 function ensureEventSource(elt, url, retryCount) { 197 var source = htmx.createEventSource(url) 198 199 source.onerror = function(err) { 200 // Log an error event 201 api.triggerErrorEvent(elt, 'htmx:sseError', { error: err, source }) 202 203 // If parent no longer exists in the document, then clean up this EventSource 204 if (maybeCloseSSESource(elt)) { 205 return 206 } 207 208 // Otherwise, try to reconnect the EventSource 209 if (source.readyState === EventSource.CLOSED) { 210 retryCount = retryCount || 0 211 retryCount = Math.max(Math.min(retryCount * 2, 128), 1) 212 var timeout = retryCount * 500 213 window.setTimeout(function() { 214 ensureEventSourceOnElement(elt, retryCount) 215 }, timeout) 216 } 217 } 218 219 source.onopen = function(evt) { 220 api.triggerEvent(elt, 'htmx:sseOpen', { source }) 221 222 if (retryCount && retryCount > 0) { 223 const childrenToFix = elt.querySelectorAll("[sse-swap], [data-sse-swap], [hx-trigger], [data-hx-trigger]") 224 for (let i = 0; i < childrenToFix.length; i++) { 225 registerSSE(childrenToFix[i]) 226 } 227 // We want to increase the reconnection delay for consecutive failed attempts only 228 retryCount = 0 229 } 230 } 231 232 api.getInternalData(elt).sseEventSource = source 233 234 235 var closeAttribute = api.getAttributeValue(elt, "sse-close"); 236 if (closeAttribute) { 237 // close eventsource when this message is received 238 source.addEventListener(closeAttribute, function() { 239 api.triggerEvent(elt, 'htmx:sseClose', { 240 source, 241 type: 'message', 242 }) 243 source.close() 244 }); 245 } 246 } 247 248 /** 249 * maybeCloseSSESource confirms that the parent element still exists. 250 * If not, then any associated SSE source is closed and the function returns true. 251 * 252 * @param {HTMLElement} elt 253 * @returns boolean 254 */ 255 function maybeCloseSSESource(elt) { 256 if (!api.bodyContains(elt)) { 257 var source = api.getInternalData(elt).sseEventSource 258 if (source != undefined) { 259 api.triggerEvent(elt, 'htmx:sseClose', { 260 source, 261 type: 'nodeMissing', 262 }) 263 source.close() 264 // source = null 265 return true 266 } 267 } 268 return false 269 } 270 271 272 /** 273 * @param {HTMLElement} elt 274 * @param {string} content 275 */ 276 function swap(elt, content) { 277 api.withExtensions(elt, function(extension) { 278 content = extension.transformResponse(content, null, elt) 279 }) 280 281 var swapSpec = api.getSwapSpecification(elt) 282 var target = api.getTarget(elt) 283 api.swap(target, content, swapSpec) 284 } 285 286 287 function hasEventSource(node) { 288 return api.getInternalData(node).sseEventSource != null 289 } 290})()