Heavily customized version of smokesignal - https://whtwnd.com/kayrozen.com/3lpwe4ymowg2t
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})()