Skip to content

Commit fbdc770

Browse files
committed
Merge branch 'feat/vendor-static-assets'
2 parents 62f843d + e6069fa commit fbdc770

11 files changed

Lines changed: 374 additions & 10 deletions

File tree

‎packages/studyctl/src/studyctl/web/static/session.html‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,11 @@
1010
<link rel="stylesheet" href="/style.css">
1111
<link rel="manifest" href="/manifest.json">
1212
<link rel="icon" href="/icon-192.svg" type="image/svg+xml">
13-
<!-- HTMX + SSE extension -->
14-
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
15-
<script src="https://unpkg.com/htmx-ext-sse@2.2.2/sse.js"></script>
16-
<!-- Alpine.js -->
17-
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.8/dist/cdn.min.js"></script>
13+
<!-- HTMX + SSE extension (vendored for offline PWA) -->
14+
<script src="/vendor/js/htmx-2.0.4.min.js"></script>
15+
<script src="/vendor/js/htmx-ext-sse-2.2.2.js"></script>
16+
<!-- Alpine.js (vendored for offline PWA) -->
17+
<script defer src="/vendor/js/alpine-3.14.8.min.js"></script>
1818
</head>
1919
<body>
2020
<header>

‎packages/studyctl/src/studyctl/web/static/style.css‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap");
2-
@import url("https://cdn.jsdelivr.net/npm/@fontsource/opendyslexic@5/400.css");
2+
@import url("/vendor/css/opendyslexic-400.css");
33

44
:root {
55
--bg: #1a1b26;

‎packages/studyctl/src/studyctl/web/static/sw.js‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
1-
const CACHE = "studyctl-v3";
2-
const ASSETS = ["/", "/style.css", "/app.js", "/manifest.json", "/session", "/session.html"];
1+
const CACHE = "studyctl-v4";
2+
const ASSETS = [
3+
"/", "/style.css", "/app.js", "/manifest.json", "/session", "/session.html",
4+
"/vendor/js/htmx-2.0.4.min.js", "/vendor/js/htmx-ext-sse-2.2.2.js",
5+
"/vendor/js/alpine-3.14.8.min.js", "/vendor/css/opendyslexic-400.css",
6+
];
37

48
self.addEventListener("install", (e) => {
59
e.waitUntil(caches.open(CACHE).then((c) => c.addAll(ASSETS)));
Binary file not shown.
Binary file not shown.
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
/* opendyslexic-latin-400-normal */
2+
@font-face {
3+
font-family: 'OpenDyslexic';
4+
font-style: normal;
5+
font-display: swap;
6+
font-weight: 400;
7+
src: url(./files/opendyslexic-latin-400-normal.woff2) format('woff2'), url(./files/opendyslexic-latin-400-normal.woff) format('woff');
8+
}

‎packages/studyctl/src/studyctl/web/static/vendor/js/alpine-3.14.8.min.js‎

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

‎packages/studyctl/src/studyctl/web/static/vendor/js/htmx-2.0.4.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.
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+
})()

‎packages/studyctl/tests/test_web_session.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,11 +29,11 @@ def test_session_page_returns_html(self, client: TestClient) -> None:
2929

3030
def test_session_page_loads_htmx(self, client: TestClient) -> None:
3131
resp = client.get("/session")
32-
assert "htmx.org" in resp.text
32+
assert "/vendor/js/htmx-2.0.4.min.js" in resp.text
3333

3434
def test_session_page_loads_alpine(self, client: TestClient) -> None:
3535
resp = client.get("/session")
36-
assert "alpinejs" in resp.text
36+
assert "/vendor/js/alpine-3.14.8.min.js" in resp.text
3737

3838

3939
class TestSessionStateAPI:

0 commit comments

Comments
 (0)