... | ... |
@@ -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 |
}); |
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 |
+ |