UNPKG

10.1 kBJavaScriptView 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
10 /** @type {import("../htmx").HtmxInternalApi} */
11 var api;
12
13 htmx.defineExtension("sse", {
14
15 /**
16 * Init saves the provided reference to the internal HTMX API.
17 *
18 * @param {import("../htmx").HtmxInternalApi} api
19 * @returns void
20 */
21 init: function(apiRef) {
22 // store a reference to the internal API.
23 api = apiRef;
24
25 // set a function in the public API for creating new EventSource objects
26 if (htmx.createEventSource == undefined) {
27 htmx.createEventSource = createEventSource;
28 }
29 },
30
31 /**
32 * onEvent handles all events passed to this extension.
33 *
34 * @param {string} name
35 * @param {Event} evt
36 * @returns void
37 */
38 onEvent: function(name, evt) {
39
40 var parent = evt.target || evt.detail.elt;
41 switch (name) {
42
43 case "htmx:beforeCleanupElement":
44 var internalData = api.getInternalData(parent)
45 // Try to remove remove an EventSource when elements are removed
46 if (internalData.sseEventSource) {
47 internalData.sseEventSource.close();
48 }
49
50 return;
51
52 // Try to create EventSources when elements are processed
53 case "htmx:afterProcessNode":
54 ensureEventSourceOnElement(parent);
55 }
56 }
57 });
58
59 ///////////////////////////////////////////////
60 // HELPER FUNCTIONS
61 ///////////////////////////////////////////////
62
63
64 /**
65 * createEventSource is the default method for creating new EventSource objects.
66 * it is hoisted into htmx.config.createEventSource to be overridden by the user, if needed.
67 *
68 * @param {string} url
69 * @returns EventSource
70 */
71 function createEventSource(url) {
72 return new EventSource(url, { withCredentials: true });
73 }
74
75 function splitOnWhitespace(trigger) {
76 return trigger.trim().split(/\s+/);
77 }
78
79 function getLegacySSEURL(elt) {
80 var legacySSEValue = api.getAttributeValue(elt, "hx-sse");
81 if (legacySSEValue) {
82 var values = splitOnWhitespace(legacySSEValue);
83 for (var i = 0; i < values.length; i++) {
84 var value = values[i].split(/:(.+)/);
85 if (value[0] === "connect") {
86 return value[1];
87 }
88 }
89 }
90 }
91
92 function getLegacySSESwaps(elt) {
93 var legacySSEValue = api.getAttributeValue(elt, "hx-sse");
94 var returnArr = [];
95 if (legacySSEValue != null) {
96 var values = splitOnWhitespace(legacySSEValue);
97 for (var i = 0; i < values.length; i++) {
98 var value = values[i].split(/:(.+)/);
99 if (value[0] === "swap") {
100 returnArr.push(value[1]);
101 }
102 }
103 }
104 return returnArr;
105 }
106
107 /**
108 * registerSSE looks for attributes that can contain sse events, right
109 * now hx-trigger and sse-swap and adds listeners based on these attributes too
110 * the closest event source
111 *
112 * @param {HTMLElement} elt
113 */
114 function registerSSE(elt) {
115 // Add message handlers for every `sse-swap` attribute
116 queryAttributeOnThisOrChildren(elt, "sse-swap").forEach(function (child) {
117 // Find closest existing event source
118 var sourceElement = api.getClosestMatch(child, hasEventSource);
119 if (sourceElement == null) {
120 // api.triggerErrorEvent(elt, "htmx:noSSESourceError")
121 return null; // no eventsource in parentage, orphaned element
122 }
123
124 // Set internalData and source
125 var internalData = api.getInternalData(sourceElement);
126 var source = internalData.sseEventSource;
127
128 var sseSwapAttr = api.getAttributeValue(child, "sse-swap");
129 if (sseSwapAttr) {
130 var sseEventNames = sseSwapAttr.split(",");
131 } else {
132 var sseEventNames = getLegacySSESwaps(child);
133 }
134
135 for (var i = 0; i < sseEventNames.length; i++) {
136 var sseEventName = sseEventNames[i].trim();
137 var listener = function(event) {
138
139 // If the source is missing then close SSE
140 if (maybeCloseSSESource(sourceElement)) {
141 return;
142 }
143
144 // If the body no longer contains the element, remove the listener
145 if (!api.bodyContains(child)) {
146 source.removeEventListener(sseEventName, listener);
147 return;
148 }
149
150 // swap the response into the DOM and trigger a notification
151 if(!api.triggerEvent(elt, "htmx:sseBeforeMessage", event)) {
152 return;
153 }
154 swap(child, event.data);
155 api.triggerEvent(elt, "htmx:sseMessage", event);
156 };
157
158 // Register the new listener
159 api.getInternalData(child).sseEventListener = listener;
160 source.addEventListener(sseEventName, listener);
161 }
162 });
163
164 // Add message handlers for every `hx-trigger="sse:*"` attribute
165 queryAttributeOnThisOrChildren(elt, "hx-trigger").forEach(function(child) {
166 // Find closest existing event source
167 var sourceElement = api.getClosestMatch(child, hasEventSource);
168 if (sourceElement == null) {
169 // api.triggerErrorEvent(elt, "htmx:noSSESourceError")
170 return null; // no eventsource in parentage, orphaned element
171 }
172
173 // Set internalData and source
174 var internalData = api.getInternalData(sourceElement);
175 var source = internalData.sseEventSource;
176
177 var sseEventName = api.getAttributeValue(child, "hx-trigger");
178 if (sseEventName == null) {
179 return;
180 }
181
182 // Only process hx-triggers for events with the "sse:" prefix
183 if (sseEventName.slice(0, 4) != "sse:") {
184 return;
185 }
186
187 // remove the sse: prefix from here on out
188 sseEventName = sseEventName.substr(4);
189
190 var listener = function() {
191 if (maybeCloseSSESource(sourceElement)) {
192 return
193 }
194
195 if (!api.bodyContains(child)) {
196 source.removeEventListener(sseEventName, listener);
197 }
198 }
199 });
200 }
201
202 /**
203 * ensureEventSourceOnElement creates a new EventSource connection on the provided element.
204 * If a usable EventSource already exists, then it is returned. If not, then a new EventSource
205 * is created and stored in the element's internalData.
206 * @param {HTMLElement} elt
207 * @param {number} retryCount
208 * @returns {EventSource | null}
209 */
210 function ensureEventSourceOnElement(elt, retryCount) {
211
212 if (elt == null) {
213 return null;
214 }
215
216 // handle extension source creation attribute
217 queryAttributeOnThisOrChildren(elt, "sse-connect").forEach(function(child) {
218 var sseURL = api.getAttributeValue(child, "sse-connect");
219 if (sseURL == null) {
220 return;
221 }
222
223 ensureEventSource(child, sseURL, retryCount);
224 });
225
226 // handle legacy sse, remove for HTMX2
227 queryAttributeOnThisOrChildren(elt, "hx-sse").forEach(function(child) {
228 var sseURL = getLegacySSEURL(child);
229 if (sseURL == null) {
230 return;
231 }
232
233 ensureEventSource(child, sseURL, retryCount);
234 });
235
236 registerSSE(elt);
237 }
238
239 function ensureEventSource(elt, url, retryCount) {
240 var source = htmx.createEventSource(url);
241
242 source.onerror = function(err) {
243
244 // Log an error event
245 api.triggerErrorEvent(elt, "htmx:sseError", { error: err, source: source });
246
247 // If parent no longer exists in the document, then clean up this EventSource
248 if (maybeCloseSSESource(elt)) {
249 return;
250 }
251
252 // Otherwise, try to reconnect the EventSource
253 if (source.readyState === EventSource.CLOSED) {
254 retryCount = retryCount || 0;
255 var timeout = Math.random() * (2 ^ retryCount) * 500;
256 window.setTimeout(function() {
257 ensureEventSourceOnElement(elt, Math.min(7, retryCount + 1));
258 }, timeout);
259 }
260 };
261
262 source.onopen = function(evt) {
263 api.triggerEvent(elt, "htmx:sseOpen", { source: source });
264 }
265
266 api.getInternalData(elt).sseEventSource = source;
267 }
268
269 /**
270 * maybeCloseSSESource confirms that the parent element still exists.
271 * If not, then any associated SSE source is closed and the function returns true.
272 *
273 * @param {HTMLElement} elt
274 * @returns boolean
275 */
276 function maybeCloseSSESource(elt) {
277 if (!api.bodyContains(elt)) {
278 var source = api.getInternalData(elt).sseEventSource;
279 if (source != undefined) {
280 source.close();
281 // source = null
282 return true;
283 }
284 }
285 return false;
286 }
287
288 /**
289 * queryAttributeOnThisOrChildren returns all nodes that contain the requested attributeName, INCLUDING THE PROVIDED ROOT ELEMENT.
290 *
291 * @param {HTMLElement} elt
292 * @param {string} attributeName
293 */
294 function queryAttributeOnThisOrChildren(elt, attributeName) {
295
296 var result = [];
297
298 // If the parent element also contains the requested attribute, then add it to the results too.
299 if (api.hasAttribute(elt, attributeName)) {
300 result.push(elt);
301 }
302
303 // Search all child nodes that match the requested attribute
304 elt.querySelectorAll("[" + attributeName + "], [data-" + attributeName + "]").forEach(function(node) {
305 result.push(node);
306 });
307
308 return result;
309 }
310
311 /**
312 * @param {HTMLElement} elt
313 * @param {string} content
314 */
315 function swap(elt, content) {
316
317 api.withExtensions(elt, function(extension) {
318 content = extension.transformResponse(content, null, elt);
319 });
320
321 var swapSpec = api.getSwapSpecification(elt);
322 var target = api.getTarget(elt);
323 var settleInfo = api.makeSettleInfo(elt);
324
325 api.selectAndSwap(swapSpec.swapStyle, target, elt, content, settleInfo);
326
327 settleInfo.elts.forEach(function(elt) {
328 if (elt.classList) {
329 elt.classList.add(htmx.config.settlingClass);
330 }
331 api.triggerEvent(elt, 'htmx:beforeSettle');
332 });
333
334 // Handle settle tasks (with delay if requested)
335 if (swapSpec.settleDelay > 0) {
336 setTimeout(doSettle(settleInfo), swapSpec.settleDelay);
337 } else {
338 doSettle(settleInfo)();
339 }
340 }
341
342 /**
343 * doSettle mirrors much of the functionality in htmx that
344 * settles elements after their content has been swapped.
345 * TODO: this should be published by htmx, and not duplicated here
346 * @param {import("../htmx").HtmxSettleInfo} settleInfo
347 * @returns () => void
348 */
349 function doSettle(settleInfo) {
350
351 return function() {
352 settleInfo.tasks.forEach(function(task) {
353 task.call();
354 });
355
356 settleInfo.elts.forEach(function(elt) {
357 if (elt.classList) {
358 elt.classList.remove(htmx.config.settlingClass);
359 }
360 api.triggerEvent(elt, 'htmx:afterSettle');
361 });
362 }
363 }
364
365 function hasEventSource(node) {
366 return api.getInternalData(node).sseEventSource != null;
367 }
368
369})();