/** * htmx Server-Sent Events Extension * * Adds support for Server Sent Events to htmx. * Ported from the original IIFE extension for ES module bundling. */ import type { HtmxApi, HtmxInternalApi } from '../types' export function initSSEExtension(htmx: HtmxApi): void { let api: HtmxInternalApi function createEventSource(url: string): EventSource { return new EventSource(url, { withCredentials: true }) } function hasEventSource(node: Element): boolean { return api.getInternalData(node).sseEventSource != null } function maybeCloseSSESource(elt: Element): boolean { if (!api.bodyContains(elt)) { const internalData = api.getInternalData(elt) const source = internalData.sseEventSource as EventSource | undefined if (source != null) { api.triggerEvent(elt, 'htmx:sseClose', { source, type: 'nodeMissing', }) source.close() return true } } return false } function swap(elt: Element, content: string): void { let transformedContent = content api.withExtensions(elt, (extension) => { if (extension.transformResponse) { transformedContent = extension.transformResponse(transformedContent, null, elt) } }) const swapSpec = api.getSwapSpecification(elt) const target = api.getTarget(elt) if (target) { api.swap(target, transformedContent, swapSpec) } } function registerSSE(elt: Element): void { // Add message handlers for every `sse-swap` attribute const sseSwapAttr = api.getAttributeValue(elt, 'sse-swap') if (sseSwapAttr) { const sourceElement = api.getClosestMatch(elt, hasEventSource) if (sourceElement == null) { return } const internalData = api.getInternalData(sourceElement) const source = internalData.sseEventSource as EventSource const sseEventNames = sseSwapAttr.split(',') for (const sseEventName of sseEventNames) { const eventName = sseEventName.trim() const listener = (event: Event) => { const messageEvent = event as MessageEvent if (maybeCloseSSESource(sourceElement)) { return } if (!api.bodyContains(elt)) { source.removeEventListener(eventName, listener) return } if (!api.triggerEvent(elt, 'htmx:sseBeforeMessage', event)) { return } swap(elt, messageEvent.data) api.triggerEvent(elt, 'htmx:sseMessage', event) } api.getInternalData(elt).sseEventListener = listener source.addEventListener(eventName, listener) } } // Add message handlers for every `hx-trigger="sse:*"` attribute const hxTrigger = api.getAttributeValue(elt, 'hx-trigger') if (hxTrigger) { const sourceElement = api.getClosestMatch(elt, hasEventSource) if (sourceElement == null) { return } const internalData = api.getInternalData(sourceElement) const source = internalData.sseEventSource as EventSource const triggerSpecs = api.getTriggerSpecs(elt) for (const ts of triggerSpecs) { if (!ts.trigger.startsWith('sse:')) { continue } const eventName = ts.trigger.slice(4) const listener = (event: Event) => { if (maybeCloseSSESource(sourceElement)) { return } if (!api.bodyContains(elt)) { source.removeEventListener(eventName, listener) } htmx.trigger(elt, ts.trigger, event) htmx.trigger(elt, 'htmx:sseMessage', event) } api.getInternalData(elt).sseEventListener = listener source.addEventListener(eventName, listener) } } } function ensureEventSource(elt: Element, url: string, retryCount?: number): void { const source = htmx.createEventSource!(url) source.onerror = (err) => { api.triggerErrorEvent(elt, 'htmx:sseError', { error: err, source }) if (maybeCloseSSESource(elt)) { return } if (source.readyState === EventSource.CLOSED) { let count = retryCount || 0 count = Math.max(Math.min(count * 2, 128), 1) const timeout = count * 500 window.setTimeout(() => { ensureEventSourceOnElement(elt, count) }, timeout) } } source.onopen = () => { 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 (const child of childrenToFix) { registerSSE(child) } } } api.getInternalData(elt).sseEventSource = source const closeAttribute = api.getAttributeValue(elt, 'sse-close') if (closeAttribute) { source.addEventListener(closeAttribute, () => { api.triggerEvent(elt, 'htmx:sseClose', { source, type: 'message', }) source.close() }) } } function ensureEventSourceOnElement(elt: Element | null, retryCount?: number): void { if (elt == null) { return } const sseURL = api.getAttributeValue(elt, 'sse-connect') if (sseURL) { ensureEventSource(elt, sseURL, retryCount) } registerSSE(elt) } htmx.defineExtension('sse', { init(apiRef: HtmxInternalApi) { api = apiRef if (htmx.createEventSource == null) { htmx.createEventSource = createEventSource } }, getSelectors() { return ['[sse-connect]', '[data-sse-connect]', '[sse-swap]', '[data-sse-swap]'] }, onEvent(name: string, evt: CustomEvent) { const parent = (evt.target || evt.detail?.elt) as Element switch (name) { case 'htmx:beforeCleanupElement': { const internalData = api.getInternalData(parent) const source = internalData.sseEventSource as EventSource | undefined if (source) { api.triggerEvent(parent, 'htmx:sseClose', { source, type: 'nodeReplaced', }) source.close() } return } case 'htmx:afterProcessNode': ensureEventSourceOnElement(parent) break } }, }) }