Index âș 17. htmx, the htmx WebSocket extension, and socket routes
Get introduced to Kittenâs first-class support for htmx and the htmx WebSocket extension and how to use them to extend the Fetchiverse example to create the Streamiverse example: a streaming interface of curated public fediverse posts.
Topics covered
- Kittenâs first-class support for certain libraries and how to include them in your pages.
- htmx.
- htmx WebSocket extension.
- Hypermedia-Driven Applications.
- Kittenâs WebSocket routes (.socket.js files).
- How to broadcast messages to all connected pages.
While the fetching a fediverse timeline is fun and all, wouldnât it be cooler if you could stream it? Letâs do just that using a WebSocket on the server and htmx to enhance the base fetchiverse example.
Mastodon removed unauthenticated access to the public streaming API so weâre going to consume a curated stream of posts from a little Kitten app called Streamiverse from streamiverse.small-web.org.
Kitten, via its first-class support for htmx, encourages a Hypermedia-Driven Application architecture for web applications where application state is represented in hypermedia. Basically, this means we send HTML between the client and server instead of using data formats like JSON and state is managed on the server.
The only exception to this is when it comes to protecting the identity and privacy of the person who owns a Kitten site/app. Identity and authentication, as we will see later, are handled entirely in the client (in the browser) via public-key cryptography as we expect that the client runs on a device (a computer, phone, etc.) that is entirely within the control of the person.
If you want to learn htmx (and about hypermedia in general), there is now a book called Hypermedia Systems by the folks who authored and maintain htmx.
index.page.js
Letâs start with the code for our new index page:
import post from './post.component.js'
export default async function () {
const response = await fetch('https://streamiverse.small-web.org/public/')
const posts = await response.json()
return kitten.html`
<page htmx htmx-websocket>
<h1>Aralâs Public Fediverse Timeline</h1>
<ul id='posts' hx-ext='ws' ws-connect='/updates.socket'>
${posts.map(item => kitten.html`<${post} post=${item} />`)}
</ul>
<style>
body { font-family: sans-serif; font-size: 1.25em; padding-left: 1.5em; padding-right: 1.5em; }
h1 { font-size: 2.5em; text-align: center; }
ul { padding: 0; }
</style>
`
}
Pay special attention to the unordered list tagâs attributes:
<ul id='posts' hx-ext='ws' ws-connect='/updates.socket'>
${posts.map(item => kitten.html`<${post} post=${item} />`)}
</ul>
Specifically:
It has an
id. We will be using thisidfrom our socket code to tell client where to add new posts as they stream in.It uses the htmx WebSockets extension (
hx-ext='ws') and tells it to connect to a WebSocket route at the path /updates.socket (ws-connect='/updates.socket').
Finally, we have to tell Kitten, explicitly, that we are using htmx and its WebSocket extension on the page so it knows to include script tags in the head of the rendered page to load in those libraries:
<page htmx htmx-websocket>
Believe it or not, thatâs all the code you need on the client to set up and manage a WebSocket connection.
When I said Kitten loves you, I meant Kitten loves you.
Kitten has built-in support for htmx, the htmx WebSocket extension, the idiomorph extension, Alpine.js and Water. You include them with attributes on the <page> tag: htmx, htmx-websocket, htmx-idiomorph, alpinejs and css.
Since htmx is a progressive enhancement on HTML, if you forget to include it, your page will render and display without any errors, it just wonât have any client-side interactivity.
To include a library manually, you can find its file name in kitten.libraries and tack it onto the route that Kitten serves internal libraries from (/đ±/library/). See Kitten client-side libraries) for more details.
Note that Kitten also adds syntactic sugar on top of htmx, like its Streaming HTML workflow or aliases like trigger for hx-trigger and swap for hx-swap that do away with the hx- prefix to simplify authoring. (As these are syntactic sugar, they get precompiled down to standard htmx. And, of course, youâre welcome to use standard htmx to begin with if youâd rather.)
post.component.js
Notice how weâve refactored the Fetchiverse example so that we now have a post component. Its avatar and content components live in the same file, in the name of locality of behaviour.
export default function ({ post }) {
return kitten.html`
<li>
<${avatar} post=${post} />
<${content} post=${post} />
<style inline>
@scope {
display: flex; align-items: flex-start; column-gap: 1em; padding: 1em;
margin-bottom: 1em; background-color: #ccc; border-radius: 1em;
}
</style>
</li>
`
}
// Private components (can only be used by post).
function avatar ({ post }) {
return kitten.html`
<a href='${post.account.url}'>
<img
src='${post.account.avatar}'
alt='${post.account.username}âs avatar'
>
<style inline>
@scope {
img {
width: 8em;
border: 6px solid #aaa;
border-radius: 1em;
}
}
</style>
</a>
`
}
function content ({ post }) {
return kitten.html`
<div>
${kitten.safelyAddUntrustedHtml(post.content)}
${post.media_attachments.map(media => (
media.type === 'image' && kitten.html`
<img src='${media.url}' alt='${media.description ?? ''}'>
`
))}
<style inline>
@scope {
/* Grow to fill all remaining space in the row. */
flex: 1;
p { line-height: 1.5; }
p:first-of-type { margin-top: 0; }
a {
background-color: #8bdaff;
color: black;
text-decoration: none;
padding: 0.25em;
border-radius: 0.25em;
/* Make sure posts donât overflow their containers. */
overflow-wrap: anywhere;
}
img { max-width: 100%; }
}
</style>
</div>
`
}
The avatar and content components are from the components and fragments tutorial.
A post is a natural unit for a component in our example, as we receive one post at a time from the Streamiverse feed. Since we send HTML over the wire, our socket route has to render post components, and so does our GET route in the index page. With post in its own module, we can import and use it from both places.
Finally, letâs look at the big new thing that makes this version stream: the socket route.
updates.socket
In Kitten, you declare WebSocket routes in .socket.js files.
You will have noticed that Kitten usually strips the extensions from your routes. If you have a page called /hello.page, for example, you can access it from https://localhost/hello. The exception is WebSocket routes, which keep their extensions. So the path to the updates.socket route is https://localhost/updates.socket.
The main reason for this is due to the built-in redirection Kitten performs to forward URIs that donât contain a trailing slash to ones that do (e.g., https://localhost/hello will get a 308 forward to https://localhost/hello/). This works for HTTP routes but is not guaranteed to work for WebSocket routes (because clients are not obligated to follow redirects during the handshake/protocol upgrade stage⊠donât ask me why not.) So, to avoid the situation where you could have to refer to your WebSocket route as, for example, â/chat/â and where it would not work if you forgot the trailing slash â which would be very easy to do â Kitten decrees that the file extension is kept for socket routes, thereby bypassing the issue altogether (while also improving semantics and further differentiating WebSocket routes from HTTP routes at a glance).
The function signature of your socket route is similar to the structure of regular HTTP routes but, in that it expects a parameter object that has a reference to the HTTP request. However, instead of a reference to the HTTP response â which, in WebSocket routes is managed for you and which you should not interfere with manually â you get a reference to the socket instance that you can use to communicate with the current connection to the page (as well as to all connected pages via the broadcast and all methods on it).
In the example below, since we are not using the request for anything, we simply do not declare it in the function signature.
import post from './post.component.js'
let stream = null
export default function ({ socket }) {
// Lazily start listening for timeline updates on first request.
if (stream === null) {
console.info(' đ Listening for Streamiverse updatesâŠ')
stream = new kitten.WebSocket('wss://streamiverse.small-web.org/stream.socket')
stream.addEventListener('message', event => {
const message = JSON.parse(event.data)
if (message.event === 'update') {
const item = JSON.parse(message.payload)
console.info(` đ Got an update from ${item.account.username}!`)
const update = kitten.html`
<div swap-target="afterbegin:#posts">
<${post} post=${item} />
</div>
`
socket.all(update)
}
})
}
}
The WebSocket route itself opens a WebSocket connection to the Streamiverse feed. It also adds a message listener that gets called each time thereâs a message from Streamiverse. Since we only care about new posts in this example, we only handle update messages.
As you can see here, WebSocket connections do not have to be client-to-server, they can be server-to-server also. To help you create these sorts of connections, Kitten exposes a reference to the WebSocket module it uses internally (ws) at kitten.WebSocket. This is what weâre using in the code above.
The messages Streamiverse sends are in JavaScript Object Notation (JSON) format so the first thing we do is to parse them into a plain JavaScript object. And in that object we find the payload property that contains the post itself.
Now comes the the htmx magic:
const update = kitten.html`
<div swap-target="afterbegin:#posts">
<${post} post=${item} />
</div>
`
The swap-target attribute is Kittenâs syntactic sugar for HTMXâs hx-swap-oob attribute (which stands for swap out-of-band). It states that this post should be added to the top of the list of posts on the page (remember that our posts list had the id of posts).
Finally, after weâve rendered our new post, we send it to all connected WebSocket clients using the special .all() method on the socket object:
socket.all(update)
And thatâs all there is to it!
Run the example using kitten and visit https://localhost to see a curated fediverse timeline streaming in.
Notice that weâre sending HTML over the wire using the WebSocket. This is how htmx works. It makes it possible for us to create dynamic functionality like a streaming fediverse timeline without writing any custom client-side JavaScript.
In this tutorial, you learned how to use a WebSocket to push data from the server to the client. But the WebSocket protocol is full-duplex (which is a fancy way of saying âtwo-wayâ). In the next tutorial, letâs see how we can also push data from the client to the server by creating a simple WebSocket echo server.
Next tutorial: Two-way WebSocket communication