htmx.org 2.0.0-alpha2 → 2.0.0-beta1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,369 @@
1
+ /*
2
+ Server Sent Events Extension
3
+ ============================
4
+ This 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
+ })();