UNPKG

8.66 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 source.onopen = function (evt) {
160 api.triggerEvent(elt, "htmx::sseOpen", {source: source});
161 }
162
163 // Add message handlers for every `sse-swap` attribute
164 queryAttributeOnThisOrChildren(elt, "sse-swap").forEach(function(child) {
165
166 var sseSwapAttr = api.getAttributeValue(child, "sse-swap");
167 if (sseSwapAttr) {
168 var sseEventNames = sseSwapAttr.split(",");
169 } else {
170 var sseEventNames = getLegacySSESwaps(child);
171 }
172
173 for (var i = 0 ; i < sseEventNames.length ; i++) {
174 var sseEventName = sseEventNames[i].trim();
175 var listener = function(event) {
176
177 // If the parent is missing then close SSE and remove listener
178 if (maybeCloseSSESource(elt)) {
179 source.removeEventListener(sseEventName, listener);
180 return;
181 }
182
183 // swap the response into the DOM and trigger a notification
184 swap(child, event.data);
185 api.triggerEvent(elt, "htmx:sseMessage", event);
186 };
187
188 // Register the new listener
189 api.getInternalData(elt).sseEventListener = listener;
190 source.addEventListener(sseEventName, listener);
191 }
192 });
193
194 // Add message handlers for every `hx-trigger="sse:*"` attribute
195 queryAttributeOnThisOrChildren(elt, "hx-trigger").forEach(function(child) {
196
197 var sseEventName = api.getAttributeValue(child, "hx-trigger");
198 if (sseEventName == null) {
199 return;
200 }
201
202 // Only process hx-triggers for events with the "sse:" prefix
203 if (sseEventName.slice(0, 4) != "sse:") {
204 return;
205 }
206
207 var listener = function(event) {
208
209 // If parent is missing, then close SSE and remove listener
210 if (maybeCloseSSESource(elt)) {
211 source.removeEventListener(sseEventName, listener);
212 return;
213 }
214
215 // Trigger events to be handled by the rest of htmx
216 htmx.trigger(child, sseEventName, event);
217 htmx.trigger(child, "htmx:sseMessage", event);
218 }
219
220 // Register the new listener
221 api.getInternalData(elt).sseEventListener = listener;
222 source.addEventListener(sseEventName.slice(4), listener);
223 });
224 }
225
226 /**
227 * maybeCloseSSESource confirms that the parent element still exists.
228 * If not, then any associated SSE source is closed and the function returns true.
229 *
230 * @param {HTMLElement} elt
231 * @returns boolean
232 */
233 function maybeCloseSSESource(elt) {
234 if (!api.bodyContains(elt)) {
235 var source = api.getInternalData(elt).sseEventSource;
236 if (source != undefined) {
237 source.close();
238 // source = null
239 return true;
240 }
241 }
242 return false;
243 }
244
245 /**
246 * queryAttributeOnThisOrChildren returns all nodes that contain the requested attributeName, INCLUDING THE PROVIDED ROOT ELEMENT.
247 *
248 * @param {HTMLElement} elt
249 * @param {string} attributeName
250 */
251 function queryAttributeOnThisOrChildren(elt, attributeName) {
252
253 var result = [];
254
255 // If the parent element also contains the requested attribute, then add it to the results too.
256 if (api.hasAttribute(elt, attributeName) || api.hasAttribute(elt, "hx-sse")) {
257 result.push(elt);
258 }
259
260 // Search all child nodes that match the requested attribute
261 elt.querySelectorAll("[" + attributeName + "], [data-" + attributeName + "], [hx-sse], [data-hx-sse]").forEach(function(node) {
262 result.push(node);
263 });
264
265 return result;
266 }
267
268 /**
269 * @param {HTMLElement} elt
270 * @param {string} content
271 */
272 function swap(elt, content) {
273
274 api.withExtensions(elt, function(extension) {
275 content = extension.transformResponse(content, null, elt);
276 });
277
278 var swapSpec = api.getSwapSpecification(elt);
279 var target = api.getTarget(elt);
280 var settleInfo = api.makeSettleInfo(elt);
281
282 api.selectAndSwap(swapSpec.swapStyle, target, elt, content, settleInfo);
283
284 settleInfo.elts.forEach(function (elt) {
285 if (elt.classList) {
286 elt.classList.add(htmx.config.settlingClass);
287 }
288 api.triggerEvent(elt, 'htmx:beforeSettle');
289 });
290
291 // Handle settle tasks (with delay if requested)
292 if (swapSpec.settleDelay > 0) {
293 setTimeout(doSettle(settleInfo), swapSpec.settleDelay);
294 } else {
295 doSettle(settleInfo)();
296 }
297 }
298
299 /**
300 * doSettle mirrors much of the functionality in htmx that
301 * settles elements after their content has been swapped.
302 * TODO: this should be published by htmx, and not duplicated here
303 * @param {import("../htmx").HtmxSettleInfo} settleInfo
304 * @returns () => void
305 */
306 function doSettle(settleInfo) {
307
308 return function() {
309 settleInfo.tasks.forEach(function (task) {
310 task.call();
311 });
312
313 settleInfo.elts.forEach(function (elt) {
314 if (elt.classList) {
315 elt.classList.remove(htmx.config.settlingClass);
316 }
317 api.triggerEvent(elt, 'htmx:afterSettle');
318 });
319 }
320 }
321
322})();
\No newline at end of file