0.0.970 cdbcd03

htmx-sse.js RawHistoryFolder

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})();

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close