aboutsummaryrefslogtreecommitdiff
path: root/static/js/ext/ws.js
diff options
context:
space:
mode:
Diffstat (limited to 'static/js/ext/ws.js')
-rw-r--r--static/js/ext/ws.js481
1 files changed, 481 insertions, 0 deletions
diff --git a/static/js/ext/ws.js b/static/js/ext/ws.js
new file mode 100644
index 0000000..3d7e44d
--- /dev/null
+++ b/static/js/ext/ws.js
@@ -0,0 +1,481 @@
+/*
+WebSockets Extension
+============================
+This extension adds support for WebSockets to htmx. See /www/extensions/ws.md for usage instructions.
+*/
+
+(function () {
+
+ if (htmx.version && !htmx.version.startsWith("1.")) {
+ console.warn("WARNING: You are using an htmx 1 extension with htmx " + htmx.version +
+ ". It is recommended that you move to the version of this extension found on https://htmx.org/extensions")
+ }
+
+ /** @type {import("../htmx").HtmxInternalApi} */
+ var api;
+
+ htmx.defineExtension("ws", {
+
+ /**
+ * init is called once, when this extension is first registered.
+ * @param {import("../htmx").HtmxInternalApi} apiRef
+ */
+ init: function (apiRef) {
+
+ // Store reference to internal API
+ api = apiRef;
+
+ // Default function for creating new EventSource objects
+ if (!htmx.createWebSocket) {
+ htmx.createWebSocket = createWebSocket;
+ }
+
+ // Default setting for reconnect delay
+ if (!htmx.config.wsReconnectDelay) {
+ htmx.config.wsReconnectDelay = "full-jitter";
+ }
+ },
+
+ /**
+ * onEvent handles all events passed to this extension.
+ *
+ * @param {string} name
+ * @param {Event} evt
+ */
+ onEvent: function (name, evt) {
+ var parent = evt.target || evt.detail.elt;
+
+ switch (name) {
+
+ // Try to close the socket when elements are removed
+ case "htmx:beforeCleanupElement":
+
+ var internalData = api.getInternalData(parent)
+
+ if (internalData.webSocket) {
+ internalData.webSocket.close();
+ }
+ return;
+
+ // Try to create websockets when elements are processed
+ case "htmx:beforeProcessNode":
+ forEach(queryAttributeOnThisOrChildren(parent, "ws-connect"), function (child) {
+ ensureWebSocket(child)
+ });
+ forEach(queryAttributeOnThisOrChildren(parent, "ws-send"), function (child) {
+ ensureWebSocketSend(child)
+ });
+ }
+ }
+ });
+
+ function splitOnWhitespace(trigger) {
+ return trigger.trim().split(/\s+/);
+ }
+
+ function getLegacyWebsocketURL(elt) {
+ var legacySSEValue = api.getAttributeValue(elt, "hx-ws");
+ if (legacySSEValue) {
+ var values = splitOnWhitespace(legacySSEValue);
+ for (var i = 0; i < values.length; i++) {
+ var value = values[i].split(/:(.+)/);
+ if (value[0] === "connect") {
+ return value[1];
+ }
+ }
+ }
+ }
+
+ /**
+ * ensureWebSocket creates a new WebSocket on the designated element, using
+ * the element's "ws-connect" attribute.
+ * @param {HTMLElement} socketElt
+ * @returns
+ */
+ function ensureWebSocket(socketElt) {
+
+ // If the element containing the WebSocket connection no longer exists, then
+ // do not connect/reconnect the WebSocket.
+ if (!api.bodyContains(socketElt)) {
+ return;
+ }
+
+ // Get the source straight from the element's value
+ var wssSource = api.getAttributeValue(socketElt, "ws-connect")
+
+ if (wssSource == null || wssSource === "") {
+ var legacySource = getLegacyWebsocketURL(socketElt);
+ if (legacySource == null) {
+ return;
+ } else {
+ wssSource = legacySource;
+ }
+ }
+
+ // Guarantee that the wssSource value is a fully qualified URL
+ if (wssSource.indexOf("/") === 0) {
+ var base_part = location.hostname + (location.port ? ':' + location.port : '');
+ if (location.protocol === 'https:') {
+ wssSource = "wss://" + base_part + wssSource;
+ } else if (location.protocol === 'http:') {
+ wssSource = "ws://" + base_part + wssSource;
+ }
+ }
+
+ var socketWrapper = createWebsocketWrapper(socketElt, function () {
+ return htmx.createWebSocket(wssSource)
+ });
+
+ socketWrapper.addEventListener('message', function (event) {
+ if (maybeCloseWebSocketSource(socketElt)) {
+ return;
+ }
+
+ var response = event.data;
+ if (!api.triggerEvent(socketElt, "htmx:wsBeforeMessage", {
+ message: response,
+ socketWrapper: socketWrapper.publicInterface
+ })) {
+ return;
+ }
+
+ api.withExtensions(socketElt, function (extension) {
+ response = extension.transformResponse(response, null, socketElt);
+ });
+
+ var settleInfo = api.makeSettleInfo(socketElt);
+ var fragment = api.makeFragment(response);
+
+ if (fragment.children.length) {
+ var children = Array.from(fragment.children);
+ for (var i = 0; i < children.length; i++) {
+ api.oobSwap(api.getAttributeValue(children[i], "hx-swap-oob") || "true", children[i], settleInfo);
+ }
+ }
+
+ api.settleImmediately(settleInfo.tasks);
+ api.triggerEvent(socketElt, "htmx:wsAfterMessage", { message: response, socketWrapper: socketWrapper.publicInterface })
+ });
+
+ // Put the WebSocket into the HTML Element's custom data.
+ api.getInternalData(socketElt).webSocket = socketWrapper;
+ }
+
+ /**
+ * @typedef {Object} WebSocketWrapper
+ * @property {WebSocket} socket
+ * @property {Array<{message: string, sendElt: Element}>} messageQueue
+ * @property {number} retryCount
+ * @property {(message: string, sendElt: Element) => void} sendImmediately sendImmediately sends message regardless of websocket connection state
+ * @property {(message: string, sendElt: Element) => void} send
+ * @property {(event: string, handler: Function) => void} addEventListener
+ * @property {() => void} handleQueuedMessages
+ * @property {() => void} init
+ * @property {() => void} close
+ */
+ /**
+ *
+ * @param socketElt
+ * @param socketFunc
+ * @returns {WebSocketWrapper}
+ */
+ function createWebsocketWrapper(socketElt, socketFunc) {
+ var wrapper = {
+ socket: null,
+ messageQueue: [],
+ retryCount: 0,
+
+ /** @type {Object<string, Function[]>} */
+ events: {},
+
+ addEventListener: function (event, handler) {
+ if (this.socket) {
+ this.socket.addEventListener(event, handler);
+ }
+
+ if (!this.events[event]) {
+ this.events[event] = [];
+ }
+
+ this.events[event].push(handler);
+ },
+
+ sendImmediately: function (message, sendElt) {
+ if (!this.socket) {
+ api.triggerErrorEvent()
+ }
+ if (!sendElt || api.triggerEvent(sendElt, 'htmx:wsBeforeSend', {
+ message: message,
+ socketWrapper: this.publicInterface
+ })) {
+ this.socket.send(message);
+ sendElt && api.triggerEvent(sendElt, 'htmx:wsAfterSend', {
+ message: message,
+ socketWrapper: this.publicInterface
+ })
+ }
+ },
+
+ send: function (message, sendElt) {
+ if (this.socket.readyState !== this.socket.OPEN) {
+ this.messageQueue.push({ message: message, sendElt: sendElt });
+ } else {
+ this.sendImmediately(message, sendElt);
+ }
+ },
+
+ handleQueuedMessages: function () {
+ while (this.messageQueue.length > 0) {
+ var queuedItem = this.messageQueue[0]
+ if (this.socket.readyState === this.socket.OPEN) {
+ this.sendImmediately(queuedItem.message, queuedItem.sendElt);
+ this.messageQueue.shift();
+ } else {
+ break;
+ }
+ }
+ },
+
+ init: function () {
+ if (this.socket && this.socket.readyState === this.socket.OPEN) {
+ // Close discarded socket
+ this.socket.close()
+ }
+
+ // Create a new WebSocket and event handlers
+ /** @type {WebSocket} */
+ var socket = socketFunc();
+
+ // The event.type detail is added for interface conformance with the
+ // other two lifecycle events (open and close) so a single handler method
+ // can handle them polymorphically, if required.
+ api.triggerEvent(socketElt, "htmx:wsConnecting", { event: { type: 'connecting' } });
+
+ this.socket = socket;
+
+ socket.onopen = function (e) {
+ wrapper.retryCount = 0;
+ api.triggerEvent(socketElt, "htmx:wsOpen", { event: e, socketWrapper: wrapper.publicInterface });
+ wrapper.handleQueuedMessages();
+ }
+
+ socket.onclose = function (e) {
+ // If socket should not be connected, stop further attempts to establish connection
+ // If Abnormal Closure/Service Restart/Try Again Later, then set a timer to reconnect after a pause.
+ if (!maybeCloseWebSocketSource(socketElt) && [1006, 1012, 1013].indexOf(e.code) >= 0) {
+ var delay = getWebSocketReconnectDelay(wrapper.retryCount);
+ setTimeout(function () {
+ wrapper.retryCount += 1;
+ wrapper.init();
+ }, delay);
+ }
+
+ // Notify client code that connection has been closed. Client code can inspect `event` field
+ // to determine whether closure has been valid or abnormal
+ api.triggerEvent(socketElt, "htmx:wsClose", { event: e, socketWrapper: wrapper.publicInterface })
+ };
+
+ socket.onerror = function (e) {
+ api.triggerErrorEvent(socketElt, "htmx:wsError", { error: e, socketWrapper: wrapper });
+ maybeCloseWebSocketSource(socketElt);
+ };
+
+ var events = this.events;
+ Object.keys(events).forEach(function (k) {
+ events[k].forEach(function (e) {
+ socket.addEventListener(k, e);
+ })
+ });
+ },
+
+ close: function () {
+ this.socket.close()
+ }
+ }
+
+ wrapper.init();
+
+ wrapper.publicInterface = {
+ send: wrapper.send.bind(wrapper),
+ sendImmediately: wrapper.sendImmediately.bind(wrapper),
+ queue: wrapper.messageQueue
+ };
+
+ return wrapper;
+ }
+
+ /**
+ * ensureWebSocketSend attaches trigger handles to elements with
+ * "ws-send" attribute
+ * @param {HTMLElement} elt
+ */
+ function ensureWebSocketSend(elt) {
+ var legacyAttribute = api.getAttributeValue(elt, "hx-ws");
+ if (legacyAttribute && legacyAttribute !== 'send') {
+ return;
+ }
+
+ var webSocketParent = api.getClosestMatch(elt, hasWebSocket)
+ processWebSocketSend(webSocketParent, elt);
+ }
+
+ /**
+ * hasWebSocket function checks if a node has webSocket instance attached
+ * @param {HTMLElement} node
+ * @returns {boolean}
+ */
+ function hasWebSocket(node) {
+ return api.getInternalData(node).webSocket != null;
+ }
+
+ /**
+ * processWebSocketSend adds event listeners to the <form> element so that
+ * messages can be sent to the WebSocket server when the form is submitted.
+ * @param {HTMLElement} socketElt
+ * @param {HTMLElement} sendElt
+ */
+ function processWebSocketSend(socketElt, sendElt) {
+ var nodeData = api.getInternalData(sendElt);
+ var triggerSpecs = api.getTriggerSpecs(sendElt);
+ triggerSpecs.forEach(function (ts) {
+ api.addTriggerHandler(sendElt, ts, nodeData, function (elt, evt) {
+ if (maybeCloseWebSocketSource(socketElt)) {
+ return;
+ }
+
+ /** @type {WebSocketWrapper} */
+ var socketWrapper = api.getInternalData(socketElt).webSocket;
+ var headers = api.getHeaders(sendElt, api.getTarget(sendElt));
+ var results = api.getInputValues(sendElt, 'post');
+ var errors = results.errors;
+ var rawParameters = results.values;
+ var expressionVars = api.getExpressionVars(sendElt);
+ var allParameters = api.mergeObjects(rawParameters, expressionVars);
+ var filteredParameters = api.filterValues(allParameters, sendElt);
+
+ var sendConfig = {
+ parameters: filteredParameters,
+ unfilteredParameters: allParameters,
+ headers: headers,
+ errors: errors,
+
+ triggeringEvent: evt,
+ messageBody: undefined,
+ socketWrapper: socketWrapper.publicInterface
+ };
+
+ if (!api.triggerEvent(elt, 'htmx:wsConfigSend', sendConfig)) {
+ return;
+ }
+
+ if (errors && errors.length > 0) {
+ api.triggerEvent(elt, 'htmx:validation:halted', errors);
+ return;
+ }
+
+ var body = sendConfig.messageBody;
+ if (body === undefined) {
+ var toSend = Object.assign({}, sendConfig.parameters);
+ if (sendConfig.headers)
+ toSend['HEADERS'] = headers;
+ body = JSON.stringify(toSend);
+ }
+
+ socketWrapper.send(body, elt);
+
+ if (evt && api.shouldCancel(evt, elt)) {
+ evt.preventDefault();
+ }
+ });
+ });
+ }
+
+ /**
+ * getWebSocketReconnectDelay is the default easing function for WebSocket reconnects.
+ * @param {number} retryCount // The number of retries that have already taken place
+ * @returns {number}
+ */
+ function getWebSocketReconnectDelay(retryCount) {
+
+ /** @type {"full-jitter" | ((retryCount:number) => number)} */
+ var delay = htmx.config.wsReconnectDelay;
+ if (typeof delay === 'function') {
+ return delay(retryCount);
+ }
+ if (delay === 'full-jitter') {
+ var exp = Math.min(retryCount, 6);
+ var maxDelay = 1000 * Math.pow(2, exp);
+ return maxDelay * Math.random();
+ }
+
+ logError('htmx.config.wsReconnectDelay must either be a function or the string "full-jitter"');
+ }
+
+ /**
+ * maybeCloseWebSocketSource checks to the if the element that created the WebSocket
+ * still exists in the DOM. If NOT, then the WebSocket is closed and this function
+ * returns TRUE. If the element DOES EXIST, then no action is taken, and this function
+ * returns FALSE.
+ *
+ * @param {*} elt
+ * @returns
+ */
+ function maybeCloseWebSocketSource(elt) {
+ if (!api.bodyContains(elt)) {
+ api.getInternalData(elt).webSocket.close();
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * createWebSocket is the default method for creating new WebSocket objects.
+ * it is hoisted into htmx.createWebSocket to be overridden by the user, if needed.
+ *
+ * @param {string} url
+ * @returns WebSocket
+ */
+ function createWebSocket(url) {
+ var sock = new WebSocket(url, []);
+ sock.binaryType = htmx.config.wsBinaryType;
+ return sock;
+ }
+
+ /**
+ * queryAttributeOnThisOrChildren returns all nodes that contain the requested attributeName, INCLUDING THE PROVIDED ROOT ELEMENT.
+ *
+ * @param {HTMLElement} elt
+ * @param {string} attributeName
+ */
+ function queryAttributeOnThisOrChildren(elt, attributeName) {
+
+ var result = []
+
+ // If the parent element also contains the requested attribute, then add it to the results too.
+ if (api.hasAttribute(elt, attributeName) || api.hasAttribute(elt, "hx-ws")) {
+ result.push(elt);
+ }
+
+ // Search all child nodes that match the requested attribute
+ elt.querySelectorAll("[" + attributeName + "], [data-" + attributeName + "], [data-hx-ws], [hx-ws]").forEach(function (node) {
+ result.push(node)
+ })
+
+ return result
+ }
+
+ /**
+ * @template T
+ * @param {T[]} arr
+ * @param {(T) => void} func
+ */
+ function forEach(arr, func) {
+ if (arr) {
+ for (var i = 0; i < arr.length; i++) {
+ func(arr[i]);
+ }
+ }
+ }
+
+})();
+