mirror of
				https://github.com/Theodor-Springmann-Stiftung/musenalm.git
				synced 2025-11-03 19:55:31 +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])
 | 
						|
      }
 | 
						|
    }
 | 
						|
  }
 | 
						|
})()
 |