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
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