Browse code

Update htmx lib

Benjamin Roth authored on08/03/2024 10:15:01
Showing1 changed files
... ...
@@ -52,7 +52,7 @@ This extension adds support for WebSockets to htmx.  See /www/extensions/ws.md f
52 52
 					return;
53 53
 
54 54
 				// Try to create websockets when elements are processed
55
-				case "htmx:afterProcessNode":
55
+				case "htmx:beforeProcessNode":
56 56
 					var parent = evt.target;
57 57
 
58 58
 					forEach(queryAttributeOnThisOrChildren(parent, "ws-connect"), function (child) {
... ...
@@ -200,7 +200,7 @@ This extension adds support for WebSockets to htmx.  See /www/extensions/ws.md f
200 200
 				if (!this.socket) {
201 201
 					api.triggerErrorEvent()
202 202
 				}
203
-				if (sendElt && api.triggerEvent(sendElt, 'htmx:wsBeforeSend', {
203
+				if (!sendElt || api.triggerEvent(sendElt, 'htmx:wsBeforeSend', {
204 204
 					message: message,
205 205
 					socketWrapper: this.publicInterface
206 206
 				})) {
... ...
@@ -341,7 +341,7 @@ This extension adds support for WebSockets to htmx.  See /www/extensions/ws.md f
341 341
 
342 342
 				/** @type {WebSocketWrapper} */
343 343
 				var socketWrapper = api.getInternalData(socketElt).webSocket;
344
-				var headers = api.getHeaders(sendElt, socketElt);
344
+				var headers = api.getHeaders(sendElt, api.getTarget(sendElt));
345 345
 				var results = api.getInputValues(sendElt, 'post');
346 346
 				var errors = results.errors;
347 347
 				var rawParameters = results.values;
... ...
@@ -379,7 +379,7 @@ This extension adds support for WebSockets to htmx.  See /www/extensions/ws.md f
379 379
 
380 380
 				socketWrapper.send(body, elt);
381 381
 
382
-				if (api.shouldCancel(evt, elt)) {
382
+				if (evt && api.shouldCancel(evt, elt)) {
383 383
 					evt.preventDefault();
384 384
 				}
385 385
 			});
Browse code

Initial htmx npm packages installation

Benjamin Roth authored on25/05/2023 09:52:13
Showing1 changed files
1 1
new file mode 100644
... ...
@@ -0,0 +1,477 @@
1
+/*
2
+WebSockets Extension
3
+============================
4
+This extension adds support for WebSockets to htmx.  See /www/extensions/ws.md for usage instructions.
5
+*/
6
+
7
+(function () {
8
+
9
+	/** @type {import("../htmx").HtmxInternalApi} */
10
+	var api;
11
+
12
+	htmx.defineExtension("ws", {
13
+
14
+		/**
15
+		 * init is called once, when this extension is first registered.
16
+		 * @param {import("../htmx").HtmxInternalApi} apiRef
17
+		 */
18
+		init: function (apiRef) {
19
+
20
+			// Store reference to internal API
21
+			api = apiRef;
22
+
23
+			// Default function for creating new EventSource objects
24
+			if (!htmx.createWebSocket) {
25
+				htmx.createWebSocket = createWebSocket;
26
+			}
27
+
28
+			// Default setting for reconnect delay
29
+			if (!htmx.config.wsReconnectDelay) {
30
+				htmx.config.wsReconnectDelay = "full-jitter";
31
+			}
32
+		},
33
+
34
+		/**
35
+		 * onEvent handles all events passed to this extension.
36
+		 *
37
+		 * @param {string} name
38
+		 * @param {Event} evt
39
+		 */
40
+		onEvent: function (name, evt) {
41
+
42
+			switch (name) {
43
+
44
+				// Try to close the socket when elements are removed
45
+				case "htmx:beforeCleanupElement":
46
+
47
+					var internalData = api.getInternalData(evt.target)
48
+
49
+					if (internalData.webSocket) {
50
+						internalData.webSocket.close();
51
+					}
52
+					return;
53
+
54
+				// Try to create websockets when elements are processed
55
+				case "htmx:afterProcessNode":
56
+					var parent = evt.target;
57
+
58
+					forEach(queryAttributeOnThisOrChildren(parent, "ws-connect"), function (child) {
59
+						ensureWebSocket(child)
60
+					});
61
+					forEach(queryAttributeOnThisOrChildren(parent, "ws-send"), function (child) {
62
+						ensureWebSocketSend(child)
63
+					});
64
+			}
65
+		}
66
+	});
67
+
68
+	function splitOnWhitespace(trigger) {
69
+		return trigger.trim().split(/\s+/);
70
+	}
71
+
72
+	function getLegacyWebsocketURL(elt) {
73
+		var legacySSEValue = api.getAttributeValue(elt, "hx-ws");
74
+		if (legacySSEValue) {
75
+			var values = splitOnWhitespace(legacySSEValue);
76
+			for (var i = 0; i < values.length; i++) {
77
+				var value = values[i].split(/:(.+)/);
78
+				if (value[0] === "connect") {
79
+					return value[1];
80
+				}
81
+			}
82
+		}
83
+	}
84
+
85
+	/**
86
+	 * ensureWebSocket creates a new WebSocket on the designated element, using
87
+	 * the element's "ws-connect" attribute.
88
+	 * @param {HTMLElement} socketElt
89
+	 * @returns
90
+	 */
91
+	function ensureWebSocket(socketElt) {
92
+
93
+		// If the element containing the WebSocket connection no longer exists, then
94
+		// do not connect/reconnect the WebSocket.
95
+		if (!api.bodyContains(socketElt)) {
96
+			return;
97
+		}
98
+
99
+		// Get the source straight from the element's value
100
+		var wssSource = api.getAttributeValue(socketElt, "ws-connect")
101
+
102
+		if (wssSource == null || wssSource === "") {
103
+			var legacySource = getLegacyWebsocketURL(socketElt);
104
+			if (legacySource == null) {
105
+				return;
106
+			} else {
107
+				wssSource = legacySource;
108
+			}
109
+		}
110
+
111
+		// Guarantee that the wssSource value is a fully qualified URL
112
+		if (wssSource.indexOf("/") === 0) {
113
+			var base_part = location.hostname + (location.port ? ':' + location.port : '');
114
+			if (location.protocol === 'https:') {
115
+				wssSource = "wss://" + base_part + wssSource;
116
+			} else if (location.protocol === 'http:') {
117
+				wssSource = "ws://" + base_part + wssSource;
118
+			}
119
+		}
120
+
121
+		var socketWrapper = createWebsocketWrapper(socketElt, function () {
122
+			return htmx.createWebSocket(wssSource)
123
+		});
124
+
125
+		socketWrapper.addEventListener('message', function (event) {
126
+			if (maybeCloseWebSocketSource(socketElt)) {
127
+				return;
128
+			}
129
+
130
+			var response = event.data;
131
+			if (!api.triggerEvent(socketElt, "htmx:wsBeforeMessage", {
132
+				message: response,
133
+				socketWrapper: socketWrapper.publicInterface
134
+			})) {
135
+				return;
136
+			}
137
+
138
+			api.withExtensions(socketElt, function (extension) {
139
+				response = extension.transformResponse(response, null, socketElt);
140
+			});
141
+
142
+			var settleInfo = api.makeSettleInfo(socketElt);
143
+			var fragment = api.makeFragment(response);
144
+
145
+			if (fragment.children.length) {
146
+				var children = Array.from(fragment.children);
147
+				for (var i = 0; i < children.length; i++) {
148
+					api.oobSwap(api.getAttributeValue(children[i], "hx-swap-oob") || "true", children[i], settleInfo);
149
+				}
150
+			}
151
+
152
+			api.settleImmediately(settleInfo.tasks);
153
+			api.triggerEvent(socketElt, "htmx:wsAfterMessage", { message: response, socketWrapper: socketWrapper.publicInterface })
154
+		});
155
+
156
+		// Put the WebSocket into the HTML Element's custom data.
157
+		api.getInternalData(socketElt).webSocket = socketWrapper;
158
+	}
159
+
160
+	/**
161
+	 * @typedef {Object} WebSocketWrapper
162
+	 * @property {WebSocket} socket
163
+	 * @property {Array<{message: string, sendElt: Element}>} messageQueue
164
+	 * @property {number} retryCount
165
+	 * @property {(message: string, sendElt: Element) => void} sendImmediately sendImmediately sends message regardless of websocket connection state
166
+	 * @property {(message: string, sendElt: Element) => void} send
167
+	 * @property {(event: string, handler: Function) => void} addEventListener
168
+	 * @property {() => void} handleQueuedMessages
169
+	 * @property {() => void} init
170
+	 * @property {() => void} close
171
+	 */
172
+	/**
173
+	 *
174
+	 * @param socketElt
175
+	 * @param socketFunc
176
+	 * @returns {WebSocketWrapper}
177
+	 */
178
+	function createWebsocketWrapper(socketElt, socketFunc) {
179
+		var wrapper = {
180
+			socket: null,
181
+			messageQueue: [],
182
+			retryCount: 0,
183
+
184
+			/** @type {Object<string, Function[]>} */
185
+			events: {},
186
+
187
+			addEventListener: function (event, handler) {
188
+				if (this.socket) {
189
+					this.socket.addEventListener(event, handler);
190
+				}
191
+
192
+				if (!this.events[event]) {
193
+					this.events[event] = [];
194
+				}
195
+
196
+				this.events[event].push(handler);
197
+			},
198
+
199
+			sendImmediately: function (message, sendElt) {
200
+				if (!this.socket) {
201
+					api.triggerErrorEvent()
202
+				}
203
+				if (sendElt && api.triggerEvent(sendElt, 'htmx:wsBeforeSend', {
204
+					message: message,
205
+					socketWrapper: this.publicInterface
206
+				})) {
207
+					this.socket.send(message);
208
+					sendElt && api.triggerEvent(sendElt, 'htmx:wsAfterSend', {
209
+						message: message,
210
+						socketWrapper: this.publicInterface
211
+					})
212
+				}
213
+			},
214
+
215
+			send: function (message, sendElt) {
216
+				if (this.socket.readyState !== this.socket.OPEN) {
217
+					this.messageQueue.push({ message: message, sendElt: sendElt });
218
+				} else {
219
+					this.sendImmediately(message, sendElt);
220
+				}
221
+			},
222
+
223
+			handleQueuedMessages: function () {
224
+				while (this.messageQueue.length > 0) {
225
+					var queuedItem = this.messageQueue[0]
226
+					if (this.socket.readyState === this.socket.OPEN) {
227
+						this.sendImmediately(queuedItem.message, queuedItem.sendElt);
228
+						this.messageQueue.shift();
229
+					} else {
230
+						break;
231
+					}
232
+				}
233
+			},
234
+
235
+			init: function () {
236
+				if (this.socket && this.socket.readyState === this.socket.OPEN) {
237
+					// Close discarded socket
238
+					this.socket.close()
239
+				}
240
+
241
+				// Create a new WebSocket and event handlers
242
+				/** @type {WebSocket} */
243
+				var socket = socketFunc();
244
+
245
+				// The event.type detail is added for interface conformance with the
246
+				// other two lifecycle events (open and close) so a single handler method
247
+				// can handle them polymorphically, if required.
248
+				api.triggerEvent(socketElt, "htmx:wsConnecting", { event: { type: 'connecting' } });
249
+
250
+				this.socket = socket;
251
+
252
+				socket.onopen = function (e) {
253
+					wrapper.retryCount = 0;
254
+					api.triggerEvent(socketElt, "htmx:wsOpen", { event: e, socketWrapper: wrapper.publicInterface });
255
+					wrapper.handleQueuedMessages();
256
+				}
257
+
258
+				socket.onclose = function (e) {
259
+					// If socket should not be connected, stop further attempts to establish connection
260
+					// If Abnormal Closure/Service Restart/Try Again Later, then set a timer to reconnect after a pause.
261
+					if (!maybeCloseWebSocketSource(socketElt) && [1006, 1012, 1013].indexOf(e.code) >= 0) {
262
+						var delay = getWebSocketReconnectDelay(wrapper.retryCount);
263
+						setTimeout(function () {
264
+							wrapper.retryCount += 1;
265
+							wrapper.init();
266
+						}, delay);
267
+					}
268
+
269
+					// Notify client code that connection has been closed. Client code can inspect `event` field
270
+					// to determine whether closure has been valid or abnormal
271
+					api.triggerEvent(socketElt, "htmx:wsClose", { event: e, socketWrapper: wrapper.publicInterface })
272
+				};
273
+
274
+				socket.onerror = function (e) {
275
+					api.triggerErrorEvent(socketElt, "htmx:wsError", { error: e, socketWrapper: wrapper });
276
+					maybeCloseWebSocketSource(socketElt);
277
+				};
278
+
279
+				var events = this.events;
280
+				Object.keys(events).forEach(function (k) {
281
+					events[k].forEach(function (e) {
282
+						socket.addEventListener(k, e);
283
+					})
284
+				});
285
+			},
286
+
287
+			close: function () {
288
+				this.socket.close()
289
+			}
290
+		}
291
+
292
+		wrapper.init();
293
+
294
+		wrapper.publicInterface = {
295
+			send: wrapper.send.bind(wrapper),
296
+			sendImmediately: wrapper.sendImmediately.bind(wrapper),
297
+			queue: wrapper.messageQueue
298
+		};
299
+
300
+		return wrapper;
301
+	}
302
+
303
+	/**
304
+	 * ensureWebSocketSend attaches trigger handles to elements with
305
+	 * "ws-send" attribute
306
+	 * @param {HTMLElement} elt
307
+	 */
308
+	function ensureWebSocketSend(elt) {
309
+		var legacyAttribute = api.getAttributeValue(elt, "hx-ws");
310
+		if (legacyAttribute && legacyAttribute !== 'send') {
311
+			return;
312
+		}
313
+
314
+		var webSocketParent = api.getClosestMatch(elt, hasWebSocket)
315
+		processWebSocketSend(webSocketParent, elt);
316
+	}
317
+
318
+	/**
319
+	 * hasWebSocket function checks if a node has webSocket instance attached
320
+	 * @param {HTMLElement} node
321
+	 * @returns {boolean}
322
+	 */
323
+	function hasWebSocket(node) {
324
+		return api.getInternalData(node).webSocket != null;
325
+	}
326
+
327
+	/**
328
+	 * processWebSocketSend adds event listeners to the <form> element so that
329
+	 * messages can be sent to the WebSocket server when the form is submitted.
330
+	 * @param {HTMLElement} socketElt
331
+	 * @param {HTMLElement} sendElt
332
+	 */
333
+	function processWebSocketSend(socketElt, sendElt) {
334
+		var nodeData = api.getInternalData(sendElt);
335
+		var triggerSpecs = api.getTriggerSpecs(sendElt);
336
+		triggerSpecs.forEach(function (ts) {
337
+			api.addTriggerHandler(sendElt, ts, nodeData, function (elt, evt) {
338
+				if (maybeCloseWebSocketSource(socketElt)) {
339
+					return;
340
+				}
341
+
342
+				/** @type {WebSocketWrapper} */
343
+				var socketWrapper = api.getInternalData(socketElt).webSocket;
344
+				var headers = api.getHeaders(sendElt, socketElt);
345
+				var results = api.getInputValues(sendElt, 'post');
346
+				var errors = results.errors;
347
+				var rawParameters = results.values;
348
+				var expressionVars = api.getExpressionVars(sendElt);
349
+				var allParameters = api.mergeObjects(rawParameters, expressionVars);
350
+				var filteredParameters = api.filterValues(allParameters, sendElt);
351
+
352
+				var sendConfig = {
353
+					parameters: filteredParameters,
354
+					unfilteredParameters: allParameters,
355
+					headers: headers,
356
+					errors: errors,
357
+
358
+					triggeringEvent: evt,
359
+					messageBody: undefined,
360
+					socketWrapper: socketWrapper.publicInterface
361
+				};
362
+
363
+				if (!api.triggerEvent(elt, 'htmx:wsConfigSend', sendConfig)) {
364
+					return;
365
+				}
366
+
367
+				if (errors && errors.length > 0) {
368
+					api.triggerEvent(elt, 'htmx:validation:halted', errors);
369
+					return;
370
+				}
371
+
372
+				var body = sendConfig.messageBody;
373
+				if (body === undefined) {
374
+					var toSend = Object.assign({}, sendConfig.parameters);
375
+					if (sendConfig.headers)
376
+						toSend['HEADERS'] = headers;
377
+					body = JSON.stringify(toSend);
378
+				}
379
+
380
+				socketWrapper.send(body, elt);
381
+
382
+				if (api.shouldCancel(evt, elt)) {
383
+					evt.preventDefault();
384
+				}
385
+			});
386
+		});
387
+	}
388
+
389
+	/**
390
+	 * getWebSocketReconnectDelay is the default easing function for WebSocket reconnects.
391
+	 * @param {number} retryCount // The number of retries that have already taken place
392
+	 * @returns {number}
393
+	 */
394
+	function getWebSocketReconnectDelay(retryCount) {
395
+
396
+		/** @type {"full-jitter" | ((retryCount:number) => number)} */
397
+		var delay = htmx.config.wsReconnectDelay;
398
+		if (typeof delay === 'function') {
399
+			return delay(retryCount);
400
+		}
401
+		if (delay === 'full-jitter') {
402
+			var exp = Math.min(retryCount, 6);
403
+			var maxDelay = 1000 * Math.pow(2, exp);
404
+			return maxDelay * Math.random();
405
+		}
406
+
407
+		logError('htmx.config.wsReconnectDelay must either be a function or the string "full-jitter"');
408
+	}
409
+
410
+	/**
411
+	 * maybeCloseWebSocketSource checks to the if the element that created the WebSocket
412
+	 * still exists in the DOM.  If NOT, then the WebSocket is closed and this function
413
+	 * returns TRUE.  If the element DOES EXIST, then no action is taken, and this function
414
+	 * returns FALSE.
415
+	 *
416
+	 * @param {*} elt
417
+	 * @returns
418
+	 */
419
+	function maybeCloseWebSocketSource(elt) {
420
+		if (!api.bodyContains(elt)) {
421
+			api.getInternalData(elt).webSocket.close();
422
+			return true;
423
+		}
424
+		return false;
425
+	}
426
+
427
+	/**
428
+	 * createWebSocket is the default method for creating new WebSocket objects.
429
+	 * it is hoisted into htmx.createWebSocket to be overridden by the user, if needed.
430
+	 *
431
+	 * @param {string} url
432
+	 * @returns WebSocket
433
+	 */
434
+	function createWebSocket(url) {
435
+		var sock = new WebSocket(url, []);
436
+		sock.binaryType = htmx.config.wsBinaryType;
437
+		return sock;
438
+	}
439
+
440
+	/**
441
+	 * queryAttributeOnThisOrChildren returns all nodes that contain the requested attributeName, INCLUDING THE PROVIDED ROOT ELEMENT.
442
+	 *
443
+	 * @param {HTMLElement} elt
444
+	 * @param {string} attributeName
445
+	 */
446
+	function queryAttributeOnThisOrChildren(elt, attributeName) {
447
+
448
+		var result = []
449
+
450
+		// If the parent element also contains the requested attribute, then add it to the results too.
451
+		if (api.hasAttribute(elt, attributeName) || api.hasAttribute(elt, "hx-ws")) {
452
+			result.push(elt);
453
+		}
454
+
455
+		// Search all child nodes that match the requested attribute
456
+		elt.querySelectorAll("[" + attributeName + "], [data-" + attributeName + "], [data-hx-ws], [hx-ws]").forEach(function (node) {
457
+			result.push(node)
458
+		})
459
+
460
+		return result
461
+	}
462
+
463
+	/**
464
+	 * @template T
465
+	 * @param {T[]} arr
466
+	 * @param {(T) => void} func
467
+	 */
468
+	function forEach(arr, func) {
469
+		if (arr) {
470
+			for (var i = 0; i < arr.length; i++) {
471
+				func(arr[i]);
472
+			}
473
+		}
474
+	}
475
+
476
+})();
477
+