| bin | ||
| build-templates | ||
| examples | ||
| src | ||
| tests | ||
| .gitignore | ||
| .nvmrc | ||
| build | ||
| dev-install | ||
| install | ||
| package-lock.json | ||
| package.json | ||
| README.md | ||
| suppress-experimental.cjs | ||
😸️ Kitten
A web development kit that’s small, purrs, and loves you.
Create your site using just HTML, CSS, and JavaScript then enhance it with htmx and hyperscript, if you like.
🍼 Warning: Kitten is just being born.
Sections marked with 🚧 represent features that are not implemented yet.
Please feel free to have a play but proceed at your own risk. Here be dragons, etc.
System requirements
- Node 18+ LTS.
- Linux
For production servers:
- systemd
Install
-
Clone this repository
git clone https://codeberg.org/kitten/app.git kitten -
Switch to the Kitten directory.
cd kitten -
🚧 Install.
./install
Getting started
🚧 You can run Kitten using the following syntax:
kitten [path to serve]
During development, you can also run Kitten from the source folder like this:
bin/kitten [path to serve]
🍼 Workaround: Start Kitten in production mode using the development syntax:
PRODUCTION=true bin/kitten [path to serve](Development mode has not yet been implemented and the build script has not been updated yet either.
If you want to play around with development mode, please
checkoutand play with the prototype branch.
Production
In production mode, 🚧 Kitten runs as a systemd service.
🍼 Production mode currently runs as a regular process.
You can start Kitten in production mode by setting the PRODUCTION environment variable to a non-empty value. e.g.,
PRODUCTION=true bin/kitten examples/simple-chat/
💡 By default, Kitten will be as quiet as possible in the console and only surface warnings and errors.
If you want more extensive logging, you can start it with the VERBOSE environment variable set:
VERBOSE=true kitten [path to serve]or
VERBOSE=true bin/kitten [path to serve]Similarly, if you want to see performance statistics, set
PROFILE=true.
Examples
The best way to get started is to play with the examples.
- Hello Count: examples/hello-count
- Persisted Hello Count: examples/persisted-hello-count
- Kitten Chat: examples/kitten-chat
- Make Fetch Happen: examples/make-fetch-happen
- Streaming Fediverse Posts: examples/streaming-fediverse-posts
e.g., to launch the Kitten Chat example, run:
kitten examples/simple-chat
💡️ Remember to run npm install on any examples that have a package.json file in them.
Tutorials
🍼 Substitute
PRODUCTION=true bin/kittenforkittenin the tutorial instructions for the time being.
Hello, world!
Let’s quickly create and test your first “Hello, world!” Kitten site.
🚧 Create a file called index.html and add the following content to it:
🍼 For the time being, please call the file index.page.
<h1>😸️ Meow!</h1>
Now run kitten, hit https://localhost, and you should see your new site.
Yes, Kitten will happily serve any HTML you throw at it just like any other good web server should.
🍼 For the time being, if you have static assets you want to serve, put them in a special folder called #static. So you can put your HTML into #static/index.html and the outcome will be identical. This limitation will be removed shortly and Kitten will automatically serve .html, .css, .js, etc., files as any other web server would without any configuration or special effort on your part.
But you can render HTML using any web server…
Let’s do something that no other web server can do now, shall we?
Counting kittens.
Rename your index.html file to index.page and replace its contents with the following:
let count = 1
export default _ => (
html`
<h1>Kitten count<h1>
${'😸️'.repeat(count++)}
`
)
Now run kitten, goto https://localhost, and refresh the page to see the number of kittens increase each time you do.
✨ Ooh, magic! ✨
How does it work?
A page in Kitten is written in plain old JavaScript.
Kitten uses file system routing. So, in this example, your /index.page file is mapped to the root route (/) of your site.
Routes are just exported default function inside of JavaScript modules. Any HTML you return from them is sent to browsers.
You write your HTML inside of
htmltagged template literals. These are just standard JavaScript template literals (template strings) that get passed through a special global function calledhtml.🪤 Make sure you close all your HTML tags! If you don’t, Kitten will throw a tantrum.
💡 Under the hood, the
htmlfunction uses htm and vhtml to do its magic.💡 As we’ll see in the later examples, you can also return an object that has separate
markup,styles, andscriptproperties. And you can keep styles and scripts in external files that use the.stylesand.scriptextensions that you import into your pages.
The example above contains the minimum code required to achieve our goal. However, here’s a more explicit version in case you’re not familiar with JavaScript arrow functions so you can really see what’s happening (and that there’s really no magic involved).
let count = 1
export default function route (_request, _response) {
return html`
<h1>Kitten count<h1>
${'😸️'.repeat(count++)}
`
}
With the above version of the code, it’s clear that what you’re exporting from the .page file is a route and that it gets passed the HTTP request and response objects.
Underscores?
We’ve added underscores to the names of the request and response parameters to make it clear that we know we’re not using them in the body of the function. We could also just have left the parameters out but it’s always good to be as explicit as possible about your intend when coding.
Persistence is the secret to success (or something)
So counting kittens is great fun but what happens if you restart the server?
All your kittens are lost! (This is a tragedy.)
So let’s fix that.
(Brace yourself, you’re about to use – drumroll – a SCARY database! Oooh!)
👻 Using JavaScript Database (JSDB) – a (not so) scary database
Update your code to match this:
if (db.kittens === undefined) db.kittens = {count: 1}
export default _ => (
html`
<h1>Kitten count<h1>
<p>${'😸️'.repeat(db.kittens.count++)}</p>
`
)
Now load the page, refresh, stop the server, restart it, and load the page again…
Wait, what?
That’s it?
Seriously?
Yep, that’s the magic of the integrated JavaScript Database (JSDB) in Kitten.
If you don’t believe me, restart the server and note that all your kittens are still there.
If you still don’t believe me (wow, what a cynic), look in the ~/.config/small-tech.org/kitten/database folder and you should see a folder there that mirrors the path of your project (e.g., if your project is in /var/home/aral/projects/greetings, the folder will be var.home.aral.projects.greetings). Inside your project’s database folder, you should see a kittens.js file. Open it up in a text editor and take a look. You should see something like this:
export const _ = { 'count': 18 };
_['count'] = 19;
_['count'] = 20;
That’s what a table looks like in JavaScript Database (JSDB), which is integrated into Kitten and available to all your routes via a global db reference.
Yes, you need never fear persistence ever again.
There’s so much more to JSDB that you can learn about in the JSDB documentation.
Fetchiverse
You can install, import, and use Node modules in your project just like in any other Node.js project. However, Kitten also has commonly-used global APIs you can use without installing or importing them. You’ve already seen one of those, the JavaScript Database (JSDB), which is available via the global db reference.
Similarly, the Fetch API is available for use as fetch.
Here’s an example of how to use the Fetch API to get the list of public posts from a Mastodon instance.
This is the instance we’ll be using: https://mastodon.ar.al
And this is the JSON endpoint with the public timeline data: https://mastodon.ar.al/api/v1/timelines/public
Take a look at both to understand what we’re working with before creating a new folder called fetchiverse with a file called index.page in it. Then add the following code to that file:
export default async function route (_request, _response) {
const postsResponse = await fetch('https://mastodon.ar.al/api/v1/timelines/public')
const posts = await postsResponse.json()
const markup = html`
<h1>Aral’s Public Fediverse Timeline</h1>
<ul>
${posts.map(post => (
html`
<li>
<a class='avatar-link' href='${post.account.url}'>
<img class='avatar' src='${post.account.avatar}' alt='${post.account.username}’s avatar'>
</a>
<div class='content'>
${post.content}
${post.media_attachments.map(media => (
media.type === 'image' ? html`<img class='image' src='${media.url}'>` : ''
))}
</div>
</li>
`
))}
</ul>
`
return { markup, styles }
}
// Notice we’re creating the styles outside of the route
// for efficiency as they are static.
const styles = css`
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; }
p:first-of-type { margin-top: 0; }
p { line-height: 1.5; }
a:not(.avatar-link) {
text-decoration: none; background-color: rgb(139, 218, 255);
border-radius: 0.25em; padding: 0.25em; color: black;
}
ul { padding: 0; }
li {
display: flex; align-items: flex-start; column-gap: 1em; padding: 1em;
margin-bottom: 1em; background-color: #ccc; border-radius: 1em;
}
.avatar { width: 8em; border-radius: 1em; }
.content { flex: 1; }
.image { max-width: 100%; }
`
Now, run kitten with:
kitten make-fetch-happen
And hit https://localhost to see the latest public timeline from Aral’s mastodon instance.
💾 This example is available in examples/fetchiverse.
💾 There’s also version of this example that implements a streaming timeline using a WebSocket in examples/streamiverse.
Components and fragments
The above example is only about 50 lines of code in a single file. While that’s fine for something so simple, in larger examples, it would help us maintain our code if we break it up into smaller components and fragments.
Let’s start by examining the layout of our list. We have two major elements in each list item: the author’s avatar and the post content itself. These would be prime candidates to make into separate fragments or components. So let’s do that:
export default async function route (_request, _response) {
const postsResponse = await fetch('https://mastodon.ar.al/api/v1/timelines/public')
const posts = await postResponse.json()
const markup = html`
<h1>Aral’s Public Fediverse Timeline</h1>
<ul>
${posts.map(post => (
html`
<li>
<${Avatar} post=${post} />
<${Content} post=${post} />
</li>
`
))}
</ul>
`
return { markup, styles }
}
const Avatar = post => (
html`
<a class='avatar-link' href='${post.account.url}'>
<img class='avatar' src='${post.account.avatar}' alt='${post.account.username}’s avatar'>
</a>
`
)
const Content = post => (
html`
<div class='content'>
${post.content}
${post.media_attachments.map(media => (
media.type === 'image' ? html`<img class='image' src='${media.url}'>` : ''
))}
</div>
`
)
const styles = css`
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; }
p:first-of-type { margin-top: 0; }
p { line-height: 1.5; }
a:not(.avatar-link) {
text-decoration: none; background-color: rgb(139, 218, 255);
border-radius: 0.25em; padding: 0.25em; color: black;
}
ul { padding: 0; }
li {
display: flex; align-items: flex-start; column-gap: 1em; padding: 1em;
margin-bottom: 1em; background-color: #ccc; border-radius: 1em;
}
.avatar { width: 8em; border-radius: 1em; }
.content { flex: 1; }
.image { max-width: 100%; }
`
Once you’ve separated your page into components and fragments, there’s no rule that says they must all be in the same file. Since they are just snippets of JavaScript, you can put each one in its own file and import it in.
😻 In Kitten, we call a custom HTML element that can be included as a custom tag a component and any other snippet of HTML, CSS, or JavaScript a fragment. They go in .component and .fragment files, respectively. Like every other Kitten-specific extension, these are just JavaScript files. And you don’t have to adhere to this convention but others who know Kitten can contribute to your projects more easily if you do.
So here’s one way we could organise the code:
index.page
import Avatar from './Avatar.component'
import Content from './Content.component'
import styles from './styles.fragment'
export default async function route (_request, _response) {
const postsResponse = await fetch('https://mastodon.ar.al/api/v1/timelines/public')
const posts = await postsResponse.json()
const markup = html`
<h1>Aral’s Public Fediverse Timeline</h1>
<ul>
${posts.map(post => (
html`
<li>
<${Avatar} post=${post} />
<${Content} post=${post} />
</li>
`
))}
</ul>
`
return { markup, styles }
}
Avatar.component
export default ({ post }) => (
html`
<a class='avatar-link' href='${post.account.url}'>
<img class='avatar' src='${post.account.avatar}' alt='${post.account.username}’s avatar' />
</a>
`
)
Content.component
export default post => (
html`
<div class='content'>
${post.content}
${post.media_attachments.map(media => (
media.type === 'image' ? html`<img class='image' src='${media.url}'>` : ''
))}
</div>
`
)
styles.fragment
export default _ => (
css`
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; }
p:first-of-type { margin-top: 0; }
p { line-height: 1.5; }
a:not(.avatar-link) {
text-decoration: none; background-color: rgb(139, 218, 255);
border-radius: 0.25em; padding: 0.25em; color: black;
}
ul { padding: 0; }
li {
display: flex; align-items: flex-start; column-gap: 1em; padding: 1em;
margin-bottom: 1em; background-color: #ccc; border-radius: 1em;
}
.avatar { width: 8em; border-radius: 1em; }
.content { flex: 1; }
.image { max-width: 100%; }
`
)
💡 Separating your pages into components and fragments should make your sites easier to maintain but this does come at the expense of locality of behaviour (keeping related functionality together so you can easily read through the code in a linear fashion). In this example, I probably would have kept the
AvatarandContentcomponents in the index.page file and put the CSS fragment into its own file. Don’t be afraid to experiment with how you organise your own projects. Soon, you’ll develop a knack for knowing when you’ve hit the sweet spot.
Streamiverse
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 base fetchiverse example.
index.page
Let’s start with the code for our new index page:
import styles from './styles.fragment'
import Post from './Post.component'
export default async function route (_request, _response) {
const response = await fetch('https://mastodon.ar.al/api/v1/timelines/public')
const posts = await response.json()
const markup = html`
<h1>Aral’s Public Fediverse Timeline</h1>
<ul id='posts' hx-ext='ws' ws-connect='/updates'>
${posts.map(post => html`<${Post} post=${post} />`)}
</ul>
`
return({ markup, styles })
}
Pay special attention to the unordered list tag’s attributes:
<ul id='posts' hx-ext='ws' ws-connect='/updates'>
${posts.map(post => html`<${Post} post=${post} />`)}
</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 _/updates (ws-connect='/updates')
Believe it or not, that’s all the code you need on the client to set up and manage a WebSocket connection. Kitten will include htmx and the htmx WebSockets extension automatically in your page and serve it from your site so you don’t have to worry about setting up any of that either.
😻 When I said Kitten loves you, I meant Kitten loves you.
Post.component
Notice how we’ve refactored the fetchiverse example so that we now have a Post component. We’ve also added the simple Avatar and Content components to the same file in the name of locality of behaviour.
export default function Post ({ post }) {
return html`
<li>
<${Avatar} post=${post} />
<${Content} post=${post} />
</li>
`
}
const Avatar = ({ post }) => (
html`
<a class='avatar-link' href='${post.account.url}'>
<img class='avatar' src='${post.account.avatar}' alt='${post.account.username}’s avatar' />
</a>
`
)
const Content = ({ post }) => (
html`
<div class='content'>
${raw(post.content)}
${post.media_attachments.map(media => (
media.type === 'image' ? html`<img class='image' src='${media.url}' />` : ''
))}
</div>
`
)
A post is a natural unit for a component in our example as we receive individual post updates from the Mastodon API. Since we send HTML over the wire, our web socket will have to create Post instances. And we also have to create Post instances in our original GET route in the index page that sends over the initial timeline. By having Post as a component in its own module, we can import and use it from both places.
💡 None of the code in the
Post,Avatar, orContentcomponents has otherwise changed from the fetchiverse example.
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 files.
import Post from './Post.component'
let stream = null
export default function socket (socket, _request) {
// Lazily start listening for timeline updates on first request.
if (stream === null) {
console.log(' 🐘 Listening for Mastodon updates…')
stream = new WebSocket('wss://mastodon.ar.al/api/v1/streaming?stream=public')
stream.addEventListener('message', event => {
const message = JSON.parse(event.data)
if (message.event === 'update') {
const post = JSON.parse(message.payload)
console.log(` 🐘 Got an update from ${post.account.username}!`)
const update = html`
<div hx-swap-oob="afterbegin:#posts">
<${Post} post=${post} />
</div>
`
socket.all(update)
}
})
}
}
The WebSocket route itself creates a WebSocket connection to consume Aral’s public Mastodon feed. It also adds a message listener that gets called each time there’s a message from the Mastodon server. Since we only care about new posts in this example, we only handle update messages.
The messages sent by Mastodon 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 = html`
<div hx-swap-oob="afterbegin:#posts">
<${Post} post=${post} />
</div>
`
The hx-swap-oob attribute (which stands for swap out-of-band) will signal to htmx in the browser that it should add this post 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 created our Post snippet, 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 Aral’s public fediverse timeline streaming from his Mastodon server.
💡 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.
Kitten chat
In the Streamiverse example, we used a WebSocket to stream in post updates but we could just as well have used Server-Sent Events (SSE) given that the communication was one-way.
💡 Kitten does not currently implement support for HTTP/2 so if you use SSE, people will be limited to 6 browser connections to your app.
🚧 Support for Server-Sent Events has not been implemented yet in Kitten.
What’s unique about WebSockets is that you can carry out asynchronous two-way communication. So let’s take advantage of that by creating a very simple chat app.
To being with, let’s create the index page:
index.page
import styles from './index.styles'
// The page template is static so we render it outside
// the route handler for efficiency (so it only gets rendered
// once when this file first loads instead of on every request).
const markup = html`
<h1>😸 <a href='https://kitten.small-web.org'>Kitten</a> Chat</h1>
<div id='chat' hx-ext='ws' ws-connect='/chat'>
<ul id='messages'>
<!-- Received messages will go here. -->
</ul>
<form id='message-form' ws-send>
<label for='nickname'>Nickname:</label>
<input id='nickname' name='nickname' required />
<label for='text'>Message:</label>
<input id='text' name='text' required />
<button id='sendButton' type='submit'>Send</button>
</form>
</div>
`
export default function route (_request, _response) {
return { markup, styles }
}
Also, create an index.styles file and add the following CSS rules to it so our interface fills up the whole browser window, with the majority of the space reserved for the chat messages.
index.styles
export default _ => css`
* { box-sizing: border-box; }
h1 {
margin-top: 0;
margin-bottom: 0;
}
p {
margin-top: 0;
margin-bottom: 0;
}
a {
color: #334b4c;
}
form {
background: #eee;
display: grid;
grid-template-columns: [labels] auto [controls] 1fr;
align-items: center;
grid-row-gap: 0.5em;
grid-column-gap: 0.5em;
padding: 0.75em;
width: 100%;
}
form > label { grid-column: labels; }
form > input, form > button {
grid-column: controls;
min-width: 6em;
max-width: 300px;
padding: 0.5em;
font-size: 1em;
}
button {
text-align: center;
cursor: pointer;
font-size:16px;
color: white;
border-radius: 4px;
background-color:#466B6A;
border: none;
padding: 0.75em;
padding-top: 0.25em;
padding-bottom: 0.25em;
transition: color 0.5s;
transition: background-color 0.5s;
}
button:hover {
color: black;
background-color: #92AAA4;
}
button:disabled {
color: #999;
background-color: #ccc;
}
/* We want the interface to take up the full browser canvas. */
#application {
display: flex;
font-family: sans-serif;
height: calc(var(--vh, 1vh) * 100 - 1em);
flex-direction: column;
flex-wrap: nowrap;
justify-content: flex-start;
align-content: stretch;
align-items: flex-start;
padding: 1em;
}
/* Make sure the chat div does not affect the layout. */
#chat {
display: contents;
}
#messages {
list-style: none;
width: 100%;
flex: 100 1 auto;
align-self: stretch;
overflow-y: scroll;
background-color: #eee;
padding: 0.75em;
}
`
OK, and now, finally, let’s create our socket route to handle the passing of messages between people.
chat.socket
🚧 You should be able to give socket routes the same name as page (regular GET) routes however, this is not functioning correctly in Kitten at the moment. Until then, please name them differently to your page routes. That’s why we haven’t named our socket index.socket here although it would make sense to and be more consistent.
export default function (socket, request) {
socket.addEventListener('message', event => {
// A new message has been received: broadcast it to all clients
// in the same room after performing basic validation.
const message = JSON.parse(event.data)
if (!isValidMessage(message)) {
console.warn(`Message is invalid; not broadcasting.`)
return
}
const numberOfRecipients = socket.all(
html`
<div hx-swap-oob="beforeend:#messages">
<li><strong>${message.nickname}</strong> ${message.text}</li>
</div>
`
)
// Log the number of recipients message was sent to
// and make sure we pluralise the log message properly.
console.log(` 🫧 Kitten ${request.originalUrl} message from ${message.nickname} broadcast to `
+ `${numberOfRecipients} recipient`
+ `${numberOfRecipients === 1 ? '' : 's'}.`)
})
}
// Some basic validation.
// Is the passed object a valid string?
function isValidString(s) {
return Boolean(s) // Isn’t null, undefined, '', or 0
&& typeof s === 'string' // and is the correct type
&& s.replace(/\s/g, '') !== '' // and is not just whitespace.
}
// Is the passed message object valid?
function isValidMessage(m) {
return isValidString(m.nickname) && isValidString(m.text)
}
Now, if you run kitten and visit https://localhost you should see the chat interface and be able to send messages. Open another browser window to see the messages appear.
💡 When you send a message, we are not optimistically copying it to the messages list on the client. In fact, our app currently has no custom client-side functionality at all. We’ve declared all dynamic functionally as htmx attributes in the HTML. So that’s why we use the socket’s all() method to send received messages to all clients, including the one that originally sent the message. (Or else the person sending the message would not see it in the message list.) This also means that you can know that the socket is working even if you don’t open another browser tab or window to see the sent messages appear as long as they’re appearing for you in your own window after being sent.
If we were optimistically updating the messages list with client-side logic, we would use the
broadcast()method on the socket instead. This method ensures that a message is sent to all clients apart from the one that originally sent it.Also, due to the way htmx’s WebSocket extension functions, the outer
(or any other outer element you specify in your response) is stripped by htmx before updating the document. So if you view source on your page, you’ll see that only the list items are present in the list.
So we’ve just written a very basic chat app without writing any custom client-side logic at all. That’s pretty cool. But our chat app does have a number of usability issues that we could improve by sprinkling a little custom logic on the client using hyperscript.
Enhancing usability with hyperscript
The most visible and annoying usability issue right now is that when you send a message, the message box is not cleared. So let’s fix that first:
<form id='message-form' ws-send>
…
<input
id='text' name='text' required
script='on htmx:wsSend from #message-form set my value to ""'
/>
</form>
💡 The
wsSendevent is unique to Kitten at the moment and does not exist in htmx’s WebSocket extension. I will be contributing them upstream when I get a moment.
Run Kitten and test the chat app and note that sent messages are now cleared from the input box.
Ah, that’s better! 😻
But how about the first-launch experience?
When the page initially loads, the message list is empty. If you’ve used the app before, then you’ll know that that’s where the messages go but it’s not overly friendly. So let’s show a placeholder message there when there are no messages and hide it when the first message arrives.
Modify your index.page to add a placeholder list item to the #messages list:
<div id='chat' hx-ext='ws' ws-connect='/chat'>
<ul id='messages'>
<li id='placeholder' script='on htmx:load from #chat hide me'>
No messages yet, why not send one?
</li>
</ul>
…
</div>
Run the app and verify that the placeholder gets hidden when the first message arrives in the message list.
The #chat div that encapsulates the WebSocket connect fires htmx-load events whenever new data is loaded. When the placeholder hears this event, it hides itself.
So that was simple.
But how about this: reduce the height of your chat window so that there is only space for two or three to messages to display. Now send yourself some messages and notice what happens when the fourth or fifth message comes in. They’re added to the list but they aren’t shown on screen as they’re added to the bottom of the chat section.
Let’s fix that by adding a bit more hyperscript to make the chat section scroll to the bottom whenever a new message is received:
<div id='chat' hx-ext='ws' ws-connect='/chat'>
<ul id='messages' script='on htmx:load from #chat set my scrollTop to my scrollHeight'>
…
</ul>
…
</div>
Adding a status indicator
Since a WebSocket is a persistent connection, it would be good to know when we get disconnected. htmx’s WebSocket extension does a good job of queuing messages when this happens but it would help if we knew that we were offline (either because our Internet connection is disrupted or because the server has died).
So let’s add a status indicator component that uses hyperscript to achieve this:
StatusIndicator.component
export default function StatusIndicator ({ target }) {
return html`
<p>Status: <span id='status' script='
on htmx:wsConnecting from ${target}
put "Connecting…" into me
remove .online from me
remove .offline from me
end
on htmx:wsOpen from ${target}
put "Online" into me
add .online to me
remove .offline from me
end
on htmx:wsClose from ${target}
put "Offline" into me
add .offline to me
remove .online from me
end
'>Initialising…</span></p>
<style>
.online {color: green}
.offline {color: red}
</style>
`
}
🚧 Notice that we’ve inlined the CSS into the HTML here. While this is fine for simple CSS, it won’t work for more involved rules (e.g., child selectors) as the html template tag will escape characters like the greater-than symbol. In the future, you’ll be able to either return { markup, styles } from components like you can from pages and/or the html parsing will be more intelligent about escaping with embedded styles, etc.
💡 The
wsConnecting,wsOpen, andwxCloseevents are unique to Kitten at the moment and do not exist in htmx’s WebSocket extension. I will be contributing them upstream when I get a moment.
Finally, let’s add the StatusIndicator component to our index.page:
import styles from './index.styles'
import script from './index.script'
import StatusIndicator from './StatusIndicator.component'
const markup = html`
<h1>😸 <a href='https://kitten.small-web.org'>Kitten</a> Chat</h1>
<${StatusIndicator} target='#chat'/>
<div id='chat' hx-ext='ws' ws-connect='/chat'>
…
`
Now, when you run Kitten Chat, you should see the indicator turn green when you’re online.
Stop Kitten and note that the indicator turns red and shows you that you’re offline.
Restart Kitten and note the indicator turn green again once the app reconnects.
🚧 The htmx WebSocket extension implements an exponential backoff algorithm that keeps trying to connect to the server after exponentially longer waiting periods after getting disconnected. There is an issue with this implementation where this interval does not reset even if you close the browser tab. You actually have to restart the browser for the interval to reset. I’m going to look into filing a bug about this and hopefully contribute a fix upstream once I get a chance.
Adding final touches for mobile devices using custom JavaScript
While hyperscript lets you do commonly done things easily, it’s sometimes easier to just pop out into JavaScript. You can easily do so by creating a .script file that will be added to your page as an inline <script> element.
In this case, we want to use a trick to ensure that our interface displays correctly even when the address bar is visible in mobile browsers. This is a hacky workaround (oh, hello, welcome to web development) so let’s add a .script file to include it:
index.script
export default _ => {
// Fix on mobile so interface takes up full-screen.
function fixInterfaceSizeOnMobile () {
function resetHeight() {
let vh = window.innerHeight * 0.01;
document.querySelector(':root').style.setProperty('--vh', `${vh}px`)
// document.documentElement.style.setProperty('--vh', `${vh}px`)
}
resetHeight()
window.onresize = function (_event) {
resetHeight()
}
}
window.addEventListener('load', fixInterfaceSizeOnMobile)
}
While this is an issue that only surfaces on mobile devices, running the fix does not have a negative effect on desktop browsers so we just always run it when the page first loads by listening for the load event on the window.
💡 Notice how the .script file exports a function. Kitten takes the source code of this function and appends it to your page within a
<script>…</script>section. Keep in mind that although you’re writing JavaScript code just like in your page and socket routes, this code is meant to run in the client so it never executes on the server.
Focus is hard sometimes
Finally, let’s make one last usability improvement. Wouldn’t it be nice if we could keep chatting after sending a message without having to constantly click in the chat box? (In other words, if the chat box kept focus after sending a message, even if we click the Send button, for example.)
This is very easy to do using hyperscript. Modify #text input in the #message-form as shown below:
<form id='message-form' ws-send>
…
<input
id='text' name='text' required script='
on htmx:wsSend from #message-form
set my value to ""
focus() me
'/>
…
</form>
Now run the example on a desktop computer and notice that the message box keeps its focus even if you press the Send button.
That’s nice!
But now run the example on a mobile phone with a virtual keyboard. Ah. By keeping focus on the message field, we’re stopping the keyboard from hiding. And that means that we can’t see the message we just sent. That’s less than ideal. So let’s implement another little hack by defining a function that tries to detect if the person is on mobile (remember that none of these hacks are ideal) and then let’s see how we can call that JavaScript from our hyperscript.
First, add a function called isMobile() to index.script:
export default _ => {
globalThis.isMobile = _ => {
return (/(android|bb\d+|meego).+mobile|avantgo|bada\/|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(hone|od)|ipad|iris|kindle|Android|Silk|lge |maemo|midp|mmp|netfront|opera m(ob|in)i|palm( os)?|phone|p(ixi|re)\/|plucker|pocket|psp|series(4|6)0|symbian|treo|up\.(browser|link)|vodafone|wap|windows (ce|phone)|xda|xiino/i.test(navigator.userAgent)
|| /1207|6310|6590|3gso|4thp|50[1-6]i|770s|802s|a wa|abac|ac(er|oo|s\-)|ai(ko|rn)|al(av|ca|co)|amoi|an(ex|ny|yw)|aptu|ar(ch|go)|as(te|us)|attw|au(di|\-m|r |s )|avan|be(ck|ll|nq)|bi(lb|rd)|bl(ac|az)|br(e|v)w|bumb|bw\-(n|u)|c55\/|capi|ccwa|cdm\-|cell|chtm|cldc|cmd\-|co(mp|nd)|craw|da(it|ll|ng)|dbte|dc\-s|devi|dica|dmob|do(c|p)o|ds(12|\-d)|el(49|ai)|em(l2|ul)|er(ic|k0)|esl8|ez([4-7]0|os|wa|ze)|fetc|fly(\-|_)|g1 u|g560|gene|gf\-5|g\-mo|go(\.w|od)|gr(ad|un)|haie|hcit|hd\-(m|p|t)|hei\-|hi(pt|ta)|hp( i|ip)|hs\-c|ht(c(\-| |_|a|g|p|s|t)|tp)|hu(aw|tc)|i\-(20|go|ma)|i230|iac( |\-|\/)|ibro|idea|ig01|ikom|im1k|inno|ipaq|iris|ja(t|v)a|jbro|jemu|jigs|kddi|keji|kgt( |\/)|klon|kpt |kwc\-|kyo(c|k)|le(no|xi)|lg( g|\/(k|l|u)|50|54|\-[a-w])|libw|lynx|m1\-w|m3ga|m50\/|ma(te|ui|xo)|mc(01|21|ca)|m\-cr|me(rc|ri)|mi(o8|oa|ts)|mmef|mo(01|02|bi|de|do|t(\-| |o|v)|zz)|mt(50|p1|v )|mwbp|mywa|n10[0-2]|n20[2-3]|n30(0|2)|n50(0|2|5)|n7(0(0|1)|10)|ne((c|m)\-|on|tf|wf|wg|wt)|nok(6|i)|nzph|o2im|op(ti|wv)|oran|owg1|p800|pan(a|d|t)|pdxg|pg(13|\-([1-8]|c))|phil|pire|pl(ay|uc)|pn\-2|po(ck|rt|se)|prox|psio|pt\-g|qa\-a|qc(07|12|21|32|60|\-[2-7]|i\-)|qtek|r380|r600|raks|rim9|ro(ve|zo)|s55\/|sa(ge|ma|mm|ms|ny|va)|sc(01|h\-|oo|p\-)|sdk\/|se(c(\-|0|1)|47|mc|nd|ri)|sgh\-|shar|sie(\-|m)|sk\-0|sl(45|id)|sm(al|ar|b3|it|t5)|so(ft|ny)|sp(01|h\-|v\-|v )|sy(01|mb)|t2(18|50)|t6(00|10|18)|ta(gt|lk)|tcl\-|tdg\-|tel(i|m)|tim\-|t\-mo|to(pl|sh)|ts(70|m\-|m3|m5)|tx\-9|up(\.b|g1|si)|utst|v400|v750|veri|vi(rg|te)|vk(40|5[0-3]|\-v)|vm40|voda|vulc|vx(52|53|60|61|70|80|81|83|85|98)|w3c(\-| )|webc|whit|wi(g |nc|nw)|wmlb|wonu|x700|yas\-|your|zeto|zte\-/i.test(navigator.userAgent.substr(0,4)))
}
// …
}
💡 Notice how we declared the function in global scope so we can call it from hyperscript.
Now, modify your index.page one last time to only focus the field if the person is not on a mobile device:
<form id='message-form' ws-send>
…
<input
id='text' name='text' required script='
on htmx:wsSend from #message-form
-- Clear the text input.
set my value to ""
-- On mobile, don’t refocus the input box
-- in case there is a virtual keyboard so
-- the person can see the message they just sent.
if not isMobile() then focus() me
'/>
…
</form>
💡 Take note that this approach is brittle. We are using the user agent string to make a best guess effort whether the person is on a mobile device. User agent strings can be spoofed. Beyond that, just because a person is on a mobile device, it doesn’t mean that they’re using a virtual keyboard. They could have a physical keyboard attached. Unfortunately, there isn’t a built-in way of detecting from a web page whether someone is using a virtual keyboard. Although there are other hacks you might want to try.
You can find the final version of this example is the examples/kitten-chat folder.
Persistent Kitten Chat
While we were able to improve the usability of the Kitten Chat example by sprinkling a little hyperscript here, a little JavaScript there on the client, there is one limitation that we need to implement more server-side functionality to overcome: the messages are not persisted.
If two people are having a chat and someone else enters the room, they don’t see the messages that have already been sent.
JavaScript Database (JSDB) to the rescue once again!
What we need to do is to persist messages when they arrive in our chat.socket and display the messages that are already in our database while rendering our index.page. Since both the socket and page route now need to create messages, let’s start by creating a Message component that can be used by both of them:
Message.component
export default function Message ({ message }) {
return html`<li><strong>${message.nickname}</strong> ${message.text}</li>`
}
This component simply takes a message object and render a list item that shows the person’s nickname and the text of their message.
Now, let’s refactor our WebSocket route (index.socket) to:
- Create our messages table (if it doesn’t exist),
- Persist received messages to the messages table,
- Render messages using the Message component and send them to all connected clients.
chat.socket
import Message from './Message.component'
// Ensure the messages table exists in the database before using it.
if (db.messages === undefined) db.messages = []
// Kitten Chat example back-end.
export default function (socket, request) {
socket.addEventListener('message', event => {
const message = JSON.parse(event.data)
if (!isValidMessage(message)) {
console.warn(`Message is invalid; not broadcasting.`)
return
}
// We don’t need to use the message HEADERS, delete them so
// they don’t take unnecessary space when we persist the message.
delete message.HEADERS
// Persist the message in the messages table.
db.messages.push(message)
const numberOfRecipients = socket.all(
html`
<div hx-swap-oob="beforeend:#messages">
<${Message} message='${message}' />
</div>
`
)
// …
}
}
// …
💡 Messages sent by the htmx WebSocket extension include a htmx-specific
HEADERSobject.For example:
{ nickname: 'Aral', text: 'Hello, everyone!', HEADERS: { 'HX-Request': 'true', 'HX-Trigger': 'message-form', 'HX-Trigger-Name': null, 'HX-Target': 'chat', 'HX-Current-URL': 'https://localhost/' } }All we want to store in the database is the
nicknameandtextso we delete theHEADERSobject before persisting so we’re left with an array of objects like the following in our database:{ nickname: 'Aral', text: 'Hello, everyone!' }
Finally, let’s modify our index.page to both use our new Message component and, if there are any messages in the database, to render them in the page:
index.page
import styles from './index.styles'
import script from './index.script'
import Message from './Message.component'
import StatusIndicator from './StatusIndicator.component'
// Ensure the messages table exists in the database before using it.
if (db.messages === undefined) db.messages = []
export default function route (_request, _response) {
const markup = html`
…
<div id='chat' hx-ext='ws' ws-connect='/chat'>
<ul id='messages' _='on htmx:load from #chat set my scrollTop to my scrollHeight'>
${(messages => {
if (messages.length === 0) {
return html`
<li id='placeholder' _='on htmx:load from #chat hide me'>
No messages yet, why not send one?
</li>
`
} else {
return messages.map(message => html`<${Message} message=${message} />`)
}
})(db.messages)}
</ul>
…
</div>
`
return { markup, styles, script }
}
💡 Since the conditional logic in our template is somewhat verbose, I chose to use an immediately-invoked arrow function expression to encapsulate it (so I could express the conditional using and
ifandelseblocks:${(messages => { if (messages.length === 0) { return html` <li id='placeholder' _='on htmx:load from #chat hide me'> No messages yet, why not send one? </li> ` } else { return messages.map(message => html`<${Message} message=${message} />`) } })(db.messages)}If that’s confusing to read, you can also write it as a regular immediately-invoked function expression (IIFE):
${(function (messages) { if (messages.length === 0) { return html` <li id='placeholder' _='on htmx:load from #chat hide me'> No messages yet, why not send one? </li> ` } else { return messages.map(message => html`<${Message} message=${message} />`) } })(db.messages)}Or, if that’s still confusing, you can always use a conditional (ternary) operator. Notice how you refer to
db.messagesdirectly if you do this.${ db.messages.length === 0 ? html` <li id='placeholder' _='on htmx:load from #chat hide me'> No messages yet, why not send one? </li> ` : db.messages.map(message => html`<${Message} message=${message} />`) }All three of these approaches are equivalent. Feel free to use the one that reads best for you.
And that’s it: now when you run the app and load it in your browser, you will see any messages that were sent previously when the page first loads.
I guess persistence really does pay off.
(I’m here all week. 😸)
📝 TODO: KITTEN REWRITE. LEFT OFF HERE.
APIs and working with data
For many projects, you should be able to keep your both your client and server code in the same .page file using NodeScript.
However, if you have an API or purely data-related routes, you can create server-side routes by creating files with any valid HTTP1/1.1 method lowercased as the file extension (i.e., .get, .post, .patch, .head, etc.)
Also, you can create a WebSocket route simply by creating a .socket file.
e.g.,
my-project
├ index.page
├ index.post
├ about
│ ╰ index.page
├ todos
│ ╰ index.get
╰ chat
╰ index.socket
Optionally, to organise larger projects, you can encapsulate your site within a src folder. If a src folder does exist, Kitten will only serve routes from that folder and not from the project root.
e.g.,
my-project
├ src
│ ├ index.page
│ ├ index.post
│ ├ index.socket
│ ╰ about
│ ╰ index.page
├ test
│ ╰ index.js
╰ README.md
Valid file types
Kitten doesn’t force you to put different types of routes into predefined folders. Instead, it uses file extensions to know how to handle different routes and other code and assets.
Here is a list of the main file types Kitten handles and how it handles them:
| Extension | Type | Behaviour |
|---|---|---|
| .page | Kitten page (supports NodeScript) | Compiled into HTML and served in response to a HTTP GET request for the specified path. |
| .get, .head, .patch, .options, .connect, .delete, .trace, .post, .put | HTTP route | Served in response to an HTTP request for the specified method and path. |
| .socket | WebSocket route | Served in response to a WebSocket request for the specified path. |
| .component | Svelte component | Ignored by router. |
| .svelte | Svelte component (.component is just an alias for .svelte) | Ignored by router. |
| .js | Javascript module | Ignored by router. |
HTTP routes
HTTP data routes are served in response to an HTTP request for the specified method and path.
All HTTP request methods are supported.
You create an HTTP route by create a JavaScript file named with the HTTP request method you want to respond to.
For example, to respond to GET requests at /books, you would create a file named books.get in the root of your source folder.
The content of HTTP routes is an ESM module that exports a standard Node route request handler that takes http.IncomingMessage and http.ServerResponse arguments.
For example, your books.get route might look like this:
export default (request, response) => {
const books = db.books.get()
response.end(books)
}
WebSocket routes
WebSocket routes are defined in files ending with the .socket extension.
They resemble HTTP routes but get the socket passed in as an extra initial parameter.
For example, here’s a basic echo.socket route that repeats whatever it receives back to you:
export default (socket, request, response) => {
socket.addEventListener('message', event => {
socket.send(event.data)
})
}
💡 Since we’re not using the
requestorresponseobjects in this route, we could have just left them off of the function signature.
And a simple index.page route that uses it:
<socket>
export default (socket, request, response) => {
socket.addEventListener('message', event => {
socket.send(event.data)
})
}
</socket>
<script>
import { onMount } from 'svelte'
let messageField
let socket
let message = ''
let messages = []
onMount(() => {
// Initialise the web socket
socket = new WebSocket(`wss:///`)
socket.addEventListener('open', event => {
socket.send('Hello, there!')
})
socket.addEventListener('message', event => {
messages = [...messages, event.data]
})
})
</script>
<h1>WebSocket Echo Demo</h1>
<form id='messageForm' on:submit|preventDefault={event => {
socket.send(message)
message = ''
messageField.focus()
}}>
<label>Message:
<input type='text' bind:this={messageField} bind:value={message}>
</label>
<button type='submit'>Send</button>
</form>
<h2>Received messages</h2>
<ul id='received'>
{#each messages as message}
<li>{message}</li>
{/each}
</ul>
And, just like with inline <get>…</get> routes, you can also inline WebSocket routes so they share the same route as the page. So all the code for the example, above, can be added to the same page:
<script>
import { onMount } from 'svelte'
let messageField
let socket
let message = ''
let messages = []
onMount(() => {
// Initialise the web socket
socket = new WebSocket(`wss://localhost/echo`)
socket.addEventListener('open', event => {
socket.send('Hello, there!')
})
socket.addEventListener('message', event => {
messages = [...messages, event.data]
})
})
</script>
<h1>WebSocket Echo Demo</h1>
<form id='messageForm' on:submit|preventDefault={event => {
socket.send(message)
message = ''
messageField.focus()
}}>
<label>Message:
<input type='text' bind:this={messageField} bind:value={message}>
</label>
<button type='submit'>Send</button>
</form>
<h2>Received messages</h2>
<ul id='received'>
{#each messages as message}
<li>{message}</li>
{/each}
</ul>
Database
Kitten has an integrated JSDB database that’s available from all your routes as db.
JSDB is a transparent, in-memory, streaming write-on-update JavaScript database for the Small Web that persists to a JavaScript transaction log.
You can find the databases for your projects in the ~/.small-tech.org/kitten/database folder. Each project gets its own folder in there with a name based on the absolute path to your project on your disk (e.g., if a Kitten project is stored in /var/home/aral/projects/my-project, its database will be in a folder named var.home.aral.projects.my-project in the main database folder.)
Tables in JSDB are simply JavaScript objects or arrays and JSDB writes to plain old JavaScript files.
Route parameters
You can include route parameters in your route paths by separating them with underscores and surrounding the parameter names in square brackets.
For example:
manage_[token]_[domain].socket
Will create a WebSocket endpoint at:
/manage/:token/:domain
You can also intersperse path fragments with parameters:
books_[id]_pages_[page].page
Will compile the Kitten page and make it available for HTTP GET requests at:
/books/:id/pages/:page
So you can access the route via, say, https://my.site/books/3/pages/10.
You can also specify the same routes using folder structures. For example, the following directory structure will result in the same route as above:
my-site
╰ books
╰ [id]
╰ pages
╰ [page].page
Note that you could also have set the name of the page to index[page].page_. Using just [page].page for a parameterised index page is a shorthand.
You can decide which strategy to follow based on the structure of your app. If, for example, you could access not just the pages but the references and images of a book, it might make sense to use a folder structure:
my-site
╰ books
╰ [id]
├ pages
│ ╰ [page].page
├ references
│ ╰ [reference].page
╰ images
╰ [image].page
You may, or may not find that easier to manage than:
my-site
├ books_[id]_pages_[page].page
├ books_[id]_references_[reference].page
╰ books_[id]_images_[image].page
Kitten leaves the decision up to you.
Multiple roots
For larger projects, you might want to organise your routes, say, to separate your pages from your API. You can specify any folder within your source to be a new route by prefixing its name with an octothorpe (hash symbol/#).
For example, you can split the following directory structure:
my-project
├ index.page
├ contacts.page
╰ contacts.post
As:
my-project
├ index.page
╰ #api
├ contacts.page
╰ contacts.post
Or even:
my-project
├ #pages
│ ├ index.page
│ ╰ contacts.page
╰ #api
╰ contacts.post
In all of the above versions, HTTP GET calls to /contacts will find contacts.page and HTTP POST calls to /contacts will find contacts.post.
(If you wanted your contacts.post route to be accessible from /api/contacts instead, you would just remove the # and make it a regular folder.)
Static files
If you want Kitten to serve static files, put them in a special folder called #static. This is a special case of the multiple roots feature explained above, where any files (excluding dotfiles) are served as static elements.
For example:
my-project
├ index.page
╰ #static
├ header.svg
╰ demo.mp4
Command-line interface
serve
Default command.
💡
kitten serve [path to serve]andkitten [path to serve]are equivalent.
Note that if do not specify a path to serve, the default directory (./) is assumed.
--version
Displays the version number.
Currently does not exit the process unless when run from the distribution build.
⚠️ Building Kitten
To build a distribution bundle for Kitten, run:
./build
You will find the distribution under the dist/ folder.
To run Kitten from the distribution folder, use the following syntax:
./kitten [path to serve]
💡 It’s usually easier just to run
bin/kitten [path to serve]without building or, to test the distribution build, the ./quick-install script as that will run build for you and install the kitten command into your path so you can run it askitten [path to serve]
⚠️ Debugging
To run Kitten with the Node debugger (node --inspect), start it using:
bin/kitten-inspect [path to serve]
💡 If you use VSCodium, you can add breakpoints in your code and attach to the process using the Attach command in the Run and Debug panel.
Testing
Tests are written in Tape With Promises, run using ESM Tape Runner, and displayed using Tap Monkey.
Coverage is provided by c8.
Run tests:
npm -s test
Run coverage:
npm run -s coverage
💡️ The -s just silences the npm logs for cleaner output.
Technical design
Kitten is an ESM-only project for Node.js and relies on (the currently experimental) ES Module Loaders (follow the latest work) functionality.
Additionally, Kitten relies on a number of core dependencies for its essential features.
Core dependencies
| Dependency | Purpose |
|---|---|
| @small-tech/https | Drop-in replacement for Node’s native https module with automatic TLS for development and production using @small-tech/auto-encrypt and @small-tech/auto-encrypt-localhost. |
| @small-tech/jsdb | Zero-dependency, transparent, in-memory, streaming write-on-update JavaScript database that persists to JavaScript transaction logs. |
| Polka@next | Native HTTP server with added support for routing, middleware, and sub-applications. Polka uses Trouter as its router. |
| tinyws | WebSocket middleware for Node.js based on ws. |
| Svelte | Interface framework. NodeScript, used in Pages, is an extension of Svelte. Components, used in Pages, are Svelte components. |
| esbuild | An extremely fast JavaScript bundler. Used to bundle hydration scripts and NodeScript routes during server-side rendering. |
| node-git-server | Git server for hosting your source code. Used in deployments. |
| isomorphic-git | Git client used in deployments on development and for handling auto-updates on production. |
| sade | A small command-line interface (CLI) framework that uses mri for its argument parsing. |
Frequently-Asked Questions (FAQs)
What about serverless?
Dude, this is literally a server. If you want “serverless” (funny how folks who own servers want you to go serverless, isn’t it? It’s almost like a small group of people get to own stuff and you have to rent from them on their terms… hmm 🤔️) then use some Big Tech framework like SvelteKit. They will bend over backwards to cater to all your Big Tech needs.
Can you add <insert Big Tech feature here>?
No, go away.
Will this scale?
Fuck off.
(Yes, it will scale for the purposes it was designed for. It will not scale for the purposes of farming the population for their data and destroying our human rights and democracy in the process. That’s a feature, not a bug.)
Is there anything stopping me from using this to build sites or apps that violate people’s privacy and farm them for their data? (You know, the business model of Silicon Valley… that thing we call surveillance capitalism?)
No, there is nothing in the license to stop you from doing so.
But I will fucking haunt you in your nightmares.
(Just sayin’)
Also, it’s not nice. Don’t.
Is this really a Frequently Asked Questions section or a political statement?
Can’t it be both?
(It’s a political statement.)
Ideas
- (Suggested by Laura) Example apps in Kitten covering the 7 GUIs tasks: https://eugenkiss.github.io/7guis/tasks