The smokesignal.events web application
at main 221 lines 6.4 kB view raw
1/** 2 * htmx Server-Sent Events Extension 3 * 4 * Adds support for Server Sent Events to htmx. 5 * Ported from the original IIFE extension for ES module bundling. 6 */ 7 8import type { HtmxApi, HtmxInternalApi } from '../types' 9 10export function initSSEExtension(htmx: HtmxApi): void { 11 let api: HtmxInternalApi 12 13 function createEventSource(url: string): EventSource { 14 return new EventSource(url, { withCredentials: true }) 15 } 16 17 function hasEventSource(node: Element): boolean { 18 return api.getInternalData(node).sseEventSource != null 19 } 20 21 function maybeCloseSSESource(elt: Element): boolean { 22 if (!api.bodyContains(elt)) { 23 const internalData = api.getInternalData(elt) 24 const source = internalData.sseEventSource as EventSource | undefined 25 if (source != null) { 26 api.triggerEvent(elt, 'htmx:sseClose', { 27 source, 28 type: 'nodeMissing', 29 }) 30 source.close() 31 return true 32 } 33 } 34 return false 35 } 36 37 function swap(elt: Element, content: string): void { 38 let transformedContent = content 39 api.withExtensions(elt, (extension) => { 40 if (extension.transformResponse) { 41 transformedContent = extension.transformResponse(transformedContent, null, elt) 42 } 43 }) 44 45 const swapSpec = api.getSwapSpecification(elt) 46 const target = api.getTarget(elt) 47 if (target) { 48 api.swap(target, transformedContent, swapSpec) 49 } 50 } 51 52 function registerSSE(elt: Element): void { 53 // Add message handlers for every `sse-swap` attribute 54 const sseSwapAttr = api.getAttributeValue(elt, 'sse-swap') 55 if (sseSwapAttr) { 56 const sourceElement = api.getClosestMatch(elt, hasEventSource) 57 if (sourceElement == null) { 58 return 59 } 60 61 const internalData = api.getInternalData(sourceElement) 62 const source = internalData.sseEventSource as EventSource 63 64 const sseEventNames = sseSwapAttr.split(',') 65 66 for (const sseEventName of sseEventNames) { 67 const eventName = sseEventName.trim() 68 const listener = (event: Event) => { 69 const messageEvent = event as MessageEvent 70 if (maybeCloseSSESource(sourceElement)) { 71 return 72 } 73 74 if (!api.bodyContains(elt)) { 75 source.removeEventListener(eventName, listener) 76 return 77 } 78 79 if (!api.triggerEvent(elt, 'htmx:sseBeforeMessage', event)) { 80 return 81 } 82 swap(elt, messageEvent.data) 83 api.triggerEvent(elt, 'htmx:sseMessage', event) 84 } 85 86 api.getInternalData(elt).sseEventListener = listener 87 source.addEventListener(eventName, listener) 88 } 89 } 90 91 // Add message handlers for every `hx-trigger="sse:*"` attribute 92 const hxTrigger = api.getAttributeValue(elt, 'hx-trigger') 93 if (hxTrigger) { 94 const sourceElement = api.getClosestMatch(elt, hasEventSource) 95 if (sourceElement == null) { 96 return 97 } 98 99 const internalData = api.getInternalData(sourceElement) 100 const source = internalData.sseEventSource as EventSource 101 102 const triggerSpecs = api.getTriggerSpecs(elt) 103 for (const ts of triggerSpecs) { 104 if (!ts.trigger.startsWith('sse:')) { 105 continue 106 } 107 108 const eventName = ts.trigger.slice(4) 109 const listener = (event: Event) => { 110 if (maybeCloseSSESource(sourceElement)) { 111 return 112 } 113 if (!api.bodyContains(elt)) { 114 source.removeEventListener(eventName, listener) 115 } 116 htmx.trigger(elt, ts.trigger, event) 117 htmx.trigger(elt, 'htmx:sseMessage', event) 118 } 119 120 api.getInternalData(elt).sseEventListener = listener 121 source.addEventListener(eventName, listener) 122 } 123 } 124 } 125 126 function ensureEventSource(elt: Element, url: string, retryCount?: number): void { 127 const source = htmx.createEventSource!(url) 128 129 source.onerror = (err) => { 130 api.triggerErrorEvent(elt, 'htmx:sseError', { error: err, source }) 131 132 if (maybeCloseSSESource(elt)) { 133 return 134 } 135 136 if (source.readyState === EventSource.CLOSED) { 137 let count = retryCount || 0 138 count = Math.max(Math.min(count * 2, 128), 1) 139 const timeout = count * 500 140 window.setTimeout(() => { 141 ensureEventSourceOnElement(elt, count) 142 }, timeout) 143 } 144 } 145 146 source.onopen = () => { 147 api.triggerEvent(elt, 'htmx:sseOpen', { source }) 148 149 if (retryCount && retryCount > 0) { 150 const childrenToFix = elt.querySelectorAll( 151 '[sse-swap], [data-sse-swap], [hx-trigger], [data-hx-trigger]' 152 ) 153 for (const child of childrenToFix) { 154 registerSSE(child) 155 } 156 } 157 } 158 159 api.getInternalData(elt).sseEventSource = source 160 161 const closeAttribute = api.getAttributeValue(elt, 'sse-close') 162 if (closeAttribute) { 163 source.addEventListener(closeAttribute, () => { 164 api.triggerEvent(elt, 'htmx:sseClose', { 165 source, 166 type: 'message', 167 }) 168 source.close() 169 }) 170 } 171 } 172 173 function ensureEventSourceOnElement(elt: Element | null, retryCount?: number): void { 174 if (elt == null) { 175 return 176 } 177 178 const sseURL = api.getAttributeValue(elt, 'sse-connect') 179 if (sseURL) { 180 ensureEventSource(elt, sseURL, retryCount) 181 } 182 183 registerSSE(elt) 184 } 185 186 htmx.defineExtension('sse', { 187 init(apiRef: HtmxInternalApi) { 188 api = apiRef 189 190 if (htmx.createEventSource == null) { 191 htmx.createEventSource = createEventSource 192 } 193 }, 194 195 getSelectors() { 196 return ['[sse-connect]', '[data-sse-connect]', '[sse-swap]', '[data-sse-swap]'] 197 }, 198 199 onEvent(name: string, evt: CustomEvent) { 200 const parent = (evt.target || evt.detail?.elt) as Element 201 switch (name) { 202 case 'htmx:beforeCleanupElement': { 203 const internalData = api.getInternalData(parent) 204 const source = internalData.sseEventSource as EventSource | undefined 205 if (source) { 206 api.triggerEvent(parent, 'htmx:sseClose', { 207 source, 208 type: 'nodeReplaced', 209 }) 210 source.close() 211 } 212 return 213 } 214 215 case 'htmx:afterProcessNode': 216 ensureEventSourceOnElement(parent) 217 break 218 } 219 }, 220 }) 221}