prest 0.5.1

Progressive RESTful framework
Documentation
/*
Server Sent Events Extension
============================
This extension adds support for Server Sent Events to htmx.  See /www/extensions/sse.md for usage instructions.

*/

export default defineSse = (htmx) => {
    /** @type {import("./htmx").HtmxInternalApi} */
    var api
  
    htmx.defineExtension('sse', {
  
      /**
       * Init saves the provided reference to the internal HTMX API.
       *
       * @param {import("./htmx").HtmxInternalApi} api
       * @returns void
       */
      init: function(apiRef) {
        // store a reference to the internal API.
        api = apiRef
  
        // set a function in the public API for creating new EventSource objects
        if (htmx.createEventSource == undefined) {
          htmx.createEventSource = createEventSource
        }
      },
  
      getSelectors: function() {
        return ['[sse-connect]', '[data-sse-connect]', '[sse-swap]', '[data-sse-swap]']
      },
  
      /**
       * onEvent handles all events passed to this extension.
       *
       * @param {string} name
       * @param {Event} evt
       * @returns void
       */
      onEvent: function(name, evt) {
        var parent = evt.target || evt.detail.elt
        switch (name) {
          case 'htmx:beforeCleanupElement':
            var internalData = api.getInternalData(parent)
            // Try to remove remove an EventSource when elements are removed
            var source = internalData.sseEventSource
            if (source) {
              api.triggerEvent(parent, 'htmx:sseClose', {
                source,
                type: 'nodeReplaced',
              })
              internalData.sseEventSource.close()
            }
  
            return
  
          // Try to create EventSources when elements are processed
          case 'htmx:afterProcessNode':
            ensureEventSourceOnElement(parent)
        }
      }
    })
  
    /// ////////////////////////////////////////////
    // HELPER FUNCTIONS
    /// ////////////////////////////////////////////
  
    /**
     * createEventSource is the default method for creating new EventSource objects.
     * it is hoisted into htmx.config.createEventSource to be overridden by the user, if needed.
     *
     * @param {string} url
     * @returns EventSource
     */
    function createEventSource(url) {
      return new EventSource(url, { withCredentials: true })
    }
  
    /**
     * registerSSE looks for attributes that can contain sse events, right
     * now hx-trigger and sse-swap and adds listeners based on these attributes too
     * the closest event source
     *
     * @param {HTMLElement} elt
     */
    function registerSSE(elt) {
      // Add message handlers for every `sse-swap` attribute
      if (api.getAttributeValue(elt, 'sse-swap')) {
        // Find closest existing event source
        var sourceElement = api.getClosestMatch(elt, hasEventSource)
        if (sourceElement == null) {
          // api.triggerErrorEvent(elt, "htmx:noSSESourceError")
          return null // no eventsource in parentage, orphaned element
        }
  
        // Set internalData and source
        var internalData = api.getInternalData(sourceElement)
        var source = internalData.sseEventSource
  
        var sseSwapAttr = api.getAttributeValue(elt, 'sse-swap')
        var sseEventNames = sseSwapAttr.split(',')
  
        for (var i = 0; i < sseEventNames.length; i++) {
          const sseEventName = sseEventNames[i].trim()
          const listener = function(event) {
            // If the source is missing then close SSE
            if (maybeCloseSSESource(sourceElement)) {
              return
            }
  
            // If the body no longer contains the element, remove the listener
            if (!api.bodyContains(elt)) {
              source.removeEventListener(sseEventName, listener)
              return
            }
  
            // swap the response into the DOM and trigger a notification
            if (!api.triggerEvent(elt, 'htmx:sseBeforeMessage', event)) {
              return
            }
            swap(elt, event.data)
            api.triggerEvent(elt, 'htmx:sseMessage', event)
          }
  
          // Register the new listener
          api.getInternalData(elt).sseEventListener = listener
          source.addEventListener(sseEventName, listener)
        }
      }
  
      // Add message handlers for every `hx-trigger="sse:*"` attribute
      if (api.getAttributeValue(elt, 'hx-trigger')) {
        // Find closest existing event source
        var sourceElement = api.getClosestMatch(elt, hasEventSource)
        if (sourceElement == null) {
          // api.triggerErrorEvent(elt, "htmx:noSSESourceError")
          return null // no eventsource in parentage, orphaned element
        }
  
        // Set internalData and source
        var internalData = api.getInternalData(sourceElement)
        var source = internalData.sseEventSource
  
        var triggerSpecs = api.getTriggerSpecs(elt)
        triggerSpecs.forEach(function(ts) {
          if (ts.trigger.slice(0, 4) !== 'sse:') {
            return
          }
  
          var listener = function (event) {
            if (maybeCloseSSESource(sourceElement)) {
              return
            }
            if (!api.bodyContains(elt)) {
              source.removeEventListener(ts.trigger.slice(4), listener)
            }
            // Trigger events to be handled by the rest of htmx
            htmx.trigger(elt, ts.trigger, event)
            htmx.trigger(elt, 'htmx:sseMessage', event)
          }
  
          // Register the new listener
          api.getInternalData(elt).sseEventListener = listener
          source.addEventListener(ts.trigger.slice(4), listener)
        })
      }
    }
  
    /**
     * ensureEventSourceOnElement creates a new EventSource connection on the provided element.
     * If a usable EventSource already exists, then it is returned.  If not, then a new EventSource
     * is created and stored in the element's internalData.
     * @param {HTMLElement} elt
     * @param {number} retryCount
     * @returns {EventSource | null}
     */
    function ensureEventSourceOnElement(elt, retryCount) {
      if (elt == null) {
        return null
      }
  
      // handle extension source creation attribute
      if (api.getAttributeValue(elt, 'sse-connect')) {
        var sseURL = api.getAttributeValue(elt, 'sse-connect')
        if (sseURL == null) {
          return
        }
  
        ensureEventSource(elt, sseURL, retryCount)
      }
  
      registerSSE(elt)
    }
  
    function ensureEventSource(elt, url, retryCount) {
      var source = htmx.createEventSource(url)
  
      source.onerror = function(err) {
        // Log an error event
        api.triggerErrorEvent(elt, 'htmx:sseError', { error: err, source })
  
        // If parent no longer exists in the document, then clean up this EventSource
        if (maybeCloseSSESource(elt)) {
          return
        }
  
        // Otherwise, try to reconnect the EventSource
        if (source.readyState === EventSource.CLOSED) {
          retryCount = retryCount || 0
          retryCount = Math.max(Math.min(retryCount * 2, 128), 1)
          var timeout = retryCount * 500
          window.setTimeout(function() {
            ensureEventSourceOnElement(elt, retryCount)
          }, timeout)
        }
      }
  
      source.onopen = function(evt) {
        api.triggerEvent(elt, 'htmx:sseOpen', { source })
  
        if (retryCount && retryCount > 0) {
          const childrenToFix = elt.querySelectorAll("[sse-swap], [data-sse-swap], [hx-trigger], [data-hx-trigger]")
          for (let i = 0; i < childrenToFix.length; i++) {
            registerSSE(childrenToFix[i])
          }
          // We want to increase the reconnection delay for consecutive failed attempts only
          retryCount = 0
        }
      }
  
      api.getInternalData(elt).sseEventSource = source
  
  
      var closeAttribute = api.getAttributeValue(elt, "sse-close");
      if (closeAttribute) {
        // close eventsource when this message is received
        source.addEventListener(closeAttribute, function() {
          api.triggerEvent(elt, 'htmx:sseClose', {
            source,
            type: 'message',
          })
          source.close()
        });
      }
    }
  
    /**
     * maybeCloseSSESource confirms that the parent element still exists.
     * If not, then any associated SSE source is closed and the function returns true.
     *
     * @param {HTMLElement} elt
     * @returns boolean
     */
    function maybeCloseSSESource(elt) {
      if (!api.bodyContains(elt)) {
        var source = api.getInternalData(elt).sseEventSource
        if (source != undefined) {
          api.triggerEvent(elt, 'htmx:sseClose', {
            source,
            type: 'nodeMissing',
          })
          source.close()
          // source = null
          return true
        }
      }
      return false
    }
  
  
    /**
     * @param {HTMLElement} elt
     * @param {string} content
     */
    function swap(elt, content) {
      api.withExtensions(elt, function(extension) {
        content = extension.transformResponse(content, null, elt)
      })
  
      var swapSpec = api.getSwapSpecification(elt)
      var target = api.getTarget(elt)
      api.swap(target, content, swapSpec)
    }
  
  
    function hasEventSource(node) {
      return api.getInternalData(node).sseEventSource != null
    }
  }