UNPKG

9.95 kBJavaScriptView Raw
1/*
2WebSockets Extension
3============================
4This 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 == undefined) {
25 htmx.createWebSocket = createWebSocket;
26 }
27
28 // Default setting for reconnect delay
29 if (htmx.config.wsReconnectDelay == undefined) {
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 remove remove an EventSource when elements are removed
45 case "htmx:beforeCleanupElement":
46
47 var internalData = api.getInternalData(evt.target)
48
49 if (internalData.webSocket != undefined) {
50 internalData.webSocket.close();
51 }
52 return;
53
54 // Try to create EventSources 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} elt
89 * @param {number=} retryCount
90 * @returns
91 */
92 function ensureWebSocket(elt, retryCount) {
93
94 // If the element containing the WebSocket connection no longer exists, then
95 // do not connect/reconnect the WebSocket.
96 if (!api.bodyContains(elt)) {
97 return;
98 }
99
100 // Get the source straight from the element's value
101 var wssSource = api.getAttributeValue(elt, "ws-connect")
102
103 if (wssSource == null || wssSource === "") {
104 var legacySource = getLegacyWebsocketURL(elt);
105 if (legacySource == null) {
106 return;
107 } else {
108 wssSource = legacySource;
109 }
110 }
111
112 // Default value for retryCount
113 if (retryCount == undefined) {
114 retryCount = 0;
115 }
116
117 // Guarantee that the wssSource value is a fully qualified URL
118 if (wssSource.indexOf("/") == 0) {
119 var base_part = location.hostname + (location.port ? ':'+location.port: '');
120 if (location.protocol == 'https:') {
121 wssSource = "wss://" + base_part + wssSource;
122 } else if (location.protocol == 'http:') {
123 wssSource = "ws://" + base_part + wssSource;
124 }
125 }
126
127 // Create a new WebSocket and event handlers
128 /** @type {WebSocket} */
129 var socket = htmx.createWebSocket(wssSource);
130
131 var messageQueue = [];
132
133 socket.onopen = function (e) {
134 retryCount = 0;
135 handleQueuedMessages(messageQueue, socket);
136 }
137
138 socket.onclose = function (e) {
139 // If Abnormal Closure/Service Restart/Try Again Later, then set a timer to reconnect after a pause.
140 if ([1006, 1012, 1013].indexOf(e.code) >= 0) {
141 var delay = getWebSocketReconnectDelay(retryCount);
142 setTimeout(function() {
143 ensureWebSocket(elt, retryCount+1);
144 }, delay);
145 }
146 };
147
148 socket.onerror = function (e) {
149 api.triggerErrorEvent(elt, "htmx:wsError", {error:e, socket:socket});
150 maybeCloseWebSocketSource(elt);
151 };
152
153 socket.addEventListener('message', function (event) {
154 if (maybeCloseWebSocketSource(elt)) {
155 return;
156 }
157
158 var response = event.data;
159 api.withExtensions(elt, function(extension){
160 response = extension.transformResponse(response, null, elt);
161 });
162
163 var settleInfo = api.makeSettleInfo(elt);
164 var fragment = api.makeFragment(response);
165
166 if (fragment.children.length) {
167 var children = Array.from(fragment.children);
168 for (var i = 0; i < children.length; i++) {
169 api.oobSwap(api.getAttributeValue(children[i], "hx-swap-oob") || "true", children[i], settleInfo);
170 }
171 }
172
173 api.settleImmediately(settleInfo.tasks);
174 });
175
176 // Put the WebSocket into the HTML Element's custom data.
177 api.getInternalData(elt).webSocket = socket;
178 api.getInternalData(elt).webSocketMessageQueue = messageQueue;
179 }
180
181 /**
182 * ensureWebSocketSend attaches trigger handles to elements with
183 * "ws-send" attribute
184 * @param {HTMLElement} elt
185 */
186 function ensureWebSocketSend(elt) {
187 var legacyAttribute = api.getAttributeValue(elt, "hx-ws");
188 if (legacyAttribute && legacyAttribute !== 'send') {
189 return;
190 }
191
192 var webSocketParent = api.getClosestMatch(elt, hasWebSocket)
193 processWebSocketSend(webSocketParent, elt);
194 }
195
196 /**
197 * hasWebSocket function checks if a node has webSocket instance attached
198 * @param {HTMLElement} node
199 * @returns {boolean}
200 */
201 function hasWebSocket(node) {
202 return api.getInternalData(node).webSocket != null;
203 }
204
205 /**
206 * processWebSocketSend adds event listeners to the <form> element so that
207 * messages can be sent to the WebSocket server when the form is submitted.
208 * @param {HTMLElement} parent
209 * @param {HTMLElement} child
210 */
211 function processWebSocketSend(parent, child) {
212 var nodeData = api.getInternalData(child);
213 let triggerSpecs = api.getTriggerSpecs(child);
214 triggerSpecs.forEach(function(ts) {
215 api.addTriggerHandler(child, ts, nodeData, function (evt) {
216 var webSocket = api.getInternalData(parent).webSocket;
217 var messageQueue = api.getInternalData(parent).webSocketMessageQueue;
218 var headers = api.getHeaders(child, parent);
219 var results = api.getInputValues(child, 'post');
220 var errors = results.errors;
221 var rawParameters = results.values;
222 var expressionVars = api.getExpressionVars(child);
223 var allParameters = api.mergeObjects(rawParameters, expressionVars);
224 var filteredParameters = api.filterValues(allParameters, child);
225 filteredParameters['HEADERS'] = headers;
226 if (errors && errors.length > 0) {
227 api.triggerEvent(child, 'htmx:validation:halted', errors);
228 return;
229 }
230 webSocketSend(webSocket, JSON.stringify(filteredParameters), messageQueue);
231 if(api.shouldCancel(evt, child)){
232 evt.preventDefault();
233 }
234 });
235 });
236 }
237
238 /**
239 * webSocketSend provides a safe way to send messages through a WebSocket.
240 * It checks that the socket is in OPEN state and, otherwise, awaits for it.
241 * @param {WebSocket} socket
242 * @param {string} message
243 * @param {string[]} messageQueue
244 * @return {boolean}
245 */
246 function webSocketSend(socket, message, messageQueue) {
247 if (socket.readyState != socket.OPEN) {
248 messageQueue.push(message);
249 } else {
250 socket.send(message);
251 }
252 }
253
254 /**
255 * handleQueuedMessages sends messages awaiting in the message queue
256 */
257 function handleQueuedMessages(messageQueue, socket) {
258 while (messageQueue.length > 0) {
259 var message = messageQueue[0]
260 if (socket.readyState == socket.OPEN) {
261 socket.send(message);
262 messageQueue.shift()
263 } else {
264 break;
265 }
266 }
267 }
268
269 /**
270 * getWebSocketReconnectDelay is the default easing function for WebSocket reconnects.
271 * @param {number} retryCount // The number of retries that have already taken place
272 * @returns {number}
273 */
274 function getWebSocketReconnectDelay(retryCount) {
275
276 /** @type {"full-jitter" | (retryCount:number) => number} */
277 var delay = htmx.config.wsReconnectDelay;
278 if (typeof delay === 'function') {
279 return delay(retryCount);
280 }
281 if (delay === 'full-jitter') {
282 var exp = Math.min(retryCount, 6);
283 var maxDelay = 1000 * Math.pow(2, exp);
284 return maxDelay * Math.random();
285 }
286
287 logError('htmx.config.wsReconnectDelay must either be a function or the string "full-jitter"');
288 }
289
290 /**
291 * maybeCloseWebSocketSource checks to the if the element that created the WebSocket
292 * still exists in the DOM. If NOT, then the WebSocket is closed and this function
293 * returns TRUE. If the element DOES EXIST, then no action is taken, and this function
294 * returns FALSE.
295 *
296 * @param {*} elt
297 * @returns
298 */
299 function maybeCloseWebSocketSource(elt) {
300 if (!api.bodyContains(elt)) {
301 api.getInternalData(elt).webSocket.close();
302 return true;
303 }
304 return false;
305 }
306
307 /**
308 * createWebSocket is the default method for creating new WebSocket objects.
309 * it is hoisted into htmx.createWebSocket to be overridden by the user, if needed.
310 *
311 * @param {string} url
312 * @returns WebSocket
313 */
314 function createWebSocket(url){
315 return new WebSocket(url, []);
316 }
317
318 /**
319 * queryAttributeOnThisOrChildren returns all nodes that contain the requested attributeName, INCLUDING THE PROVIDED ROOT ELEMENT.
320 *
321 * @param {HTMLElement} elt
322 * @param {string} attributeName
323 */
324 function queryAttributeOnThisOrChildren(elt, attributeName) {
325
326 var result = []
327
328 // If the parent element also contains the requested attribute, then add it to the results too.
329 if (api.hasAttribute(elt, attributeName) || api.hasAttribute(elt, "hx-ws")) {
330 result.push(elt);
331 }
332
333 // Search all child nodes that match the requested attribute
334 elt.querySelectorAll("[" + attributeName + "], [data-" + attributeName + "], [data-hx-ws], [hx-ws]").forEach(function(node) {
335 result.push(node)
336 })
337
338 return result
339 }
340
341 /**
342 * @template T
343 * @param {T[]} arr
344 * @param {(T) => void} func
345 */
346 function forEach(arr, func) {
347 if (arr) {
348 for (var i = 0; i < arr.length; i++) {
349 func(arr[i]);
350 }
351 }
352 }
353
354})();
355