mirror of
				https://github.com/Theodor-Springmann-Stiftung/kgpz_web.git
				synced 2025-10-31 01:55:29 +00:00 
			
		
		
		
	
		
			
				
	
	
		
			472 lines
		
	
	
		
			14 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
			
		
		
	
	
			472 lines
		
	
	
		
			14 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
| /*
 | |
| WebSockets Extension
 | |
| ============================
 | |
| This extension adds support for WebSockets to htmx.  See /www/extensions/ws.md for usage instructions.
 | |
| */
 | |
| 
 | |
| (function() {
 | |
|   /** @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,
 | |
|           socketWrapper: this.publicInterface
 | |
|         })) {
 | |
|           this.socket.send(message)
 | |
|           sendElt && api.triggerEvent(sendElt, 'htmx:wsAfterSend', {
 | |
|             message,
 | |
|             socketWrapper: this.publicInterface
 | |
|           })
 | |
|         }
 | |
|       },
 | |
| 
 | |
|       send: function(message, sendElt) {
 | |
|         if (this.socket.readyState !== this.socket.OPEN) {
 | |
|           this.messageQueue.push({ message, 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 = Object.assign({}, 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,
 | |
|           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)) {
 | |
|       var internalData = api.getInternalData(elt)
 | |
|       if (internalData.webSocket) {
 | |
|         internalData.webSocket.close()
 | |
|         return true
 | |
|       }
 | |
|       return false
 | |
|     }
 | |
|     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])
 | |
|       }
 | |
|     }
 | |
|   }
 | |
| })()
 | 
