UNPKG

8.56 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 switch (name) {
41
42 // Try to remove remove an EventSource when elements are removed
43 case "htmx:beforeCleanupElement":
44 var internalData = api.getInternalData(evt.target)
45 if (internalData.sseEventSource) {
46 internalData.sseEventSource.close();
47 }
48 return;
49
50 // Try to create EventSources when elements are processed
51 case "htmx:afterProcessNode":
52 createEventSourceOnElement(evt.target);
53 }
54 }
55 });
56
57 ///////////////////////////////////////////////
58 // HELPER FUNCTIONS
59 ///////////////////////////////////////////////
60
61
62 /**
63 * createEventSource is the default method for creating new EventSource objects.
64 * it is hoisted into htmx.config.createEventSource to be overridden by the user, if needed.
65 *
66 * @param {string} url
67 * @returns EventSource
68 */
69 function createEventSource(url) {
70 return new EventSource(url, {withCredentials:true});
71 }
72
73 function splitOnWhitespace(trigger) {
74 return trigger.trim().split(/\s+/);
75 }
76
77 function getLegacySSEURL(elt) {
78 var legacySSEValue = api.getAttributeValue(elt, "hx-sse");
79 if (legacySSEValue) {
80 var values = splitOnWhitespace(legacySSEValue);
81 for (var i = 0; i < values.length; i++) {
82 var value = values[i].split(/:(.+)/);
83 if (value[0] === "connect") {
84 return value[1];
85 }
86 }
87 }
88 }
89
90 function getLegacySSESwaps(elt) {
91 var legacySSEValue = api.getAttributeValue(elt, "hx-sse");
92 var returnArr = [];
93 if (legacySSEValue) {
94 var values = splitOnWhitespace(legacySSEValue);
95 for (var i = 0; i < values.length; i++) {
96 var value = values[i].split(/:(.+)/);
97 if (value[0] === "swap") {
98 returnArr.push(value[1]);
99 }
100 }
101 }
102 return returnArr;
103 }
104
105 /**
106 * createEventSourceOnElement creates a new EventSource connection on the provided element.
107 * If a usable EventSource already exists, then it is returned. If not, then a new EventSource
108 * is created and stored in the element's internalData.
109 * @param {HTMLElement} elt
110 * @param {number} retryCount
111 * @returns {EventSource | null}
112 */
113 function createEventSourceOnElement(elt, retryCount) {
114
115 if (elt == null) {
116 return null;
117 }
118
119 var internalData = api.getInternalData(elt);
120
121 // get URL from element's attribute
122 var sseURL = api.getAttributeValue(elt, "sse-connect");
123
124
125 if (sseURL == undefined) {
126 var legacyURL = getLegacySSEURL(elt)
127 if (legacyURL) {
128 sseURL = legacyURL;
129 } else {
130 return null;
131 }
132 }
133
134 // Connect to the EventSource
135 var source = htmx.createEventSource(sseURL);
136 internalData.sseEventSource = source;
137
138 // Create event handlers
139 source.onerror = function (err) {
140
141 // Log an error event
142 api.triggerErrorEvent(elt, "htmx:sseError", {error:err, source:source});
143
144 // If parent no longer exists in the document, then clean up this EventSource
145 if (maybeCloseSSESource(elt)) {
146 return;
147 }
148
149 // Otherwise, try to reconnect the EventSource
150 if (source.readyState === EventSource.CLOSED) {
151 retryCount = retryCount || 0;
152 var timeout = Math.random() * (2 ^ retryCount) * 500;
153 window.setTimeout(function() {
154 createEventSourceOnElement(elt, Math.min(7, retryCount+1));
155 }, timeout);
156 }
157 };
158
159 // Add message handlers for every `sse-swap` attribute
160 queryAttributeOnThisOrChildren(elt, "sse-swap").forEach(function(child) {
161
162 var sseSwapAttr = api.getAttributeValue(child, "sse-swap");
163 if (sseSwapAttr) {
164 var sseEventNames = sseSwapAttr.split(",");
165 } else {
166 var sseEventNames = getLegacySSESwaps(child);
167 }
168
169 for (var i = 0 ; i < sseEventNames.length ; i++) {
170 var sseEventName = sseEventNames[i].trim();
171 var listener = function(event) {
172
173 // If the parent is missing then close SSE and remove listener
174 if (maybeCloseSSESource(elt)) {
175 source.removeEventListener(sseEventName, listener);
176 return;
177 }
178
179 // swap the response into the DOM and trigger a notification
180 swap(child, event.data);
181 api.triggerEvent(elt, "htmx:sseMessage", event);
182 };
183
184 // Register the new listener
185 api.getInternalData(elt).sseEventListener = listener;
186 source.addEventListener(sseEventName, listener);
187 }
188 });
189
190 // Add message handlers for every `hx-trigger="sse:*"` attribute
191 queryAttributeOnThisOrChildren(elt, "hx-trigger").forEach(function(child) {
192
193 var sseEventName = api.getAttributeValue(child, "hx-trigger");
194 if (sseEventName == null) {
195 return;
196 }
197
198 // Only process hx-triggers for events with the "sse:" prefix
199 if (sseEventName.slice(0, 4) != "sse:") {
200 return;
201 }
202
203 var listener = function(event) {
204
205 // If parent is missing, then close SSE and remove listener
206 if (maybeCloseSSESource(elt)) {
207 source.removeEventListener(sseEventName, listener);
208 return;
209 }
210
211 // Trigger events to be handled by the rest of htmx
212 htmx.trigger(child, sseEventName, event);
213 htmx.trigger(child, "htmx:sseMessage", event);
214 }
215
216 // Register the new listener
217 api.getInternalData(elt).sseEventListener = listener;
218 source.addEventListener(sseEventName.slice(4), listener);
219 });
220 }
221
222 /**
223 * maybeCloseSSESource confirms that the parent element still exists.
224 * If not, then any associated SSE source is closed and the function returns true.
225 *
226 * @param {HTMLElement} elt
227 * @returns boolean
228 */
229 function maybeCloseSSESource(elt) {
230 if (!api.bodyContains(elt)) {
231 var source = api.getInternalData(elt).sseEventSource;
232 if (source != undefined) {
233 source.close();
234 // source = null
235 return true;
236 }
237 }
238 return false;
239 }
240
241 /**
242 * queryAttributeOnThisOrChildren returns all nodes that contain the requested attributeName, INCLUDING THE PROVIDED ROOT ELEMENT.
243 *
244 * @param {HTMLElement} elt
245 * @param {string} attributeName
246 */
247 function queryAttributeOnThisOrChildren(elt, attributeName) {
248
249 var result = [];
250
251 // If the parent element also contains the requested attribute, then add it to the results too.
252 if (api.hasAttribute(elt, attributeName) || api.hasAttribute(elt, "hx-sse")) {
253 result.push(elt);
254 }
255
256 // Search all child nodes that match the requested attribute
257 elt.querySelectorAll("[" + attributeName + "], [data-" + attributeName + "], [hx-sse], [data-hx-sse]").forEach(function(node) {
258 result.push(node);
259 });
260
261 return result;
262 }
263
264 /**
265 * @param {HTMLElement} elt
266 * @param {string} content
267 */
268 function swap(elt, content) {
269
270 api.withExtensions(elt, function(extension) {
271 content = extension.transformResponse(content, null, elt);
272 });
273
274 var swapSpec = api.getSwapSpecification(elt);
275 var target = api.getTarget(elt);
276 var settleInfo = api.makeSettleInfo(elt);
277
278 api.selectAndSwap(swapSpec.swapStyle, target, elt, content, settleInfo);
279
280 settleInfo.elts.forEach(function (elt) {
281 if (elt.classList) {
282 elt.classList.add(htmx.config.settlingClass);
283 }
284 api.triggerEvent(elt, 'htmx:beforeSettle');
285 });
286
287 // Handle settle tasks (with delay if requested)
288 if (swapSpec.settleDelay > 0) {
289 setTimeout(doSettle(settleInfo), swapSpec.settleDelay);
290 } else {
291 doSettle(settleInfo)();
292 }
293 }
294
295 /**
296 * doSettle mirrors much of the functionality in htmx that
297 * settles elements after their content has been swapped.
298 * TODO: this should be published by htmx, and not duplicated here
299 * @param {import("../htmx").HtmxSettleInfo} settleInfo
300 * @returns () => void
301 */
302 function doSettle(settleInfo) {
303
304 return function() {
305 settleInfo.tasks.forEach(function (task) {
306 task.call();
307 });
308
309 settleInfo.elts.forEach(function (elt) {
310 if (elt.classList) {
311 elt.classList.remove(htmx.config.settlingClass);
312 }
313 api.triggerEvent(elt, 'htmx:afterSettle');
314 });
315 }
316 }
317
318})();
\No newline at end of file