UNPKG

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