Skip to content

Commit 72c2175

Browse files
Use static HTMX rather than CDN
1 parent 2dc83c7 commit 72c2175

File tree

3 files changed

+293
-2
lines changed

3 files changed

+293
-2
lines changed

static/htmx.min.js

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

static/sse,js

Lines changed: 290 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,290 @@
1+
/*
2+
Server Sent Events Extension
3+
============================
4+
This extension adds support for Server Sent Events to htmx. See /www/extensions/sse.md for usage instructions.
5+
6+
*/
7+
8+
(function() {
9+
/** @type {import("../htmx").HtmxInternalApi} */
10+
var api
11+
12+
htmx.defineExtension('sse', {
13+
14+
/**
15+
* Init saves the provided reference to the internal HTMX API.
16+
*
17+
* @param {import("../htmx").HtmxInternalApi} api
18+
* @returns void
19+
*/
20+
init: function(apiRef) {
21+
// store a reference to the internal API.
22+
api = apiRef
23+
24+
// set a function in the public API for creating new EventSource objects
25+
if (htmx.createEventSource == undefined) {
26+
htmx.createEventSource = createEventSource
27+
}
28+
},
29+
30+
getSelectors: function() {
31+
return ['[sse-connect]', '[data-sse-connect]', '[sse-swap]', '[data-sse-swap]']
32+
},
33+
34+
/**
35+
* onEvent handles all events passed to this extension.
36+
*
37+
* @param {string} name
38+
* @param {Event} evt
39+
* @returns void
40+
*/
41+
onEvent: function(name, evt) {
42+
var parent = evt.target || evt.detail.elt
43+
switch (name) {
44+
case 'htmx:beforeCleanupElement':
45+
var internalData = api.getInternalData(parent)
46+
// Try to remove remove an EventSource when elements are removed
47+
var source = internalData.sseEventSource
48+
if (source) {
49+
api.triggerEvent(parent, 'htmx:sseClose', {
50+
source,
51+
type: 'nodeReplaced',
52+
})
53+
internalData.sseEventSource.close()
54+
}
55+
56+
return
57+
58+
// Try to create EventSources when elements are processed
59+
case 'htmx:afterProcessNode':
60+
ensureEventSourceOnElement(parent)
61+
}
62+
}
63+
})
64+
65+
/// ////////////////////////////////////////////
66+
// HELPER FUNCTIONS
67+
/// ////////////////////////////////////////////
68+
69+
/**
70+
* createEventSource is the default method for creating new EventSource objects.
71+
* it is hoisted into htmx.config.createEventSource to be overridden by the user, if needed.
72+
*
73+
* @param {string} url
74+
* @returns EventSource
75+
*/
76+
function createEventSource(url) {
77+
return new EventSource(url, { withCredentials: true })
78+
}
79+
80+
/**
81+
* registerSSE looks for attributes that can contain sse events, right
82+
* now hx-trigger and sse-swap and adds listeners based on these attributes too
83+
* the closest event source
84+
*
85+
* @param {HTMLElement} elt
86+
*/
87+
function registerSSE(elt) {
88+
// Add message handlers for every `sse-swap` attribute
89+
if (api.getAttributeValue(elt, 'sse-swap')) {
90+
// Find closest existing event source
91+
var sourceElement = api.getClosestMatch(elt, hasEventSource)
92+
if (sourceElement == null) {
93+
// api.triggerErrorEvent(elt, "htmx:noSSESourceError")
94+
return null // no eventsource in parentage, orphaned element
95+
}
96+
97+
// Set internalData and source
98+
var internalData = api.getInternalData(sourceElement)
99+
var source = internalData.sseEventSource
100+
101+
var sseSwapAttr = api.getAttributeValue(elt, 'sse-swap')
102+
var sseEventNames = sseSwapAttr.split(',')
103+
104+
for (var i = 0; i < sseEventNames.length; i++) {
105+
const sseEventName = sseEventNames[i].trim()
106+
const listener = function(event) {
107+
// If the source is missing then close SSE
108+
if (maybeCloseSSESource(sourceElement)) {
109+
return
110+
}
111+
112+
// If the body no longer contains the element, remove the listener
113+
if (!api.bodyContains(elt)) {
114+
source.removeEventListener(sseEventName, listener)
115+
return
116+
}
117+
118+
// swap the response into the DOM and trigger a notification
119+
if (!api.triggerEvent(elt, 'htmx:sseBeforeMessage', event)) {
120+
return
121+
}
122+
swap(elt, event.data)
123+
api.triggerEvent(elt, 'htmx:sseMessage', event)
124+
}
125+
126+
// Register the new listener
127+
api.getInternalData(elt).sseEventListener = listener
128+
source.addEventListener(sseEventName, listener)
129+
}
130+
}
131+
132+
// Add message handlers for every `hx-trigger="sse:*"` attribute
133+
if (api.getAttributeValue(elt, 'hx-trigger')) {
134+
// Find closest existing event source
135+
var sourceElement = api.getClosestMatch(elt, hasEventSource)
136+
if (sourceElement == null) {
137+
// api.triggerErrorEvent(elt, "htmx:noSSESourceError")
138+
return null // no eventsource in parentage, orphaned element
139+
}
140+
141+
// Set internalData and source
142+
var internalData = api.getInternalData(sourceElement)
143+
var source = internalData.sseEventSource
144+
145+
var triggerSpecs = api.getTriggerSpecs(elt)
146+
triggerSpecs.forEach(function(ts) {
147+
if (ts.trigger.slice(0, 4) !== 'sse:') {
148+
return
149+
}
150+
151+
var listener = function (event) {
152+
if (maybeCloseSSESource(sourceElement)) {
153+
return
154+
}
155+
if (!api.bodyContains(elt)) {
156+
source.removeEventListener(ts.trigger.slice(4), listener)
157+
}
158+
// Trigger events to be handled by the rest of htmx
159+
htmx.trigger(elt, ts.trigger, event)
160+
htmx.trigger(elt, 'htmx:sseMessage', event)
161+
}
162+
163+
// Register the new listener
164+
api.getInternalData(elt).sseEventListener = listener
165+
source.addEventListener(ts.trigger.slice(4), listener)
166+
})
167+
}
168+
}
169+
170+
/**
171+
* ensureEventSourceOnElement creates a new EventSource connection on the provided element.
172+
* If a usable EventSource already exists, then it is returned. If not, then a new EventSource
173+
* is created and stored in the element's internalData.
174+
* @param {HTMLElement} elt
175+
* @param {number} retryCount
176+
* @returns {EventSource | null}
177+
*/
178+
function ensureEventSourceOnElement(elt, retryCount) {
179+
if (elt == null) {
180+
return null
181+
}
182+
183+
// handle extension source creation attribute
184+
if (api.getAttributeValue(elt, 'sse-connect')) {
185+
var sseURL = api.getAttributeValue(elt, 'sse-connect')
186+
if (sseURL == null) {
187+
return
188+
}
189+
190+
ensureEventSource(elt, sseURL, retryCount)
191+
}
192+
193+
registerSSE(elt)
194+
}
195+
196+
function ensureEventSource(elt, url, retryCount) {
197+
var source = htmx.createEventSource(url)
198+
199+
source.onerror = function(err) {
200+
// Log an error event
201+
api.triggerErrorEvent(elt, 'htmx:sseError', { error: err, source })
202+
203+
// If parent no longer exists in the document, then clean up this EventSource
204+
if (maybeCloseSSESource(elt)) {
205+
return
206+
}
207+
208+
// Otherwise, try to reconnect the EventSource
209+
if (source.readyState === EventSource.CLOSED) {
210+
retryCount = retryCount || 0
211+
retryCount = Math.max(Math.min(retryCount * 2, 128), 1)
212+
var timeout = retryCount * 500
213+
window.setTimeout(function() {
214+
ensureEventSourceOnElement(elt, retryCount)
215+
}, timeout)
216+
}
217+
}
218+
219+
source.onopen = function(evt) {
220+
api.triggerEvent(elt, 'htmx:sseOpen', { source })
221+
222+
if (retryCount && retryCount > 0) {
223+
const childrenToFix = elt.querySelectorAll("[sse-swap], [data-sse-swap], [hx-trigger], [data-hx-trigger]")
224+
for (let i = 0; i < childrenToFix.length; i++) {
225+
registerSSE(childrenToFix[i])
226+
}
227+
// We want to increase the reconnection delay for consecutive failed attempts only
228+
retryCount = 0
229+
}
230+
}
231+
232+
api.getInternalData(elt).sseEventSource = source
233+
234+
235+
var closeAttribute = api.getAttributeValue(elt, "sse-close");
236+
if (closeAttribute) {
237+
// close eventsource when this message is received
238+
source.addEventListener(closeAttribute, function() {
239+
api.triggerEvent(elt, 'htmx:sseClose', {
240+
source,
241+
type: 'message',
242+
})
243+
source.close()
244+
});
245+
}
246+
}
247+
248+
/**
249+
* maybeCloseSSESource confirms that the parent element still exists.
250+
* If not, then any associated SSE source is closed and the function returns true.
251+
*
252+
* @param {HTMLElement} elt
253+
* @returns boolean
254+
*/
255+
function maybeCloseSSESource(elt) {
256+
if (!api.bodyContains(elt)) {
257+
var source = api.getInternalData(elt).sseEventSource
258+
if (source != undefined) {
259+
api.triggerEvent(elt, 'htmx:sseClose', {
260+
source,
261+
type: 'nodeMissing',
262+
})
263+
source.close()
264+
// source = null
265+
return true
266+
}
267+
}
268+
return false
269+
}
270+
271+
272+
/**
273+
* @param {HTMLElement} elt
274+
* @param {string} content
275+
*/
276+
function swap(elt, content) {
277+
api.withExtensions(elt, function(extension) {
278+
content = extension.transformResponse(content, null, elt)
279+
})
280+
281+
var swapSpec = api.getSwapSpecification(elt)
282+
var target = api.getTarget(elt)
283+
api.swap(target, content, swapSpec)
284+
}
285+
286+
287+
function hasEventSource(node) {
288+
return api.getInternalData(node).sseEventSource != null
289+
}
290+
})()

templates/layout.html

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@
77
<link rel="icon" href="{{ url_for('static', path='openai.svg') }}">
88
<link rel="stylesheet" href="{{ url_for('static', path='styles.css') }}">
99
<link rel="favicon" href="{{ url_for('static', path='favicon.png') }}">
10-
<script src="https://unpkg.com/htmx.[email protected]" integrity="sha384-HGfztofotfshcF7+8n44JQL2oJmowVChPTg48S+jvZoztPfvwD79OC/LTtG6dMp+" crossorigin="anonymous"></script>
11-
<script src="https://unpkg.com/htmx-ext-sse@2.2.2/sse.js"></script>
10+
<script src="{{ url_for('static', path='htmx.min.js') }}" integrity="sha384-HGfztofotfshcF7+8n44JQL2oJmowVChPTg48S+jvZoztPfvwD79OC/LTtG6dMp+" crossorigin="anonymous"></script>
11+
<script src="{{ url_for('static', path='sse.js') }}"></script>
1212
</head>
1313
<body>
1414
{% block content %}

0 commit comments

Comments
 (0)