forked from
smokesignal.events/smokesignal
The smokesignal.events web application
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}